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.
599 lines
20 KiB
Markdown
599 lines
20 KiB
Markdown
# 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.
|