# 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 app’s 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

## Recommended Model

### Separate Live Confidence From Progression Mastery

Keep `KeyStat.confidence` as a live speed metric:

```text
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:

```rust
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:

```rust
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`:

```rust
#[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 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.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 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_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 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.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

5. `src/ui/components/skill_tree.rs`
6. `src/ui/components/branch_progress_list.rs`
7. `src/ui/components/stats_dashboard.rs`
8. `src/main.rs`

### Data / Fixtures / Tests

9. `src/bin/generate_test_profiles.rs`
10. `tests/test_profile_fixtures.rs`

### Localization / Copy

11. `locales/en.yml`
12. other `locales/*.yml` files with mastery/confidence copy

### Documentation Updates

13. 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

### Recommended Order

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.
