Persist ranked-only mastery and use it consistently across progression, milestones, counts, and localized UI. Preserve mastery during history rebuilds, replay errors accurately, and generate replay-derived test profiles. Document the follow-up plan to remove the drill history cap.
20 KiB
Sticky Key Mastery With Error-Aware Promotion
Context
Adaptive progression currently treats key mastery as a live view of KeyStat.confidence >= 1.0. That causes three user-facing problems:
- A key can be announced as mastered, then fall below mastery after a later slow drill, then be announced as mastered again.
- The app’s UI copy equates "100% confidence" with mastery even though confidence is just a rolling speed ratio, not a durable progression state.
- Error rate does not participate in mastery at all, so a key can be promoted despite still being typo-prone.
This plan changes the model so that:
- mastery is a persistent progression state once earned
- promotion into mastery considers both speed and accuracy
- adaptive targeting can still use live metrics for maintenance and weak-key selection
- every place in the app that talks about "mastered" keys uses the same definition
This app is still work-in-progress and there are no real users whose persisted local data must be preserved across this change. Therefore this plan intentionally chooses the simplest implementation path:
- a clean schema cut is allowed
- no migration compatibility path is required
- old persisted local data may be reset/archived by the existing loader behavior
Goals
- Make key mastery one-way: once a key is mastered, it stays mastered.
- Require both sufficient speed and sufficiently low error rate before promotion.
- Keep progression fast enough to feel rewarding, especially early in a branch.
- Preserve existing adaptive drill behavior for weak-key focus as much as possible, except where it currently depends on non-sticky mastery semantics.
- Make UI, milestone, stats, and generated test data consistent with the new definition.
Non-Goals
- Reworking the n-gram anomaly system
- Changing drill generation weighting beyond what is needed to respect sticky mastery
- Introducing mastery decay or “maintenance required” regression
- Redesigning the skill tree structure
Recommended Model
Separate Live Confidence From Progression Mastery
Keep KeyStat.confidence as a live speed metric:
confidence = target_time_ms / filtered_time_ms
This remains useful for:
- weak-key selection
- skill diagnostics
- live per-key performance display
But progression should no longer infer mastery directly from confidence >= 1.0.
Introduce a persistent mastered-state bit per key, promoted by a new qualification function.
Mastery Qualification Function
Add a single source of truth helper for promotion:
fn qualifies_for_mastery(stat: &KeyStat, target_time_ms: f64) -> bool
Recommended default thresholds:
confidence >= 1.05sample_count >= 20error_rate_ema <= 0.05
Rationale:
1.05adds a small buffer above the target speed so keys do not oscillate near the threshold.20samples is enough to avoid accidental promotion after only a few good hits, but still fast enough for rewarding progression.0.05error EMA means the user must demonstrate reasonably clean typing before a key is promoted.
These values should be defined as named constants near the mastery logic, not scattered across the codebase:
const MASTERY_MIN_SPEED_CONFIDENCE: f64 = 1.05;
const MASTERY_MIN_SAMPLES: usize = 20;
const MASTERY_MAX_ERROR_RATE_EMA: f64 = 0.05;
Sticky Mastery
Once a key meets the mastery qualification function, mark it mastered permanently in persisted ranked progression data.
There is no demotion path.
Live confidence and error rate continue to update normally after mastery, but they no longer remove the mastered state.
Data Model Changes
1. Add Persistent Mastery State
File: src/engine/key_stats.rs
Extend KeyStat:
#[serde(default)]
pub mastered: bool,
Behavior:
- defaults to
false - serialized with key stats
- once set to
true, never reset by normal drill updates
Do not infer sticky mastery from confidence >= 1.0 at read time. It must be an explicit persisted field.
2. Add Accessors For Mastery Semantics
File: src/engine/key_stats.rs
Add methods:
pub fn is_mastered(&self, key: char) -> boolpub fn qualifies_for_mastery(&self, key: char) -> boolpub fn mastered_count(&self) -> usizeif helpful
qualifies_for_mastery() should read the current KeyStat and apply the thresholds above.
is_mastered() should return the sticky persisted bit only.
2a. Explicit Gate Semantics
The promotion gate uses:
sample_countas correct timed samples onlyerror_rate_emaas the accuracy gate across all ranked attempts
Do not redefine sample_count to include errors for this change.
Rationale:
sample_countalready represents timed successful executions, which is the right basis for a speed-confidence gateerror_rate_emaalready captures whether the user is making too many mistakes overall- keeping these semantics avoids broader churn in the stats model
3. Promotion During Ranked Updates
File: src/engine/key_stats.rs
After each ranked correct or error update, evaluate whether the updated key now qualifies for mastery. If so, set stat.mastered = true.
Important:
- errors alone can never unmaster a key
- correct strokes can promote a key
- error strokes do not increment
sample_count; they only affect the accuracy side of the gate viaerror_rate_ema confidenceitself remains derived from correct timed strokes only; errors participate in promotion only through the accuracy gate, not by directly changing speed confidence
Implementation detail:
- easiest is to centralize promotion logic in a helper called by both
update_key()andupdate_key_error() - qualification uses the latest
confidence,sample_count, anderror_rate_ema
4. Ranked Data Remains The Source Of Truth For Progression
Adaptive progression should continue using ranked_key_stats, not unranked/global stats, for:
- unlocking
- completed branches
- mastery popups
- all-keys-mastered milestone
Unranked key_stats can continue to power broader stats views, but anything progression-related must use ranked sticky mastery.
Implementation choice for simplicity:
- store
mastered: booldirectly onKeyStat - allow both ranked and unranked stores to deserialize that field because they share the type
- but only
ranked_key_statsis allowed to drive progression, milestones, unlocked counts, branch completion, and mastered UI summaries
To reduce accidental misuse:
- add helper accessors on
KeyStatsStore - update progression/UI code to call those helpers rather than reading
stat.mastereddirectly - avoid adding any codepath that promotes mastery on unranked
key_stats - add a short code comment near
KeyStat.masteredand/orKeyStatsStore::is_mastered()stating that sticky mastery is only authoritative when read fromranked_key_stats
Progression Logic Changes
1. Replace confidence >= 1.0 Checks With Sticky Mastery
File: src/engine/skill_tree.rs
Every progression check that currently uses stats.get_confidence(ch) >= 1.0 must change to stats.is_mastered(ch).
This includes:
- lowercase unlock gating
- lowercase branch completion
- non-lowercase level completion
- branch completion
- focused-key filtering when deciding whether a branch/level is already complete
- total mastered counts and branch mastered counts
Specifically update:
weakest_key()update()update_lowercase()update_branch_level()- all helper counters that currently mean “confident”
2. Rename Internal Helper Concepts From “Confident” To “Mastered” Where They Now Mean Sticky Mastery
File: src/engine/skill_tree.rs
Today the code mixes “confidence” and “mastery” terminology. After this change:
- helpers that count persistent progression state should use
masteredin names - helpers that inspect live speed ratio should continue to use
confidence
Examples:
branch_confident_keys()should be renamed tobranch_mastered_keys()total_confident_keys()should be renamed tototal_mastered_keys()
Preferred implementation approach:
- do the full rename in place during this change
- do not keep temporary compatibility helper names unless they are truly required to get the refactor through the compiler
If any temporary compatibility names are introduced during implementation, remove them before merge.
3. Rework newly_mastered Detection
File: src/engine/skill_tree.rs
Current logic detects mastery by threshold crossing on live confidence. Replace it with sticky-bit transitions:
- snapshot
before_stats.is_mastered(ch)for unlocked keys - after updates,
newly_mastered= keys wherebefore == false && after == true
This guarantees a mastery popup can happen at most once per key.
4. Rework all_keys_mastered
File: src/engine/skill_tree.rs
all_keys_mastered should mean:
- every progression key in every branch has sticky mastery
- and that condition became true during this update
Do not define it as “all branches Complete because confidence currently happens to be above 1.0”.
Branch completion will already become sticky once level advancement uses sticky mastery, so this may remain branch-status based after the upstream changes. Document this explicitly in code comments.
Explicit invariant:
- within the new schema, a completed branch must always imply
mastered == truefor every progression key in that branch
Adaptive Drill Behavior
1. Focus Selection Should Ignore Stickily Mastered Keys
File: src/engine/skill_tree.rs
Once a key is mastered, it should no longer block branch progression or become the focused key for unlock progression.
So weakest_key() should filter on !stats.is_mastered(ch) rather than confidence < 1.0.
2. Keep Live Confidence Available For Secondary Diagnostics
No change is required to drill generation formulas beyond the filter above.
The app may still display a mastered key’s live confidence below 100% in advanced stats if desired, but progression and milestone systems must not use that live value to revoke mastery.
UI and UX Consistency Changes
The app currently uses “mastered” to mean “confidence >= 1.0” in multiple screens. All of these must be updated to mean sticky mastery.
1. Skill Tree Main List
Files:
src/ui/components/skill_tree.rssrc/ui/components/branch_progress_list.rssrc/ui/components/stats_dashboard.rssrc/main.rs
Update all aggregate mastered counts to use sticky mastery derived from ranked key stats.
Required changes:
- overall key progress counts
- per-branch mastered counts
- dual-segment progress bars where the bright segment currently means “confidence >= 1.0”
- any branch completion labels that depend on key counts
2. Skill Tree Detail Panel Per-Key Bars
File: src/ui/components/skill_tree.rs
Current behavior clamps confidence to 100% and marks keys as mastered when confidence >= 1.0.
Replace with:
- mastered state indicator based on
stats.is_mastered(key) - progress bar fill based on a new display metric
Recommended display approach:
- use live confidence for the bar fill, clamped to 100%, because it still communicates progress toward promotion
- use sticky mastery for the mastered coloring/icon/state
This gives:
- a key can show as mastered permanently
- the live bar can still reflect current speed if needed
Concrete behavior recommendation:
bar_pct = min(confidence / MASTERY_MIN_SPEED_CONFIDENCE, 1.0)for non-mastered keys- mastered keys always display as full bar with mastered color
This avoids the confusing situation where the app says “mastered” while rendering a partially filled mastery bar.
3. Keyboard Explorer / Keyboard Detail Pane
File: src/main.rs
The keyboard detail pane currently labels ranked per-key progress as “Mastery” using the same live-confidence semantics.
Update it to:
- show sticky mastery status separately
- always show a secondary live-performance line labeled
Current speed confidence
Recommended compact rendering:
Mastery: MasteredorMastery: In progressCurrent speed confidence: [bar] 82%
If space constraints make that hard, prefer keeping Mastery sticky and shrinking other detail content rather than dropping the live metric entirely.
4. Milestone Overlays
Files:
src/app.rssrc/main.rs- locale files under
locales/
Update mastery-related milestone semantics:
MilestoneKind::Masteryshould fire once per key everAllKeysMasteredshould mean every progression key earned sticky mastery
Copy updates:
- stop saying “This key is now at full confidence!”
- say “This key is now mastered.” or equivalent
- stop saying “Every key is at maximum confidence” for the final milestone
- say “Every key has been mastered.”
5. Dashboard / Progress Summary Copy
Files:
locales/en.yml- all translated locale files
Audit every string that currently equates mastery with full confidence. Update terminology so:
masteredmeans persistent progression masteryconfidencemeans the live metric
Examples that must change:
all_keys_confidentkeep_practicing_masteryconfidence_completeall_mastered_descmastery_msg_*- adaptive intro text that says keys unlock “as you type them with confidence”
Locale policy for this change:
- update all locale files in the same PR
Do not leave mixed semantics where English says “mastered” but other locales still describe the same state as “full confidence”.
Persistence, Import, Export, and Replay
1. Schema Compatibility
Files:
src/store/schema.rs- any import/export compatibility logic
Because KeyStat is persisted, adding mastered: bool requires compatibility handling.
Chosen approach for this change:
- add
#[serde(default)]onmastered - bump
SCHEMA_VERSION - rely on the app’s existing clean-break reset/archive behavior on schema mismatch
This is intentionally acceptable because there are no real users whose local state must be preserved.
2. Migration / Rebuild Strategy
No migration path is required.
For all newly written data after this change:
- sticky mastery is persisted directly
- replay from
drill_historymust remain consistent with live progression logic for the retained history window and for all freshly generated post-cut profiles within the new schema
When validating replay behavior, verify against the app’s actual retained-history model rather than assuming idealized full-history reconstruction.
There is no need for:
- one-time heuristic migration
- backward-compat reconstruction from old ranked stats
- preservation of old progression state across schema versions
3. Fix Replay To Include Errors
File: src/app.rs
Current rebuild_from_history() replays only ranked correct strokes into ranked_key_stats.
That must change. Ranked replay must process:
- correct strokes via
update_key - incorrect strokes via
update_key_error
Otherwise sticky mastery reconstructed from history will diverge from live progression logic.
This is a required part of the plan, not optional cleanup.
4. Export / Test Fixtures
Files:
src/bin/generate_test_profiles.rstests/test_profile_fixtures.rs
Generated fixture profiles currently assume mastery is confidence >= 1.0.
Update fixture generation so mastered keys satisfy the new promotion function:
- confidence above mastery threshold
- sufficient samples
- low error rate
mastered: true
Update invariants accordingly:
- completed-branch keys should have
mastered == true - in-progress keys may have high confidence but
mastered == falseif they do not meet all criteria
Also revisit “near mastery” helper profiles so they sit just below the new promotion gate in a realistic way, likely by:
- confidence just below
1.05, or - sample count just below
20, or - error rate just above threshold
Pick one and use it consistently in tests.
The standalone generator binary must produce data in the new schema/data format.
Explicitly verify:
generate_test_profileswritesmastered: true/falseconsistently with the new rules- generated ranked stats satisfy the new sticky-mastery semantics
- generated profiles do not rely on legacy
confidence >= 1.0-only assumptions
Test Plan
1. src/engine/key_stats.rs
Add unit tests for:
- key is not mastered by confidence alone if
error_rate_emais too high - key is not mastered by confidence alone if
sample_countis too low - key becomes mastered when all thresholds are met
- mastered bit stays true after later slow correct strokes
- mastered bit stays true after later errors
- deserializing old JSON without
mastereddefaults tofalse
2. src/engine/skill_tree.rs
Update and add tests for:
- lowercase unlock uses sticky mastery, not raw confidence crossing
- branch level completion uses sticky mastery
newly_masteredfires on sticky transition, not confidence crossingweakest_key()ignores already-mastered keys even if their live confidence later dropsall_keys_masteredstill fires once- replay-based rebuild produces the same sticky mastery outcome as live updates for the retained-history window represented in the test fixture
Existing tests that rely on “one fast hit pushes confidence over 1.0” will need to be rewritten for the new promotion thresholds.
3. src/app.rs
Update milestone and end-to-end progression tests:
- a key can only emit one mastery milestone across repeated drills
- unlock ordering relative to mastery milestones still works
- all-keys-mastered popup still queues exactly once
- replay from history reconstructs mastered state correctly
4. UI-Oriented Tests
Where snapshot/string tests exist, update them so:
- mastered counts reflect sticky mastery
- copy no longer says “full confidence” when it means mastery
5. Manual Verification
Use fresh and near-complete test profiles to verify:
- early lowercase progression still feels fast
- a key with good speed but sloppy accuracy does not promote
- once promoted, a key never loses mastered status in the skill tree
- repeated “Key Mastered!” popups no longer occur for the same key
- branch completion and all-keys-mastered milestones still trigger correctly
- imported/replayed profiles preserve expected mastery state
File-by-File Implementation Checklist
Core Logic
src/engine/key_stats.rssrc/engine/skill_tree.rssrc/app.rssrc/store/schema.rs
UI / Rendering
src/ui/components/skill_tree.rssrc/ui/components/branch_progress_list.rssrc/ui/components/stats_dashboard.rssrc/main.rs
Data / Fixtures / Tests
src/bin/generate_test_profiles.rstests/test_profile_fixtures.rs
Localization / Copy
locales/en.yml- other
locales/*.ymlfiles with mastery/confidence copy
Documentation Updates
- update any in-repo docs or plan comments that still define mastery as
confidence >= 1.0
At minimum audit:
docs/plans/2026-02-15-skill-tree-progression-system.mddocs/plans/2026-02-20-key-milestone-overlays-keyboard-diagram-improvements.mddocs/plans/2026-02-28-skill-tree-milestone-popups.mddocs/plans/2026-02-27-generated-user-profile-data-for-testing.mddocs/plans/2026-02-22-n-gram-error-tracking-adaptive-drill-selection.md
These do not all need functional changes, but stale statements about mastery should be corrected if those docs are still used as implementation references.
Rollout Notes
Recommended Order
- Add sticky mastery field and qualification helpers in
key_stats - Update ranked replay logic so persisted state can be rebuilt correctly
- Switch skill tree progression from confidence-threshold semantics to sticky mastery
- Update milestone detection
- Update UI counters and labels
- Update fixtures/tests
- Update copy/locales
Risk Areas
- tests and fixtures that currently assume confidence alone defines mastery
- UI confusion if sticky mastery and live confidence are mixed without clear labels
Acceptance Criteria
- A key that earns mastery never loses mastered status afterward.
- A key cannot earn mastery unless it satisfies speed, accuracy, and minimum-sample thresholds.
- The same key cannot emit multiple mastery milestones across separate drills.
- Skill tree unlocking, branch completion, and all-keys-mastered progression remain correct after replay from history.
- All user-facing uses of “mastered” now mean sticky mastery, not merely current confidence.
generate_test_profilesemits profiles in the new schema/format and the fixture tests pass against them.cargo testpasses with updated fixtures and progression tests.