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.
# Skill Tree Progression System & Whitespace Support
> **Note (2026-04-18):** Mastery/progression gating has since moved from "confidence >= 1.0" to a persistent `mastered: bool` bit with a speed + samples + error-rate gate. See `docs/plans/2026-04-18-sticky-key-mastery-with-error-aware-promotion.md` for the authoritative rules. Statements below that equate confidence ≥ 1.0 with mastery are historical.
## Context
keydr currently tracks only a-z lowercase letters in its adaptive unlock system. Since keydr aims to be a coding-focused typing tutor, it must also train capitals, numbers, punctuation, whitespace (tabs/newlines), and code-specific symbols. The current flat a-z progression needs to be replaced with a branching skill tree that lets players choose their training path after mastering lowercase letters. Additionally, code drills currently strip newlines into spaces, making them unrealistic for real-world code practice.
> **Note (2026-04-18):** Mastery is now tracked by a persistent `mastered: bool` bit gated on speed + samples + error-rate — see `docs/plans/2026-04-18-sticky-key-mastery-with-error-aware-promotion.md`. Copy like "now at full confidence" has been replaced with "now mastered" in the live locales; references below are historical.
## Context
The app progressively unlocks keys as users master them via the skill tree system. Currently, when a key is unlocked or mastered, there's no celebratory feedback. This plan adds encouraging milestone overlays with keyboard visualization and finger guidance. It also improves the keyboard diagram to render modifier keys (shift, tab, enter, space, backspace) as interactive keys rather than static labels, and adds a new Keyboard Explorer screen.
# N-gram Error Tracking for Adaptive Drill Selection
> **Note (2026-04-18):** Key mastery is now a sticky `mastered: bool` bit, not a live `confidence >= 1.0` check — see `docs/plans/2026-04-18-sticky-key-mastery-with-error-aware-promotion.md`. The `confidence > 1.0` language below is still accurate as a description of the live speed ratio, but "mastered" no longer equals confidence ≥ 1.0.
## Context
keydr currently tracks typing errors at the single-character level only. The adaptive algorithm picks the weakest character by confidence score and biases drill text to include words containing that character. This misses **transition difficulties** -- sequences where individual characters are easy but the combination is hard (e.g., same-finger bigrams, awkward hand transitions). Research strongly supports that these transition effects are real and distinct from single-character difficulty.
# Plan: Create Test User Profiles at Various Skill Tree Progression Levels
> **Note (2026-04-18):** Completed-branch invariants now require `mastered == true` (a sticky bit) rather than `confidence >= 1.0`. The fixture generator and test invariants were updated accordingly — see `docs/plans/2026-04-18-sticky-key-mastery-with-error-aware-promotion.md` for the authoritative rules (speed + samples + error-rate gate). Statements below are historical.
## Context
We need importable JSON test profiles representing users at every meaningful stage of skill tree progression. Each profile must have internally consistent key stats, drill history, and skill tree state so the app behaves as if a real user reached that level. The profiles will be used for manual regression testing of UI and logic at each progression stage.
> **Note (2026-04-18):** "Mastery" is now a persistent `mastered: bool` bit gated on speed (`confidence >= 1.05`) + samples (`>= 20`) + error-rate EMA (`<= 0.05`) — see `docs/plans/2026-04-18-sticky-key-mastery-with-error-aware-promotion.md`. Milestone popups key off the sticky bit, so each key/branch fires at most once. Statements below referencing "at full confidence" or "confidence >= 1.0" are historical.
## Context
When users reach major skill tree milestones, they should see celebratory popups explaining what they've achieved and what's next. Four milestone types:
The app currently truncates persisted `drill_history` to the most recent 500 drills in both:
-`finish_drill()`
-`finish_partial_drill()`
This is implemented in `src/app.rs` by removing the oldest history entry once `drill_history.len() > 500`.
The original motivation for revisiting this came from the sticky-mastery work, where replay correctness becomes more important. But this history-cap issue is broader than mastery and should be handled as a separate task.
## Current Findings
### 1. The cap affects persisted data, not just UI
`lesson_history.json` only retains the most recent 500 entries because the in-memory history is pruned before save.
### 2. Full-history replay already exists in some paths
The app already walks all retained `drill_history` in several places:
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:
-`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`
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.
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.