Files
keydr/docs/plans/2026-04-18-sticky-key-mastery-with-error-aware-promotion.md
T
thallada 5daa644d3b feat: add sticky error-aware key mastery
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.
2026-08-12 01:09:52 -04:00

20 KiB
Raw Blame History

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:

  1. A key can be announced as mastered, then fall below mastery after a later slow drill, then be announced as mastered again.
  2. The apps UI copy equates "100% confidence" with mastery even though confidence is just a rolling speed ratio, not a durable progression state.
  3. 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

  1. Make key mastery one-way: once a key is mastered, it stays mastered.
  2. Require both sufficient speed and sufficiently low error rate before promotion.
  3. Keep progression fast enough to feel rewarding, especially early in a branch.
  4. Preserve existing adaptive drill behavior for weak-key focus as much as possible, except where it currently depends on non-sticky mastery semantics.
  5. 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

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.05
  • sample_count >= 20
  • error_rate_ema <= 0.05

Rationale:

  • 1.05 adds a small buffer above the target speed so keys do not oscillate near the threshold.
  • 20 samples is enough to avoid accidental promotion after only a few good hits, but still fast enough for rewarding progression.
  • 0.05 error 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) -> bool
  • pub fn qualifies_for_mastery(&self, key: char) -> bool
  • pub fn mastered_count(&self) -> usize if 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_count as correct timed samples only
  • error_rate_ema as the accuracy gate across all ranked attempts

Do not redefine sample_count to include errors for this change.

Rationale:

  • sample_count already represents timed successful executions, which is the right basis for a speed-confidence gate
  • error_rate_ema already 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 via error_rate_ema
  • confidence itself 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() and update_key_error()
  • qualification uses the latest confidence, sample_count, and error_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: bool directly on KeyStat
  • allow both ranked and unranked stores to deserialize that field because they share the type
  • but only ranked_key_stats is 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.mastered directly
  • avoid adding any codepath that promotes mastery on unranked key_stats
  • add a short code comment near KeyStat.mastered and/or KeyStatsStore::is_mastered() stating that sticky mastery is only authoritative when read from ranked_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 mastered in names
  • helpers that inspect live speed ratio should continue to use confidence

Examples:

  • branch_confident_keys() should be renamed to branch_mastered_keys()
  • total_confident_keys() should be renamed to total_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 where before == 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 == true for 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 keys 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.rs
  • src/ui/components/branch_progress_list.rs
  • src/ui/components/stats_dashboard.rs
  • src/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: Mastered or Mastery: In progress
  • Current 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.rs
  • src/main.rs
  • locale files under locales/

Update mastery-related milestone semantics:

  • MilestoneKind::Mastery should fire once per key ever
  • AllKeysMastered should 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:

  • mastered means persistent progression mastery
  • confidence means the live metric

Examples that must change:

  • all_keys_confident
  • keep_practicing_mastery
  • confidence_complete
  • all_mastered_desc
  • mastery_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)] on mastered
  • bump SCHEMA_VERSION
  • rely on the apps 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_history must 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 apps 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.rs
  • tests/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 == false if 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_profiles writes mastered: true/false consistently 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_ema is too high
  • key is not mastered by confidence alone if sample_count is 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 mastered defaults to false

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_mastered fires on sticky transition, not confidence crossing
  • weakest_key() ignores already-mastered keys even if their live confidence later drops
  • all_keys_mastered still 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:

  1. early lowercase progression still feels fast
  2. a key with good speed but sloppy accuracy does not promote
  3. once promoted, a key never loses mastered status in the skill tree
  4. repeated “Key Mastered!” popups no longer occur for the same key
  5. branch completion and all-keys-mastered milestones still trigger correctly
  6. imported/replayed profiles preserve expected mastery state

File-by-File Implementation Checklist

Core Logic

  1. src/engine/key_stats.rs
  2. src/engine/skill_tree.rs
  3. src/app.rs
  4. src/store/schema.rs

UI / Rendering

  1. src/ui/components/skill_tree.rs
  2. src/ui/components/branch_progress_list.rs
  3. src/ui/components/stats_dashboard.rs
  4. src/main.rs

Data / Fixtures / Tests

  1. src/bin/generate_test_profiles.rs
  2. tests/test_profile_fixtures.rs

Localization / Copy

  1. locales/en.yml
  2. other locales/*.yml files with mastery/confidence copy

Documentation Updates

  1. 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.md
  • docs/plans/2026-02-20-key-milestone-overlays-keyboard-diagram-improvements.md
  • docs/plans/2026-02-28-skill-tree-milestone-popups.md
  • docs/plans/2026-02-27-generated-user-profile-data-for-testing.md
  • docs/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

  1. Add sticky mastery field and qualification helpers in key_stats
  2. Update ranked replay logic so persisted state can be rebuilt correctly
  3. Switch skill tree progression from confidence-threshold semantics to sticky mastery
  4. Update milestone detection
  5. Update UI counters and labels
  6. Update fixtures/tests
  7. 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

  1. A key that earns mastery never loses mastered status afterward.
  2. A key cannot earn mastery unless it satisfies speed, accuracy, and minimum-sample thresholds.
  3. The same key cannot emit multiple mastery milestones across separate drills.
  4. Skill tree unlocking, branch completion, and all-keys-mastered progression remain correct after replay from history.
  5. All user-facing uses of “mastered” now mean sticky mastery, not merely current confidence.
  6. generate_test_profiles emits profiles in the new schema/format and the fixture tests pass against them.
  7. cargo test passes with updated fixtures and progression tests.