Compare commits

...
2 Commits
Author SHA1 Message Date
thallada c43454e214 Add reviews to gitignore 2026-08-12 01:09:52 -04:00
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
48 changed files with 2684 additions and 722 deletions
+1
View File
@@ -1,3 +1,4 @@
/target
/clones/
/test-profiles/
docs/reviews
+17 -7
View File
@@ -1,9 +1,7 @@
use criterion::{Criterion, black_box, criterion_group, criterion_main};
use keydr::engine::key_stats::KeyStatsStore;
use keydr::engine::ngram_stats::{
BigramKey, BigramStatsStore, extract_ngram_events,
};
use keydr::engine::ngram_stats::{BigramKey, BigramStatsStore, extract_ngram_events};
use keydr::session::result::KeyTime;
fn make_keystrokes(count: usize) -> Vec<KeyTime> {
@@ -53,6 +51,10 @@ fn bench_focus_selection(c: &mut Criterion) {
let mut bigram_stats = BigramStatsStore::default();
let mut char_stats = KeyStatsStore::default();
// Char baselines: 5% error rate, 430ms per char. Expected independent bigram
// error rate is therefore 1 - 0.95^2 = 0.0975, so a bigram needs an error_rate_ema
// above ~0.146 to clear the 1.5x error-anomaly ratio threshold, and a
// filtered_time_ms above ~645 to clear the 50% speed-anomaly threshold.
for &ch in &all_chars {
let stat = char_stats.stats.entry(ch).or_default();
stat.confidence = 0.8;
@@ -60,6 +62,7 @@ fn bench_focus_selection(c: &mut Criterion) {
stat.sample_count = 50;
stat.total_count = 50;
stat.error_count = 3;
stat.error_rate_ema = 0.05;
}
let mut count: usize = 0;
@@ -70,10 +73,15 @@ fn bench_focus_selection(c: &mut Criterion) {
}
let key = BigramKey([a, b]);
let stat = bigram_stats.stats.entry(key).or_default();
stat.confidence = 0.5 + (count % 50) as f64 * 0.01;
// Spread values so roughly half the entries clear each anomaly threshold,
// exercising both the error and speed candidate paths.
stat.error_rate_ema = 0.05 + (count % 20) as f64 * 0.01;
stat.filtered_time_ms = 500.0 + (count % 40) as f64 * 10.0;
stat.sample_count = 25 + count % 30;
stat.error_count = 5 + count % 10;
stat.redundancy_streak = if count % 3 == 0 { 3 } else { 1 };
// Streak >= 3 confirms an anomaly; mix confirmed and unconfirmed entries.
stat.error_anomaly_streak = if count % 3 == 0 { 3 } else { 1 };
stat.speed_anomaly_streak = if count % 4 == 0 { 3 } else { 0 };
count += 1;
}
}
@@ -81,8 +89,10 @@ fn bench_focus_selection(c: &mut Criterion) {
let unlocked: Vec<char> = all_chars;
c.bench_function("weakest_bigram (3K entries)", |b| {
b.iter(|| bigram_stats.weakest_bigram(black_box(&char_stats), black_box(&unlocked)))
c.bench_function("worst_confirmed_anomaly (3K entries)", |b| {
b.iter(|| {
bigram_stats.worst_confirmed_anomaly(black_box(&char_stats), black_box(&unlocked))
})
});
}
@@ -1,5 +1,7 @@
# 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.
@@ -1,5 +1,7 @@
# Plan: Key Milestone Overlays + Keyboard Diagram Improvements
> **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.
@@ -1,5 +1,7 @@
# 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.
@@ -1,5 +1,7 @@
# 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.
@@ -1,5 +1,7 @@
# Skill Tree Milestone Popups
> **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:
@@ -0,0 +1,96 @@
# Remove Drill History Cap
## Context
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:
- startup: `App::new()` calls `rebuild_ngram_stats()`
- import: imported `drill_history` is followed by `rebuild_ngram_stats()`
- delete-history-entry: `rebuild_from_history()` replays remaining history, then calls `rebuild_ngram_stats()`
So removing the cap is not just a data-retention change. It also changes the amount of work done by:
- startup
- import
- delete-history rebuilds
### 3. Saves rewrite the full history file
After each completed or partial drill, `save_data()` writes the entire `drill_history` back to `lesson_history.json`.
That means uncapping history will likely increase:
- per-drill save latency
- JSON serialization cost
- disk usage
## Questions To Resolve Later
1. Should history become fully uncapped immediately, or should there be a configurable retention policy?
2. Is JSON blob storage still acceptable for very large histories, or should history move to a more append-friendly format?
3. Should startup continue rebuilding n-gram state from full history on every launch, or should the app persist derived caches?
4. Is delete-history-entry expected to remain a full rebuild operation, or should that workflow be redesigned for large histories?
5. Do we want the exported data format to always include full history, even if local persistence later changes format?
## Likely Scope
At minimum, this future plan will need to cover:
### Data retention behavior
- remove the 500-entry truncation logic in `src/app.rs`
- verify `save_drill_history()` persists full history
- update stale comments/docs that still describe history as capped
### Performance analysis
- startup replay cost from `rebuild_ngram_stats()`
- import cost
- delete-history rebuild cost
- save latency from rewriting the full file every drill
### Possible optimization directions
- keep full history but persist derived n-gram caches
- keep full history but move storage away from one monolithic JSON blob
- keep full history but make rebuilds incremental where possible
## Non-Goals For This Stub
- choosing the final storage redesign now
- changing history persistence in the mastery plan
- making performance guarantees before measuring realistic history sizes
## Files Likely Involved
- `src/app.rs`
- `src/store/json_store.rs`
- `src/store/schema.rs`
- `docs/plans/2026-02-09-initial-plan.md`
- `docs/plans/2026-02-22-n-gram-error-tracking-adaptive-drill-selection.md`
## Acceptance Criteria For The Future Task
To be defined in the full plan after deeper investigation, but likely to include:
1. users retain full drill history
2. startup performance remains acceptable
3. per-drill save latency remains acceptable
4. replay-derived features remain correct with large histories
@@ -0,0 +1,598 @@
# 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
## 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 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
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.
+17 -6
View File
@@ -319,13 +319,13 @@ milestones:
open_skill_tree: 'Stiskni [t] pro otevreni stromu dovednosti'
branch_complete_msg: 'Dokoncil jsi vetev %{branch}!'
all_levels_mastered: 'Vsech %{count} urovni zvladnuto.'
all_keys_confident: 'Kazda klavesa v teto vetvi je na plne jistote.'
all_keys_confident: 'Kazda klavesa v teto vetvi byla zvladnuta.'
all_unlocked_msg: 'Odemkl jsi kazdou klavesu na klavesnici!'
all_unlocked_desc: 'Kazdy znak, symbol a modifikator je nyni dostupny ve tvych cvicenich.'
keep_practicing_mastery: 'Pokracuj v cviceni pro budovani zbehlosti — az kazda klavesa dosahne plne'
confidence_complete: 'jistoty, dosahnes uplneho zvladnuti klavesnice!'
keep_practicing_mastery: 'Pokracuj v cviceni — az bude kazda klavesa zvladnuta,'
confidence_complete: 'dosahnes uplneho zvladnuti klavesnice!'
all_mastered_msg: 'Gratulujeme — dosahl jsi uplneho zvladnuti klavesnice!'
all_mastered_desc: 'Kazda klavesa na klavesnici je na maximalni jistote.'
all_mastered_desc: 'Kazda klavesa na klavesnici byla zvladnuta.'
mastery_takes_practice: 'Zbehlost neni cil — vyzaduje prubezne cviceni.'
keep_drilling: 'Pokracuj v cviceni pro udrzeni sve urovne.'
hint_skill_tree_continue: 'Otevrit strom [Jina klavesa] Pokracovat'
@@ -335,16 +335,24 @@ milestones:
unlock_msg_2: 'Dalsi klavesa ve tvem arsenalu!'
unlock_msg_3: 'Tvoje klavesnice roste! Tak drzet.'
unlock_msg_4: 'O krok bliz k uplnemu zvladnuti klavesnice!'
mastery_msg_1: 'Tato klavesa je nyni na plne jistote!'
unlock_msg_plural_1: 'Skvela prace! Pokracuj v rozvoji svych dovednosti.'
unlock_msg_plural_2: 'Dalsi klavesy ve tvem arsenalu!'
unlock_msg_plural_3: 'Tvoje klavesnice roste! Tak drzet.'
unlock_msg_plural_4: 'O nekolik kroku bliz k uplnemu zvladnuti klavesnice!'
mastery_msg_1: 'Tato klavesa je nyni zvladnuta.'
mastery_msg_2: 'Tuto klavesu mas v malicku!'
mastery_msg_3: 'Svalova pamet uzamcena!'
mastery_msg_4: 'Dalsi klavesa pokorena!'
mastery_msg_plural_1: 'Tyto klavesy jsou nyni zvladnuty.'
mastery_msg_plural_2: 'Tyto klavesy mas v malicku!'
mastery_msg_plural_3: 'Svalova pamet uzamcena!'
mastery_msg_plural_4: 'Dalsi klavesy pokoreny!'
# Adaptive drill intro overlay
adaptive_intro:
title: ' Vitejte v keydr! '
how_it_works: 'Jak funguje adaptivni cviceni:'
description: 'Zacinas s malou sadou nejbeznejsich odemcenych pismen. Cviceni generuje pouze slova, ktera pouzivaji tato pismena. Jak je pises s jistotou, nove klavesy jsou postupne odemykany, az zvladnes celou klavesnici.'
description: 'Zacinas s malou sadou nejbeznejsich odemcenych pismen. Cviceni generuje pouze slova, ktera pouzivaji tato pismena. Jak je zvladnes, nove klavesy jsou postupne odemykany, az zvladnes celou klavesnici.'
target_wpm_label: 'Cilovy WPM:'
target_wpm_desc: 'Ovlivnuje rychlost odemykani klaves — vyssi cile vyzaduji rychlejsi psani. 35 WPM je dobry vychozi bod, pokud si nejsi jisty. Toto muzes kdykoli zmenit v nastaveni.'
hint_adjust: 'Upravit WPM'
@@ -389,6 +397,9 @@ keyboard:
no: 'Ne'
in_focus_label: 'V zamereni?: '
mastery_label: 'Zbehlost: '
mastery_mastered: 'Zvladnuto'
mastery_in_progress: 'Probiha'
speed_confidence_label: 'Jistota rychlosti: '
mastery_locked: 'Zamcena'
ranked_avg_time: 'Hodnoceny prum cas: '
ranked_best_time: 'Hodnoceny nejl cas: '
+17 -6
View File
@@ -319,13 +319,13 @@ milestones:
open_skill_tree: 'Tryk [t] for at aabne Faerdighedstraeet nu'
branch_complete_msg: 'Du har fuldfaort grenen %{branch}!'
all_levels_mastered: 'Alle %{count} niveauer mestrede.'
all_keys_confident: 'Hver tast i denne gren har fuld tillid.'
all_keys_confident: 'Hver tast i denne gren er mestret.'
all_unlocked_msg: 'Du har laast hver tast paa tastaturet op!'
all_unlocked_desc: 'Hvert tegn, symbol og modifikator er nu tilgaengelig i dine oevelser.'
keep_practicing_mastery: 'Bliv ved med at oeve for at opbygge mestring — naar hver tast naar fuld'
confidence_complete: 'tillid, har du opnaaat fuldstaendig tastaturmestring!'
keep_practicing_mastery: 'Bliv ved med at oeve — naar hver tast er mestret,'
confidence_complete: 'har du opnaaet fuldstaendig tastaturmestring!'
all_mastered_msg: 'Tillykke — du har opnaaat fuldstaendig tastaturmestring!'
all_mastered_desc: 'Hver tast paa tastaturet har maksimal tillid.'
all_mastered_desc: 'Hver tast paa tastaturet er mestret.'
mastery_takes_practice: 'Mestring er ikke en destination — det kraever vedvarende oevelse.'
keep_drilling: 'Bliv ved med at oeve for at bevare dit niveau.'
hint_skill_tree_continue: 'Faerdighedstrae [Anden tast] Fortsaet'
@@ -335,16 +335,24 @@ milestones:
unlock_msg_2: 'Endnu en tast i dit arsenal!'
unlock_msg_3: 'Dit tastatur vokser! Bliv ved.'
unlock_msg_4: 'Et skridt naermere fuld tastaturmestring!'
mastery_msg_1: 'Denne tast har nu fuld tillid!'
unlock_msg_plural_1: 'Godt klaret! Bliv ved med at opbygge dine skrivefaerdigheder.'
unlock_msg_plural_2: 'Flere taster i dit arsenal!'
unlock_msg_plural_3: 'Dit tastatur vokser! Bliv ved.'
unlock_msg_plural_4: 'Flere skridt naermere fuld tastaturmestring!'
mastery_msg_1: 'Denne tast er nu mestret.'
mastery_msg_2: 'Du mestrer denne tast perfekt!'
mastery_msg_3: 'Muskelhukommelse forankret!'
mastery_msg_4: 'Endnu en tast erobret!'
mastery_msg_plural_1: 'Disse taster er nu mestret.'
mastery_msg_plural_2: 'Du mestrer disse taster perfekt!'
mastery_msg_plural_3: 'Muskelhukommelse forankret!'
mastery_msg_plural_4: 'Flere taster erobret!'
# Adaptive drill intro overlay
adaptive_intro:
title: ' Velkommen til keydr! '
how_it_works: 'Saadan fungerer adaptive oevelser:'
description: 'Du starter med et lille saet af de mest almindelige bogstaver laast op. Oevelsen genererer kun ord, der bruger disse bogstaver. Naar du skriver dem med selvtillid, laases nye taster gradvist op, indtil du har mestret hele tastaturet.'
description: 'Du starter med et lille saet af de mest almindelige bogstaver laast op. Oevelsen genererer kun ord, der bruger disse bogstaver. Naar du mestrer dem, laases nye taster gradvist op, indtil du har mestret hele tastaturet.'
target_wpm_label: 'Maal-WPM:'
target_wpm_desc: 'Dette paavirker hvor hurtigt taster laases op — hoejere maal kraever hurtigere skrivning. 35 WPM er et godt udgangspunkt, hvis du er usikker. Du kan altid aendre dette senere i indstillingerne.'
hint_adjust: 'Juster WPM'
@@ -390,6 +398,9 @@ keyboard:
in_focus_label: 'I fokus?: '
mastery_label: 'Mestring: '
mastery_locked: 'Laast'
mastery_mastered: 'Mestret'
mastery_in_progress: 'I gang'
speed_confidence_label: 'Hastighedstillid: '
ranked_avg_time: 'Rangeret gns. tid: '
ranked_best_time: 'Rangeret bedste tid: '
ranked_samples: 'Rangerede stikproever: '
+17 -6
View File
@@ -319,13 +319,13 @@ milestones:
open_skill_tree: 'Druecke [t], um den Faehigkeitenbaum zu oeffnen'
branch_complete_msg: 'Du hast den Zweig %{branch} abgeschlossen!'
all_levels_mastered: 'Alle %{count} Stufen gemeistert.'
all_keys_confident: 'Jede Taste in diesem Zweig hat volle Sicherheit.'
all_keys_confident: 'Jede Taste in diesem Zweig wurde gemeistert.'
all_unlocked_msg: 'Du hast jede Taste auf der Tastatur freigeschaltet!'
all_unlocked_desc: 'Jedes Zeichen, Symbol und jeder Modifikator ist jetzt in deinen Lektionen verfuegbar.'
keep_practicing_mastery: 'Uebe weiter, um Meisterschaft aufzubauen — wenn jede Taste volle'
confidence_complete: 'Sicherheit erreicht hat, hast du die volle Tastaturbeherrschung!'
keep_practicing_mastery: 'Uebe weiter — sobald jede Taste gemeistert ist,'
confidence_complete: 'hast du die volle Tastaturbeherrschung erreicht!'
all_mastered_msg: 'Glueckwunsch — du hast volle Tastaturbeherrschung erreicht!'
all_mastered_desc: 'Jede Taste auf der Tastatur hat maximale Sicherheit.'
all_mastered_desc: 'Jede Taste auf der Tastatur wurde gemeistert.'
mastery_takes_practice: 'Meisterschaft ist kein Ziel — sie erfordert staendiges Ueben.'
keep_drilling: 'Uebe weiter, um dein Koennen zu erhalten.'
hint_skill_tree_continue: 'Faehigkeitenbaum [Andere Taste] Weiter'
@@ -335,16 +335,24 @@ milestones:
unlock_msg_2: 'Eine weitere Taste in deinem Arsenal!'
unlock_msg_3: 'Deine Tastatur waechst! Weiter so.'
unlock_msg_4: 'Einen Schritt naeher an voller Tastaturbeherrschung!'
mastery_msg_1: 'Diese Taste hat jetzt volle Sicherheit!'
unlock_msg_plural_1: 'Gut gemacht! Baue deine Tippfaehigkeiten weiter aus.'
unlock_msg_plural_2: 'Weitere Tasten in deinem Arsenal!'
unlock_msg_plural_3: 'Deine Tastatur waechst! Weiter so.'
unlock_msg_plural_4: 'Ein paar Schritte naeher an voller Tastaturbeherrschung!'
mastery_msg_1: 'Diese Taste ist jetzt gemeistert.'
mastery_msg_2: 'Diese Taste sitzt perfekt!'
mastery_msg_3: 'Muskelgedaechtnis verankert!'
mastery_msg_4: 'Eine weitere Taste bezwungen!'
mastery_msg_plural_1: 'Diese Tasten sind jetzt gemeistert.'
mastery_msg_plural_2: 'Diese Tasten sitzen perfekt!'
mastery_msg_plural_3: 'Muskelgedaechtnis verankert!'
mastery_msg_plural_4: 'Weitere Tasten bezwungen!'
# Adaptive drill intro overlay
adaptive_intro:
title: ' Willkommen bei keydr! '
how_it_works: 'Wie adaptive Lektionen funktionieren:'
description: 'Du startest mit einer kleinen Auswahl der haeufigsten Buchstaben. Die Lektion generiert nur Woerter, die diese Buchstaben verwenden. Je sicherer du sie tippst, desto mehr neue Tasten werden schrittweise freigeschaltet, bis du die ganze Tastatur beherrschst.'
description: 'Du startest mit einer kleinen Auswahl der haeufigsten Buchstaben. Die Lektion generiert nur Woerter, die diese Buchstaben verwenden. Sobald du sie meisterst, werden weitere Tasten schrittweise freigeschaltet, bis du die ganze Tastatur beherrschst.'
target_wpm_label: 'Ziel-WPM:'
target_wpm_desc: 'Dies beeinflusst, wie schnell Tasten freigeschaltet werden — hoehere Ziele erfordern schnelleres Tippen. 35 WPM ist ein guter Ausgangspunkt, falls unsicher. Du kannst dies spaeter jederzeit in den Einstellungen aendern.'
hint_adjust: 'WPM anpassen'
@@ -390,6 +398,9 @@ keyboard:
in_focus_label: 'Im Fokus?: '
mastery_label: 'Meisterschaft: '
mastery_locked: 'Gesperrt'
mastery_mastered: 'Gemeistert'
mastery_in_progress: 'In Arbeit'
speed_confidence_label: 'Tempovertrauen: '
ranked_avg_time: 'Gewertete Schnittzeit: '
ranked_best_time: 'Gewertete Bestzeit: '
ranked_samples: 'Gewertete Stichproben: '
+17 -6
View File
@@ -319,13 +319,13 @@ milestones:
open_skill_tree: 'Press [t] to open the Skill Tree now'
branch_complete_msg: 'You''ve completed the %{branch} branch!'
all_levels_mastered: 'All %{count} levels mastered.'
all_keys_confident: 'Every key in this branch is at full confidence.'
all_keys_confident: 'Every key in this branch has been mastered.'
all_unlocked_msg: 'You''ve unlocked every key on the keyboard!'
all_unlocked_desc: 'Every character, symbol, and modifier is now available in your drills.'
keep_practicing_mastery: 'Keep practicing to build mastery — once every key reaches full'
confidence_complete: 'confidence, you''ll have achieved complete keyboard mastery!'
keep_practicing_mastery: 'Keep practicing — once every key has been mastered,'
confidence_complete: 'you''ll have achieved complete keyboard mastery!'
all_mastered_msg: 'Congratulations — you''ve reached full keyboard mastery!'
all_mastered_desc: 'Every key on the keyboard is at maximum confidence.'
all_mastered_desc: 'Every key on the keyboard has been mastered.'
mastery_takes_practice: 'Mastery is not a destination — it takes ongoing practice.'
keep_drilling: 'Keep drilling to maintain your edge.'
hint_skill_tree_continue: 'Open Skill Tree [Any other key] Continue'
@@ -335,16 +335,24 @@ milestones:
unlock_msg_2: 'Another key added to your arsenal!'
unlock_msg_3: 'Your keyboard is growing! Keep it up.'
unlock_msg_4: 'One step closer to full keyboard mastery!'
mastery_msg_1: 'This key is now at full confidence!'
unlock_msg_plural_1: 'Nice work! Keep building your typing skills.'
unlock_msg_plural_2: 'More keys added to your arsenal!'
unlock_msg_plural_3: 'Your keyboard is growing! Keep it up.'
unlock_msg_plural_4: 'Several steps closer to full keyboard mastery!'
mastery_msg_1: 'This key is now mastered.'
mastery_msg_2: 'You''ve got this key down pat!'
mastery_msg_3: 'Muscle memory locked in!'
mastery_msg_4: 'One more key conquered!'
mastery_msg_plural_1: 'These keys are now mastered.'
mastery_msg_plural_2: 'You''ve got these keys down pat!'
mastery_msg_plural_3: 'Muscle memory locked in!'
mastery_msg_plural_4: 'More keys conquered!'
# Adaptive drill intro overlay
adaptive_intro:
title: ' Welcome to keydr! '
how_it_works: 'How adaptive drills work:'
description: 'You start with a small set of the most common letters unlocked. The drill only generates words that use these letters. As you type them with confidence, new keys are gradually unlocked until you''ve mastered the full keyboard.'
description: 'You start with a small set of the most common letters unlocked. The drill only generates words that use these letters. As you master them, new keys are gradually unlocked until you''ve mastered the full keyboard.'
target_wpm_label: 'Target WPM:'
target_wpm_desc: 'This affects how quickly keys unlock — higher targets require faster typing. 35 WPM is a good starting point if unsure. You can always change this later in settings.'
hint_adjust: 'Adjust WPM'
@@ -390,6 +398,9 @@ keyboard:
in_focus_label: 'In Focus?: '
mastery_label: 'Mastery: '
mastery_locked: 'Locked'
mastery_mastered: 'Mastered'
mastery_in_progress: 'In progress'
speed_confidence_label: 'Speed confidence: '
ranked_avg_time: 'Ranked Avg Time: '
ranked_best_time: 'Ranked Best Time: '
ranked_samples: 'Ranked Samples: '
+17 -6
View File
@@ -319,13 +319,13 @@ milestones:
open_skill_tree: 'Presiona [t] para abrir el Árbol de Habilidades'
branch_complete_msg: '¡Has completado la rama %{branch}!'
all_levels_mastered: 'Los %{count} niveles dominados.'
all_keys_confident: 'Cada tecla en esta rama está a máxima confianza.'
all_keys_confident: 'Cada tecla en esta rama ha sido dominada.'
all_unlocked_msg: '¡Has desbloqueado todas las teclas del teclado!'
all_unlocked_desc: 'Cada carácter, símbolo y modificador está disponible en tus ejercicios.'
keep_practicing_mastery: 'Sigue practicando para alcanzar el dominio — cuando cada tecla llegue a'
confidence_complete: 'máxima confianza, ¡habrás logrado el dominio total del teclado!'
keep_practicing_mastery: 'Sigue practicando — cuando cada tecla haya sido dominada,'
confidence_complete: '¡habrás logrado el dominio total del teclado!'
all_mastered_msg: '¡Felicidades — has alcanzado el dominio total del teclado!'
all_mastered_desc: 'Cada tecla del teclado está a máxima confianza.'
all_mastered_desc: 'Cada tecla del teclado ha sido dominada.'
mastery_takes_practice: 'El dominio no es un destino — requiere práctica continua.'
keep_drilling: 'Sigue practicando para mantener tu nivel.'
hint_skill_tree_continue: 'Abrir Árbol de Habilidades [Otra tecla] Continuar'
@@ -335,16 +335,24 @@ milestones:
unlock_msg_2: '¡Otra tecla añadida a tu arsenal!'
unlock_msg_3: '¡Tu teclado crece! Sigue así.'
unlock_msg_4: '¡Un paso más cerca del dominio total!'
mastery_msg_1: Esta tecla está a máxima confianza!'
unlock_msg_plural_1: Buen trabajo! Sigue mejorando tus habilidades.'
unlock_msg_plural_2: '¡Más teclas añadidas a tu arsenal!'
unlock_msg_plural_3: '¡Tu teclado crece! Sigue así.'
unlock_msg_plural_4: '¡Varios pasos más cerca del dominio total!'
mastery_msg_1: '¡Esta tecla ha sido dominada!'
mastery_msg_2: '¡Dominas esta tecla a la perfección!'
mastery_msg_3: '¡Memoria muscular asegurada!'
mastery_msg_4: '¡Una tecla más conquistada!'
mastery_msg_plural_1: '¡Estas teclas han sido dominadas!'
mastery_msg_plural_2: '¡Dominas estas teclas a la perfección!'
mastery_msg_plural_3: '¡Memoria muscular asegurada!'
mastery_msg_plural_4: '¡Más teclas conquistadas!'
# Superposición de introducción al ejercicio adaptativo
adaptive_intro:
title: ' ¡Bienvenido a keydr! '
how_it_works: 'Cómo funcionan los ejercicios adaptativos:'
description: 'Comienzas con un pequeño conjunto de las letras más comunes desbloqueadas. El ejercicio solo genera palabras que usan estas letras. A medida que las escribes con confianza, nuevas teclas se desbloquean gradualmente hasta que dominas el teclado completo.'
description: 'Comienzas con un pequeño conjunto de las letras más comunes desbloqueadas. El ejercicio solo genera palabras que usan estas letras. A medida que las dominas, nuevas teclas se desbloquean gradualmente hasta que dominas el teclado completo.'
target_wpm_label: 'WPM Objetivo:'
target_wpm_desc: 'Esto afecta la velocidad con que se desbloquean las teclas — objetivos más altos requieren escritura más rápida. 35 WPM es un buen punto de partida si no estás seguro. Siempre puedes cambiar esto más tarde en la configuración.'
hint_adjust: 'Ajustar WPM'
@@ -390,6 +398,9 @@ keyboard:
in_focus_label: '¿En Foco?: '
mastery_label: 'Dominio: '
mastery_locked: 'Bloqueado'
mastery_mastered: 'Dominada'
mastery_in_progress: 'En progreso'
speed_confidence_label: 'Confianza de velocidad: '
ranked_avg_time: 'Tiempo Prom. Clasificado: '
ranked_best_time: 'Mejor Tiempo Clasificado: '
ranked_samples: 'Muestras Clasificadas: '
+17 -6
View File
@@ -319,13 +319,13 @@ milestones:
open_skill_tree: 'Vajutage [t] oskuste puu avamiseks'
branch_complete_msg: 'Olete lõpetanud haru %{branch}!'
all_levels_mastered: 'Kõik %{count} taset omandatud.'
all_keys_confident: 'Iga klahv selles harus on täielikul tasemel.'
all_keys_confident: 'Iga klahv selles harus on omandatud.'
all_unlocked_msg: 'Olete avanud kõik klahvid klaviatuuril!'
all_unlocked_desc: 'Iga märk, sümbol ja muuteklahv on nüüd harjutustes saadaval.'
keep_practicing_mastery: 'Jätkake harjutamist valdamise saavutamiseks — kui iga klahv jõuab täieliku'
confidence_complete: 'kindluseni, olete saavutanud täieliku klaviatuuri valdamise!'
keep_practicing_mastery: 'Jätkake harjutamist — kui iga klahv on omandatud,'
confidence_complete: 'olete saavutanud täieliku klaviatuuri valdamise!'
all_mastered_msg: 'Palju õnne — olete saavutanud täieliku klaviatuuri valdamise!'
all_mastered_desc: 'Iga klahv klaviatuuril on maksimaalsel tasemel.'
all_mastered_desc: 'Iga klahv klaviatuuril on omandatud.'
mastery_takes_practice: 'Valdamine pole sihtkoht — see nõuab pidevat harjutamist.'
keep_drilling: 'Jätkake harjutamist oma taseme hoidmiseks.'
hint_skill_tree_continue: 'Ava oskuste puu [Suvaline klahv] Jätka'
@@ -335,16 +335,24 @@ milestones:
unlock_msg_2: 'Veel üks klahv teie arsenali!'
unlock_msg_3: 'Teie klaviatuur kasvab! Jätkake!'
unlock_msg_4: 'Samm lähemale täielikule klaviatuuri valdamisele!'
mastery_msg_1: 'See klahv on nüüd täielikul tasemel!'
unlock_msg_plural_1: 'Tubli! Jätkake oma trükkimisoskuste arendamist.'
unlock_msg_plural_2: 'Veel mitu klahvi teie arsenali!'
unlock_msg_plural_3: 'Teie klaviatuur kasvab! Jätkake!'
unlock_msg_plural_4: 'Mitu sammu lähemale täielikule klaviatuuri valdamisele!'
mastery_msg_1: 'See klahv on nüüd omandatud.'
mastery_msg_2: 'See klahv on teil selge!'
mastery_msg_3: 'Lihasmälu lukustatud!'
mastery_msg_4: 'Veel üks klahv vallutatud!'
mastery_msg_plural_1: 'Need klahvid on nüüd omandatud.'
mastery_msg_plural_2: 'Need klahvid on teil selged!'
mastery_msg_plural_3: 'Lihasmälu lukustatud!'
mastery_msg_plural_4: 'Veel mitu klahvi vallutatud!'
# Kohanduva harjutuse sissejuhatuse ülekate
adaptive_intro:
title: ' Tere tulemast keydri! '
how_it_works: 'Kuidas kohanduvad harjutused toimivad:'
description: 'Alustad väikese hulga kõige levinumate tähtedega. Harjutus genereerib ainult sõnu, mis kasutavad neid tähti. Kui trükid neid kindlalt, avatakse uued klahvid järk-järgult, kuni oled omandanud kogu klaviatuuri.'
description: 'Alustad väikese hulga kõige levinumate tähtedega. Harjutus genereerib ainult sõnu, mis kasutavad neid tähti. Kui omandad need, avatakse uued klahvid järk-järgult, kuni oled omandanud kogu klaviatuuri.'
target_wpm_label: 'Siht-WPM:'
target_wpm_desc: 'See mõjutab klahvide avamise kiirust — kõrgemad sihid nõuavad kiiremat trükkimist. 35 WPM on hea lähtepunkt, kui pole kindel. Saad seda alati hiljem seadetes muuta.'
hint_adjust: 'Kohanda WPM'
@@ -390,6 +398,9 @@ keyboard:
in_focus_label: 'Fookuses?: '
mastery_label: 'Valdamine: '
mastery_locked: 'Lukus'
mastery_mastered: 'Omandatud'
mastery_in_progress: 'Pooleli'
speed_confidence_label: 'Kiiruskindlus: '
ranked_avg_time: 'Hinnatud kesk. aeg: '
ranked_best_time: 'Hinnatud parim aeg: '
ranked_samples: 'Hinnatud proovid: '
+17 -6
View File
@@ -319,13 +319,13 @@ milestones:
open_skill_tree: 'Paina [t] avataksesi taitopuun nyt'
branch_complete_msg: 'Olet suorittanut haaran %{branch}!'
all_levels_mastered: 'Kaikki %{count} tasoa hallittu.'
all_keys_confident: 'Jokainen näppäin tässä haarassa on täydellä varmuudella.'
all_keys_confident: 'Jokainen näppäin tässä haarassa on hallittu.'
all_unlocked_msg: 'Olet avannut jokaisen näppäimen näppäimistöllä!'
all_unlocked_desc: 'Jokainen merkki, symboli ja muokkain on nyt käytettävissä harjoituksissasi.'
keep_practicing_mastery: 'Jatka harjoittelua hallinnan rakentamiseksi — kun jokainen näppäin saavuttaa täyden'
confidence_complete: 'varmuuden, olet saavuttanut täydellisen näppäimistöhallinnan!'
keep_practicing_mastery: 'Jatka harjoittelua — kun jokainen näppäin on hallittu,'
confidence_complete: 'olet saavuttanut täydellisen näppäimistöhallinnan!'
all_mastered_msg: 'Onnittelut — olet saavuttanut täyden näppäimistöhallinnan!'
all_mastered_desc: 'Jokainen näppäin näppäimistöllä on maksimivarmuudella.'
all_mastered_desc: 'Jokainen näppäin näppäimistöllä on hallittu.'
mastery_takes_practice: 'Hallinta ei ole päämäärä — se vaatii jatkuvaa harjoittelua.'
keep_drilling: 'Jatka harjoittelua säilyttääksesi taitosi.'
hint_skill_tree_continue: 'Avaa taitopuu [Muu näppäin] Jatka'
@@ -335,16 +335,24 @@ milestones:
unlock_msg_2: 'Taas yksi näppäin arsenaalissasi!'
unlock_msg_3: 'Näppäimistösi kasvaa! Jatka samaan malliin.'
unlock_msg_4: 'Askel lähempänä täyttä näppäimistöhallintaa!'
mastery_msg_1: 'Tämä näppäin on nyt täydellä varmuudella!'
unlock_msg_plural_1: 'Hienoa! Jatka kirjoitustaitojesi kehittämistä.'
unlock_msg_plural_2: 'Lisää näppäimiä arsenaalissasi!'
unlock_msg_plural_3: 'Näppäimistösi kasvaa! Jatka samaan malliin.'
unlock_msg_plural_4: 'Muutama askel lähempänä täyttä näppäimistöhallintaa!'
mastery_msg_1: 'Tämä näppäin on nyt hallittu.'
mastery_msg_2: 'Tämä näppäin on hallussa!'
mastery_msg_3: 'Lihasmuisti lukittuna!'
mastery_msg_4: 'Taas yksi näppäin valloitettu!'
mastery_msg_plural_1: 'Nämä näppäimet ovat nyt hallittuja.'
mastery_msg_plural_2: 'Nämä näppäimet ovat hallussa!'
mastery_msg_plural_3: 'Lihasmuisti lukittuna!'
mastery_msg_plural_4: 'Lisää näppäimiä valloitettu!'
# Adaptive drill intro overlay
adaptive_intro:
title: ' Tervetuloa keydriin! '
how_it_works: 'Miten mukautuvat harjoitukset toimivat:'
description: 'Aloitat pienellä joukolla yleisimpiä kirjaimia avattuna. Harjoitus tuottaa vain sanoja, jotka käyttävät näitä kirjaimia. Kun kirjoitat niitä varmasti, uusia näppäimiä avataan vähitellen, kunnes olet hallinnut koko näppäimistön.'
description: 'Aloitat pienellä joukolla yleisimpiä kirjaimia avattuna. Harjoitus tuottaa vain sanoja, jotka käyttävät näitä kirjaimia. Kun hallitset ne, uusia näppäimiä avataan vähitellen, kunnes olet hallinnut koko näppäimistön.'
target_wpm_label: 'Tavoite-WPM:'
target_wpm_desc: 'Tämä vaikuttaa näppäinten avausnopeuteen — korkeammat tavoitteet vaativat nopeampaa kirjoittamista. 35 WPM on hyvä lähtökohta, jos et ole varma. Voit aina muuttaa tätä myöhemmin asetuksissa.'
hint_adjust: 'Säädä WPM'
@@ -390,6 +398,9 @@ keyboard:
in_focus_label: 'Fokuksessa?: '
mastery_label: 'Hallinta: '
mastery_locked: 'Lukittu'
mastery_mastered: 'Hallittu'
mastery_in_progress: 'Käynnissä'
speed_confidence_label: 'Nopeusvarmuus: '
ranked_avg_time: 'Sijoitettu ka aika: '
ranked_best_time: 'Sijoitettu paras aika: '
ranked_samples: 'Sijoitetut näytteet: '
+17 -6
View File
@@ -319,13 +319,13 @@ milestones:
open_skill_tree: 'Appuyez sur [t] pour ouvrir l''Arbre de Compétences'
branch_complete_msg: 'Vous avez terminé la branche %{branch} !'
all_levels_mastered: 'Les %{count} niveaux sont maîtrisés.'
all_keys_confident: 'Chaque touche de cette branche est à confiance maximale.'
all_keys_confident: 'Chaque touche de cette branche est maîtrisée.'
all_unlocked_msg: 'Vous avez déverrouillé toutes les touches du clavier !'
all_unlocked_desc: 'Chaque caractère, symbole et modificateur est disponible dans vos exercices.'
keep_practicing_mastery: 'Continuez à pratiquer pour atteindre la maîtrise — quand chaque touche atteindra'
confidence_complete: 'la confiance maximale, vous aurez atteint la maîtrise totale du clavier !'
keep_practicing_mastery: 'Continuez à pratiquer — quand chaque touche sera maîtrisée,'
confidence_complete: 'vous aurez atteint la maîtrise totale du clavier !'
all_mastered_msg: 'Félicitations — vous avez atteint la maîtrise totale du clavier !'
all_mastered_desc: 'Chaque touche du clavier est à confiance maximale.'
all_mastered_desc: 'Chaque touche du clavier est maîtrisée.'
mastery_takes_practice: 'La maîtrise n''est pas une destination — elle nécessite une pratique continue.'
keep_drilling: 'Continuez à vous entraîner pour garder votre niveau.'
hint_skill_tree_continue: 'Ouvrir l''Arbre de Compétences [Autre touche] Continuer'
@@ -335,16 +335,24 @@ milestones:
unlock_msg_2: 'Une touche de plus dans votre arsenal !'
unlock_msg_3: 'Votre clavier s''agrandit ! Continuez !'
unlock_msg_4: 'Un pas de plus vers la maîtrise totale !'
mastery_msg_1: 'Cette touche est à confiance maximale !'
unlock_msg_plural_1: 'Bon travail ! Continuez à améliorer vos compétences.'
unlock_msg_plural_2: 'Plusieurs touches de plus dans votre arsenal !'
unlock_msg_plural_3: 'Votre clavier s''agrandit ! Continuez !'
unlock_msg_plural_4: 'Quelques pas de plus vers la maîtrise totale !'
mastery_msg_1: 'Cette touche est maîtrisée.'
mastery_msg_2: 'Vous maîtrisez cette touche parfaitement !'
mastery_msg_3: 'Mémoire musculaire acquise !'
mastery_msg_4: 'Une touche de plus conquise !'
mastery_msg_plural_1: 'Ces touches sont maîtrisées.'
mastery_msg_plural_2: 'Vous maîtrisez ces touches parfaitement !'
mastery_msg_plural_3: 'Mémoire musculaire acquise !'
mastery_msg_plural_4: 'Plusieurs touches de plus conquises !'
# Superposition d'introduction à l'exercice adaptatif
adaptive_intro:
title: ' Bienvenue sur keydr ! '
how_it_works: 'Comment fonctionnent les exercices adaptatifs :'
description: 'Vous commencez avec un petit ensemble des lettres les plus courantes déverrouillées. L''exercice ne génère que des mots qui utilisent ces lettres. Au fur et à mesure que vous les tapez avec confiance, de nouvelles touches se déverrouillent progressivement jusqu''à ce que vous ayez maîtrisé le clavier complet.'
description: 'Vous commencez avec un petit ensemble des lettres les plus courantes déverrouillées. L''exercice ne génère que des mots qui utilisent ces lettres. Au fur et à mesure que vous les maîtrisez, de nouvelles touches se déverrouillent progressivement jusqu''à ce que vous ayez maîtrisé le clavier complet.'
target_wpm_label: 'WPM Objectif :'
target_wpm_desc: 'Cela influence la vitesse de déverrouillage des touches — des objectifs plus élevés nécessitent une frappe plus rapide. 35 WPM est un bon point de départ si vous n''êtes pas sûr. Vous pouvez toujours modifier cela plus tard dans les paramètres.'
hint_adjust: 'Régler WPM'
@@ -390,6 +398,9 @@ keyboard:
in_focus_label: 'En Focus ? : '
mastery_label: 'Maîtrise : '
mastery_locked: 'Verrouillé'
mastery_mastered: 'Maîtrisée'
mastery_in_progress: 'En cours'
speed_confidence_label: 'Confiance de vitesse : '
ranked_avg_time: 'Temps Moy. Classé : '
ranked_best_time: 'Meilleur Temps Classé : '
ranked_samples: 'Échantillons Classés : '
+17 -6
View File
@@ -319,13 +319,13 @@ milestones:
open_skill_tree: 'Pritisnite [t] za otvaranje stabla vještina'
branch_complete_msg: 'Završili ste granu %{branch}!'
all_levels_mastered: 'Svih %{count} razina savladano.'
all_keys_confident: 'Svaka tipka u ovoj grani je na punoj razini pouzdanosti.'
all_keys_confident: 'Svaka tipka u ovoj grani je savladana.'
all_unlocked_msg: 'Otključali ste svaku tipku na tipkovnici!'
all_unlocked_desc: 'Svaki znak, simbol i modifikator je sada dostupan u vašim vježbama.'
keep_practicing_mastery: 'Nastavite vježbati za izgradnju majstorstva — kada svaka tipka dosegne punu'
confidence_complete: 'razinu pouzdanosti, postigli ste potpuno vladanje tipkovnicom!'
keep_practicing_mastery: 'Nastavite vježbati — kada svaka tipka bude savladana,'
confidence_complete: 'postigli ste potpuno vladanje tipkovnicom!'
all_mastered_msg: 'Čestitamo — postigli ste potpuno vladanje tipkovnicom!'
all_mastered_desc: 'Svaka tipka na tipkovnici je na maksimalnoj razini pouzdanosti.'
all_mastered_desc: 'Svaka tipka na tipkovnici je savladana.'
mastery_takes_practice: 'Majstorstvo nije odredište — zahtijeva stalnu vježbu.'
keep_drilling: 'Nastavite vježbati kako biste održali formu.'
hint_skill_tree_continue: 'Otvori stablo vještina [Bilo koja tipka] Nastavi'
@@ -335,16 +335,24 @@ milestones:
unlock_msg_2: 'Još jedna tipka dodana u vaš arsenal!'
unlock_msg_3: 'Vaša tipkovnica raste! Samo naprijed.'
unlock_msg_4: 'Korak bliže potpunom vladanju tipkovnicom!'
mastery_msg_1: 'Ova tipka je sada na punoj razini pouzdanosti!'
unlock_msg_plural_1: 'Odličan posao! Nastavite graditi vještine tipkanja.'
unlock_msg_plural_2: 'Još nekoliko tipki dodano u vaš arsenal!'
unlock_msg_plural_3: 'Vaša tipkovnica raste! Samo naprijed.'
unlock_msg_plural_4: 'Nekoliko koraka bliže potpunom vladanju tipkovnicom!'
mastery_msg_1: 'Ova tipka je sada savladana.'
mastery_msg_2: 'Ovu tipku imate u malom prstu!'
mastery_msg_3: 'Mišićna memorija zaključana!'
mastery_msg_4: 'Još jedna tipka osvojena!'
mastery_msg_plural_1: 'Ove tipke su sada savladane.'
mastery_msg_plural_2: 'Ove tipke imate u malom prstu!'
mastery_msg_plural_3: 'Mišićna memorija zaključana!'
mastery_msg_plural_4: 'Još nekoliko tipki osvojeno!'
# Uvodni prekrivač prilagodljive vježbe
adaptive_intro:
title: ' Dobrodošli u keydr! '
how_it_works: 'Kako funkcioniraju prilagodljive vježbe:'
description: 'Počinjete s malim skupom najčešćih slova koja su otključana. Vježba generira samo riječi koje koriste ta slova. Kako ih tipkate s pouzdanošću, nove tipke se postupno otključavaju dok ne savladate cijelu tipkovnicu.'
description: 'Počinjete s malim skupom najčešćih slova koja su otključana. Vježba generira samo riječi koje koriste ta slova. Kako ih savladavate, nove tipke se postupno otključavaju dok ne savladate cijelu tipkovnicu.'
target_wpm_label: 'Ciljni WPM:'
target_wpm_desc: 'Ovo utječe na brzinu otključavanja tipki — viši ciljevi zahtijevaju brže tipkanje. 35 WPM je dobro polazište ako niste sigurni. To uvijek možete promijeniti kasnije u postavkama.'
hint_adjust: 'Podesi WPM'
@@ -390,6 +398,9 @@ keyboard:
in_focus_label: 'U fokusu?: '
mastery_label: 'Majstorstvo: '
mastery_locked: 'Zaključano'
mastery_mastered: 'Savladana'
mastery_in_progress: 'U tijeku'
speed_confidence_label: 'Pouzdanost brzine: '
ranked_avg_time: 'Ocj. prosj. vrijeme: '
ranked_best_time: 'Ocj. najbolje vrijeme: '
ranked_samples: 'Ocj. uzoraka: '
+17 -6
View File
@@ -319,13 +319,13 @@ milestones:
open_skill_tree: 'Nyomja meg a [t] gombot a képességfa megnyitásához'
branch_complete_msg: 'Befejezte a %{branch} ágat!'
all_levels_mastered: 'Mind a %{count} szint elsajátítva.'
all_keys_confident: 'Minden billentyű ebben az ágban teljes megbízhatóságú.'
all_keys_confident: 'Az ág minden billentyűje elsajátításra került.'
all_unlocked_msg: 'Feloldotta a billentyűzet összes billentyűjét!'
all_unlocked_desc: 'Minden karakter, szimbólum és módosító elérhető a gyakorlatokban.'
keep_practicing_mastery: 'Folytassa a gyakorlást a mesteri szint eléréséhez — ha minden billentyű eléri a teljes'
confidence_complete: 'megbízhatóságot, elérte a teljes billentyűzet elsajátítását!'
keep_practicing_mastery: 'Folytassa a gyakorlást — ha minden billentyűt elsajátított,'
confidence_complete: 'elérte a teljes billentyűzet elsajátítását!'
all_mastered_msg: 'Gratulálunk — elérte a teljes billentyűzet elsajátítását!'
all_mastered_desc: 'A billentyűzet minden billentyűje maximális megbízhatóságú.'
all_mastered_desc: 'A billentyűzet minden billentyűje elsajátításra került.'
mastery_takes_practice: 'Az elsajátítás nem végállomás — folyamatos gyakorlást igényel.'
keep_drilling: 'Folytassa a gyakorlást, hogy megőrizze szintjét.'
hint_skill_tree_continue: 'Képességfa megnyitása [Bármely billentyű] Tovább'
@@ -335,16 +335,24 @@ milestones:
unlock_msg_2: 'Újabb billentyű az arzenáljában!'
unlock_msg_3: 'A billentyűzete bővül! Így tovább.'
unlock_msg_4: 'Egy lépéssel közelebb a teljes elsajátításhoz!'
mastery_msg_1: 'Ez a billentyű most teljes megbízhatóságú!'
unlock_msg_plural_1: 'Szép munka! Fejlessze tovább gépelési készségeit.'
unlock_msg_plural_2: 'Újabb billentyűk az arzenáljában!'
unlock_msg_plural_3: 'A billentyűzete bővül! Így tovább.'
unlock_msg_plural_4: 'Több lépéssel közelebb a teljes elsajátításhoz!'
mastery_msg_1: 'Ezt a billentyűt most elsajátította.'
mastery_msg_2: 'Ezt a billentyűt tökéletesen tudja!'
mastery_msg_3: 'Izommemória rögzítve!'
mastery_msg_4: 'Még egy billentyű meghódítva!'
mastery_msg_plural_1: 'Ezeket a billentyűket most elsajátította.'
mastery_msg_plural_2: 'Ezeket a billentyűket tökéletesen tudja!'
mastery_msg_plural_3: 'Izommemória rögzítve!'
mastery_msg_plural_4: 'Még több billentyű meghódítva!'
# Adaptív gyakorlat bevezető felugró
adaptive_intro:
title: ' Üdvözöljük a keydr-ben! '
how_it_works: 'Hogyan működnek az adaptív gyakorlatok:'
description: 'A leggyakoribb betűk egy kis készletével kezd, amelyek fel vannak oldva. A gyakorlat csak olyan szavakat generál, amelyek ezeket a betűket használják. Ahogy magabiztosan gépeli őket, az új billentyűk fokozatosan feloldódnak, amíg el nem sajátítja a teljes billentyűzetet.'
description: 'A leggyakoribb betűk egy kis készletével kezd, amelyek fel vannak oldva. A gyakorlat csak olyan szavakat generál, amelyek ezeket a betűket használják. Ahogy elsajátítja őket, az új billentyűk fokozatosan feloldódnak, amíg el nem sajátítja a teljes billentyűzetet.'
target_wpm_label: 'Cél WPM:'
target_wpm_desc: 'Ez befolyásolja a billentyűk feloldásának sebességét — magasabb célok gyorsabb gépelést igényelnek. 35 WPM jó kiindulópont, ha bizonytalan. Ezt később bármikor módosíthatja a beállításokban.'
hint_adjust: 'WPM beállítása'
@@ -390,6 +398,9 @@ keyboard:
in_focus_label: 'Fókuszban?: '
mastery_label: 'Elsajátítás: '
mastery_locked: 'Zárolva'
mastery_mastered: 'Elsajátítva'
mastery_in_progress: 'Folyamatban'
speed_confidence_label: 'Sebesség-megbízhatóság: '
ranked_avg_time: 'Ért. átl. idő: '
ranked_best_time: 'Ért. legjobb idő: '
ranked_samples: 'Ért. minták: '
+17 -6
View File
@@ -319,13 +319,13 @@ milestones:
open_skill_tree: 'Premi [t] per aprire l''Albero delle Abilità'
branch_complete_msg: 'Hai completato il ramo %{branch}!'
all_levels_mastered: 'Tutti i %{count} livelli padroneggiati.'
all_keys_confident: 'Ogni tasto in questo ramo è a confidenza massima.'
all_keys_confident: 'Ogni tasto in questo ramo è stato padroneggiato.'
all_unlocked_msg: 'Hai sbloccato tutti i tasti della tastiera!'
all_unlocked_desc: 'Ogni carattere, simbolo e modificatore è disponibile nei tuoi esercizi.'
keep_practicing_mastery: 'Continua a esercitarti per raggiungere la padronanza — quando ogni tasto raggiungerà'
confidence_complete: 'la confidenza massima, avrai raggiunto la padronanza totale della tastiera!'
keep_practicing_mastery: 'Continua a esercitarti — quando ogni tasto sarà padroneggiato,'
confidence_complete: 'avrai raggiunto la padronanza totale della tastiera!'
all_mastered_msg: 'Congratulazioni — hai raggiunto la padronanza totale della tastiera!'
all_mastered_desc: 'Ogni tasto della tastiera è a confidenza massima.'
all_mastered_desc: 'Ogni tasto della tastiera è stato padroneggiato.'
mastery_takes_practice: 'La padronanza non è una destinazione — richiede pratica continua.'
keep_drilling: 'Continua ad esercitarti per mantenere il tuo livello.'
hint_skill_tree_continue: 'Apri Albero delle Abilità [Altro tasto] Continua'
@@ -335,16 +335,24 @@ milestones:
unlock_msg_2: 'Un altro tasto aggiunto al tuo arsenale!'
unlock_msg_3: 'La tua tastiera cresce! Continua così.'
unlock_msg_4: 'Un passo più vicino alla padronanza totale!'
mastery_msg_1: 'Questo tasto è a confidenza massima!'
unlock_msg_plural_1: 'Ottimo lavoro! Continua a migliorare le tue abilità.'
unlock_msg_plural_2: 'Altri tasti aggiunti al tuo arsenale!'
unlock_msg_plural_3: 'La tua tastiera cresce! Continua così.'
unlock_msg_plural_4: 'Diversi passi più vicino alla padronanza totale!'
mastery_msg_1: 'Questo tasto è stato padroneggiato.'
mastery_msg_2: 'Hai questo tasto sotto controllo!'
mastery_msg_3: 'Memoria muscolare acquisita!'
mastery_msg_4: 'Un altro tasto conquistato!'
mastery_msg_plural_1: 'Questi tasti sono stati padroneggiati.'
mastery_msg_plural_2: 'Hai questi tasti sotto controllo!'
mastery_msg_plural_3: 'Memoria muscolare acquisita!'
mastery_msg_plural_4: 'Altri tasti conquistati!'
# Sovrapposizione introduzione esercizio adattivo
adaptive_intro:
title: ' Benvenuto su keydr! '
how_it_works: 'Come funzionano gli esercizi adattivi:'
description: 'Inizi con un piccolo insieme delle lettere più comuni sbloccate. L''esercizio genera solo parole che usano queste lettere. Man mano che le digiti con sicurezza, nuovi tasti vengono gradualmente sbloccati finché non hai padroneggiato la tastiera completa.'
description: 'Inizi con un piccolo insieme delle lettere più comuni sbloccate. L''esercizio genera solo parole che usano queste lettere. Man mano che le padroneggi, nuovi tasti vengono gradualmente sbloccati finché non hai padroneggiato la tastiera completa.'
target_wpm_label: 'WPM Obiettivo:'
target_wpm_desc: 'Questo influenza la velocità di sblocco dei tasti — obiettivi più alti richiedono una digitazione più veloce. 35 WPM è un buon punto di partenza se non sei sicuro. Puoi sempre modificarlo più tardi nelle impostazioni.'
hint_adjust: 'Regola WPM'
@@ -390,6 +398,9 @@ keyboard:
in_focus_label: 'In Focus?: '
mastery_label: 'Padronanza: '
mastery_locked: 'Bloccato'
mastery_mastered: 'Padroneggiato'
mastery_in_progress: 'In corso'
speed_confidence_label: 'Confidenza velocità: '
ranked_avg_time: 'Tempo Med. Classificato: '
ranked_best_time: 'Miglior Tempo Classificato: '
ranked_samples: 'Campioni Classificati: '
+17 -6
View File
@@ -319,13 +319,13 @@ milestones:
open_skill_tree: 'Paspauskite [t] įgūdžių medžiui atidaryti'
branch_complete_msg: 'Baigėte %{branch} šaką!'
all_levels_mastered: 'Visi %{count} lygiai įvaldyti.'
all_keys_confident: 'Kiekvienas šios šakos klavišas pasiekė pilną patikimumą.'
all_keys_confident: 'Kiekvienas šios šakos klavišas yra įvaldytas.'
all_unlocked_msg: 'Atrakinote kiekvieną klaviatūros klavišą!'
all_unlocked_desc: 'Kiekvienas simbolis, ženklas ir modifikatorius dabar prieinamas pratybose.'
keep_practicing_mastery: 'Tęskite praktiką meistriškumui ugdyti — kai kiekvienas klavišas pasieks pilną'
confidence_complete: 'patikimumą, būsite pasiekę visišką klaviatūros įvaldymą!'
keep_practicing_mastery: 'Tęskite praktiką — kai kiekvienas klavišas bus įvaldytas,'
confidence_complete: 'būsite pasiekę visišką klaviatūros įvaldymą!'
all_mastered_msg: 'Sveikiname — pasiekėte visišką klaviatūros įvaldymą!'
all_mastered_desc: 'Kiekvienas klaviatūros klavišas yra maksimalaus patikimumo.'
all_mastered_desc: 'Kiekvienas klaviatūros klavišas yra įvaldytas.'
mastery_takes_practice: 'Meistriškumas nėra tikslas — jis reikalauja nuolatinės praktikos.'
keep_drilling: 'Tęskite pratybas, kad išlaikytumėte formą.'
hint_skill_tree_continue: 'Atidaryti įgūdžių medį [Bet kuris klavišas] Tęsti'
@@ -335,16 +335,24 @@ milestones:
unlock_msg_2: 'Dar vienas klavišas jūsų arsenale!'
unlock_msg_3: 'Jūsų klaviatūra auga! Taip ir toliau.'
unlock_msg_4: 'Vienu žingsniu arčiau visiško klaviatūros įvaldymo!'
mastery_msg_1: 'Šis klavišas dabar pilno patikimumo!'
unlock_msg_plural_1: 'Puiku! Toliau tobulinkite spausdinimo įgūdžius.'
unlock_msg_plural_2: 'Dar keli klavišai jūsų arsenale!'
unlock_msg_plural_3: 'Jūsų klaviatūra auga! Taip ir toliau.'
unlock_msg_plural_4: 'Keliais žingsniais arčiau visiško klaviatūros įvaldymo!'
mastery_msg_1: 'Šis klavišas dabar įvaldytas.'
mastery_msg_2: 'Šį klavišą mokate puikiai!'
mastery_msg_3: 'Raumenų atmintis užfiksuota!'
mastery_msg_4: 'Dar vienas klavišas užkariauta!'
mastery_msg_plural_1: 'Šie klavišai dabar įvaldyti.'
mastery_msg_plural_2: 'Šiuos klavišus mokate puikiai!'
mastery_msg_plural_3: 'Raumenų atmintis užfiksuota!'
mastery_msg_plural_4: 'Dar keli klavišai užkariauti!'
# Adaptive drill intro overlay
adaptive_intro:
title: ' Sveiki atvykę į keydr! '
how_it_works: 'Kaip veikia adaptyvios pratybos:'
description: 'Pradžioje atrakinamas nedidelis dažniausių raidžių rinkinys. Pratybos generuoja tik žodžius, kurie naudoja šias raides. Kai jas spausdinsite užtikrintai, nauji klavišai palaipsniui atrakinami, kol įvaldysite visą klaviatūrą.'
description: 'Pradžioje atrakinamas nedidelis dažniausių raidžių rinkinys. Pratybos generuoja tik žodžius, kurie naudoja šias raides. Kai jas įvaldysite, nauji klavišai palaipsniui atrakinami, kol įvaldysite visą klaviatūrą.'
target_wpm_label: 'Tikslinis WPM:'
target_wpm_desc: 'Tai lemia, kaip greitai atrakinami klavišai — aukštesni tikslai reikalauja greitesnio spausdinimo. 35 WPM — gera pradžios vieta, jei nesate tikri. Tai visada galite pakeisti vėliau nustatymuose.'
hint_adjust: 'Reguliuoti WPM'
@@ -390,6 +398,9 @@ keyboard:
in_focus_label: 'Fokuse?: '
mastery_label: 'Įvaldymas: '
mastery_locked: 'Užrakinta'
mastery_mastered: 'Įvaldyta'
mastery_in_progress: 'Vyksta'
speed_confidence_label: 'Greičio patikimumas: '
ranked_avg_time: 'Vert. vid. laikas: '
ranked_best_time: 'Vert. geriausias: '
ranked_samples: 'Vert. imčių: '
+17 -6
View File
@@ -319,13 +319,13 @@ milestones:
open_skill_tree: 'Nospiediet [t] prasmju koka atvēršanai'
branch_complete_msg: 'Esat pabeidzis %{branch} zaru!'
all_levels_mastered: 'Visi %{count} līmeņi apgūti.'
all_keys_confident: 'Katrs taustiņš šajā zarā ir pilnā uzticamībā.'
all_keys_confident: 'Katrs taustiņš šajā zarā ir apgūts.'
all_unlocked_msg: 'Esat atbloķējis katru tastatūras taustiņu!'
all_unlocked_desc: 'Katra rakstzīme, simbols un modifikators tagad pieejams vingrinājumos.'
keep_practicing_mastery: 'Turpiniet praktizēt meistarības veidošanai — kad katrs taustiņš sasniegs pilnu'
confidence_complete: 'uzticamību, būsiet sasniedzis pilnīgu tastatūras apguvi!'
keep_practicing_mastery: 'Turpiniet praktizēt — kad katrs taustiņš būs apgūts,'
confidence_complete: 'būsiet sasniedzis pilnīgu tastatūras apguvi!'
all_mastered_msg: 'Apsveicam — esat sasniedzis pilnīgu tastatūras apguvi!'
all_mastered_desc: 'Katrs tastatūras taustiņš ir maksimālā uzticamībā.'
all_mastered_desc: 'Katrs tastatūras taustiņš ir apgūts.'
mastery_takes_practice: 'Meistarība nav galamērķis — tā prasa pastāvīgu praksi.'
keep_drilling: 'Turpiniet vingrinājumus, lai uzturētu formu.'
hint_skill_tree_continue: 'Atvērt prasmju koku [Jebkurš taustiņš] Turpināt'
@@ -335,16 +335,24 @@ milestones:
unlock_msg_2: 'Vēl viens taustiņš jūsu arsenālā!'
unlock_msg_3: 'Jūsu tastatūra aug! Tā turpiniet.'
unlock_msg_4: 'Soli tuvāk pilnīgai tastatūras apguvei!'
mastery_msg_1: 'Šis taustiņš tagad ir pilnā uzticamībā!'
unlock_msg_plural_1: 'Lielisks darbs! Turpiniet veidot rakstīšanas prasmes.'
unlock_msg_plural_2: 'Vairāk taustiņu jūsu arsenālā!'
unlock_msg_plural_3: 'Jūsu tastatūra aug! Tā turpiniet.'
unlock_msg_plural_4: 'Dažus soļus tuvāk pilnīgai tastatūras apguvei!'
mastery_msg_1: 'Šis taustiņš tagad ir apgūts.'
mastery_msg_2: 'Šo taustiņu jūs protat lieliski!'
mastery_msg_3: 'Muskuļu atmiņa nostiprināta!'
mastery_msg_4: 'Vēl viens taustiņš iekarots!'
mastery_msg_plural_1: 'Šie taustiņi tagad ir apgūti.'
mastery_msg_plural_2: 'Šos taustiņus jūs protat lieliski!'
mastery_msg_plural_3: 'Muskuļu atmiņa nostiprināta!'
mastery_msg_plural_4: 'Vairāk taustiņu iekaroti!'
# Adaptive drill intro overlay
adaptive_intro:
title: ' Laipni lūdzam keydr! '
how_it_works: 'Kā darbojas adaptīvie vingrinājumi:'
description: 'Sākat ar nelielu visbiežāk lietoto burtu kopu. Vingrinājums ģenerē tikai vārdus, kas izmanto šos burtus. Kad rakstāt tos pārliecinoši, jauni taustiņi pakāpeniski tiek atbloķēti, līdz esat apguvis visu tastatūru.'
description: 'Sākat ar nelielu visbiežāk lietoto burtu kopu. Vingrinājums ģenerē tikai vārdus, kas izmanto šos burtus. Kad tos apgūstat, jauni taustiņi pakāpeniski tiek atbloķēti, līdz esat apguvis visu tastatūru.'
target_wpm_label: 'Mērķa WPM:'
target_wpm_desc: 'Tas ietekmē, cik ātri taustiņi tiek atbloķēti — augstāki mērķi prasa ātrāku rakstīšanu. 35 WPM ir labs sākumpunkts, ja neesat pārliecināts. Jūs vienmēr varat to mainīt vēlāk iestatījumos.'
hint_adjust: 'Pielāgot WPM'
@@ -390,6 +398,9 @@ keyboard:
in_focus_label: 'Fokusā?: '
mastery_label: 'Apguve: '
mastery_locked: 'Bloķēts'
mastery_mastered: 'Apgūts'
mastery_in_progress: 'Notiek'
speed_confidence_label: 'Ātruma uzticamība: '
ranked_avg_time: 'Vērt. vid. laiks: '
ranked_best_time: 'Vērt. labākais laiks: '
ranked_samples: 'Vērt. paraugi: '
+17 -6
View File
@@ -319,13 +319,13 @@ milestones:
open_skill_tree: 'Trykk [t] for aa aapne Ferdighetstreet naa'
branch_complete_msg: 'Du har fullfoert grenen %{branch}!'
all_levels_mastered: 'Alle %{count} nivaaer mestret.'
all_keys_confident: 'Hver tast i denne grenen har full tillit.'
all_keys_confident: 'Hver tast i denne grenen er mestret.'
all_unlocked_msg: 'Du har laast opp hver tast paa tastaturet!'
all_unlocked_desc: 'Hvert tegn, symbol og modifikator er naa tilgjengelig i oevelsene dine.'
keep_practicing_mastery: 'Fortsett aa oeve for aa bygge mestring — naar hver tast naar full'
confidence_complete: 'tillit, har du oppnaad fullstendig tastaturmestring!'
keep_practicing_mastery: 'Fortsett aa oeve — naar hver tast er mestret,'
confidence_complete: 'har du oppnaad fullstendig tastaturmestring!'
all_mastered_msg: 'Gratulerer — du har oppnaad fullstendig tastaturmestring!'
all_mastered_desc: 'Hver tast paa tastaturet har maksimal tillit.'
all_mastered_desc: 'Hver tast paa tastaturet er mestret.'
mastery_takes_practice: 'Mestring er ikke et maal — det krever vedvarende oeving.'
keep_drilling: 'Fortsett aa oeve for aa holde deg skarp.'
hint_skill_tree_continue: 'Ferdighetstre [Annen tast] Fortsett'
@@ -335,16 +335,24 @@ milestones:
unlock_msg_2: 'Enda en tast i arsenalet ditt!'
unlock_msg_3: 'Tastaturet ditt vokser! Fortsett slik.'
unlock_msg_4: 'Et skritt naermere full tastaturmestring!'
mastery_msg_1: 'Denne tasten har naa full tillit!'
unlock_msg_plural_1: 'Bra jobbet! Fortsett aa bygge skriveferdighetene dine.'
unlock_msg_plural_2: 'Flere taster i arsenalet ditt!'
unlock_msg_plural_3: 'Tastaturet ditt vokser! Fortsett slik.'
unlock_msg_plural_4: 'Flere skritt naermere full tastaturmestring!'
mastery_msg_1: 'Denne tasten er naa mestret.'
mastery_msg_2: 'Du mestrer denne tasten perfekt!'
mastery_msg_3: 'Muskelminne forankret!'
mastery_msg_4: 'Enda en tast erobret!'
mastery_msg_plural_1: 'Disse tastene er naa mestret.'
mastery_msg_plural_2: 'Du mestrer disse tastene perfekt!'
mastery_msg_plural_3: 'Muskelminne forankret!'
mastery_msg_plural_4: 'Flere taster erobret!'
# Adaptive drill intro overlay
adaptive_intro:
title: ' Velkommen til keydr! '
how_it_works: 'Slik fungerer adaptive oevelser:'
description: 'Du starter med et lite sett av de vanligste bokstavene laast opp. Oevelsen genererer bare ord som bruker disse bokstavene. Etter hvert som du skriver dem med trygghet, laases nye taster gradvis opp til du har mestret hele tastaturet.'
description: 'Du starter med et lite sett av de vanligste bokstavene laast opp. Oevelsen genererer bare ord som bruker disse bokstavene. Etter hvert som du mestrer dem, laases nye taster gradvis opp til du har mestret hele tastaturet.'
target_wpm_label: 'Maal-WPM:'
target_wpm_desc: 'Dette paavirker hvor raskt taster laases opp — hoeyere maal krever raskere skriving. 35 WPM er et godt utgangspunkt hvis du er usikker. Du kan alltid endre dette senere i innstillingene.'
hint_adjust: 'Juster WPM'
@@ -390,6 +398,9 @@ keyboard:
in_focus_label: 'I fokus?: '
mastery_label: 'Mestring: '
mastery_locked: 'Laast'
mastery_mastered: 'Mestret'
mastery_in_progress: 'Paagaar'
speed_confidence_label: 'Hastighetstillit: '
ranked_avg_time: 'Rangert snittid: '
ranked_best_time: 'Rangert beste tid: '
ranked_samples: 'Rangerte stikkproever: '
+17 -6
View File
@@ -319,13 +319,13 @@ milestones:
open_skill_tree: 'Druk op [t] om de Vaardigheidsboom te openen'
branch_complete_msg: 'Je hebt de tak %{branch} voltooid!'
all_levels_mastered: 'Alle %{count} niveaus beheerst.'
all_keys_confident: 'Elke toets in deze tak heeft vol vertrouwen.'
all_keys_confident: 'Elke toets in deze tak is beheerst.'
all_unlocked_msg: 'Je hebt elke toets op het toetsenbord ontgrendeld!'
all_unlocked_desc: 'Elk teken, symbool en elke modifier is nu beschikbaar in je oefeningen.'
keep_practicing_mastery: 'Blijf oefenen om meesterschap op te bouwen — zodra elke toets vol'
confidence_complete: 'vertrouwen bereikt, heb je volledige toetsenbordbeheersing!'
keep_practicing_mastery: 'Blijf oefenen — zodra elke toets beheerst is,'
confidence_complete: 'heb je volledige toetsenbordbeheersing bereikt!'
all_mastered_msg: 'Gefeliciteerd — je hebt volledige toetsenbordbeheersing bereikt!'
all_mastered_desc: 'Elke toets op het toetsenbord heeft maximaal vertrouwen.'
all_mastered_desc: 'Elke toets op het toetsenbord is beheerst.'
mastery_takes_practice: 'Meesterschap is geen bestemming — het vereist voortdurend oefenen.'
keep_drilling: 'Blijf oefenen om je niveau te behouden.'
hint_skill_tree_continue: 'Vaardigheidsboom [Andere toets] Doorgaan'
@@ -335,16 +335,24 @@ milestones:
unlock_msg_2: 'Weer een toets erbij in je arsenaal!'
unlock_msg_3: 'Je toetsenbord groeit! Ga zo door.'
unlock_msg_4: 'Een stap dichter bij volledige toetsenbordbeheersing!'
mastery_msg_1: 'Deze toets heeft nu vol vertrouwen!'
unlock_msg_plural_1: 'Goed gedaan! Blijf je typvaardigheden opbouwen.'
unlock_msg_plural_2: 'Meer toetsen erbij in je arsenaal!'
unlock_msg_plural_3: 'Je toetsenbord groeit! Ga zo door.'
unlock_msg_plural_4: 'Meerdere stappen dichter bij volledige toetsenbordbeheersing!'
mastery_msg_1: 'Deze toets is nu beheerst.'
mastery_msg_2: 'Je beheerst deze toets perfect!'
mastery_msg_3: 'Spiergeheugen verankerd!'
mastery_msg_4: 'Weer een toets veroverd!'
mastery_msg_plural_1: 'Deze toetsen zijn nu beheerst.'
mastery_msg_plural_2: 'Je beheerst deze toetsen perfect!'
mastery_msg_plural_3: 'Spiergeheugen verankerd!'
mastery_msg_plural_4: 'Meer toetsen veroverd!'
# Adaptive drill intro overlay
adaptive_intro:
title: ' Welkom bij keydr! '
how_it_works: 'Hoe adaptieve oefeningen werken:'
description: 'Je begint met een kleine set van de meest voorkomende letters ontgrendeld. De oefening genereert alleen woorden die deze letters gebruiken. Naarmate je ze met vertrouwen typt, worden nieuwe toetsen geleidelijk ontgrendeld totdat je het volledige toetsenbord beheerst.'
description: 'Je begint met een kleine set van de meest voorkomende letters ontgrendeld. De oefening genereert alleen woorden die deze letters gebruiken. Naarmate je ze beheerst, worden nieuwe toetsen geleidelijk ontgrendeld totdat je het volledige toetsenbord beheerst.'
target_wpm_label: 'Doel-WPM:'
target_wpm_desc: 'Dit bepaalt hoe snel toetsen ontgrendelen — hogere doelen vereisen sneller typen. 35 WPM is een goed startpunt als je het niet zeker weet. Je kunt dit later altijd wijzigen in de instellingen.'
hint_adjust: 'WPM aanpassen'
@@ -390,6 +398,9 @@ keyboard:
in_focus_label: 'In focus?: '
mastery_label: 'Beheersing: '
mastery_locked: 'Vergrendeld'
mastery_mastered: 'Beheerst'
mastery_in_progress: 'Bezig'
speed_confidence_label: 'Snelheidsvertrouwen: '
ranked_avg_time: 'Gerangschikte gem. tijd: '
ranked_best_time: 'Gerangschikte beste tijd: '
ranked_samples: 'Gerangschikte steekproeven: '
+17 -6
View File
@@ -319,13 +319,13 @@ milestones:
open_skill_tree: 'Nacisnij [t], aby otworzyc drzewo umiejetnosci'
branch_complete_msg: 'Ukonczyles galaz %{branch}!'
all_levels_mastered: 'Wszystkie %{count} poziomow opanowanych.'
all_keys_confident: 'Kazdy klawisz w tej galezi jest na pelnej pewnosci.'
all_keys_confident: 'Kazdy klawisz w tej galezi zostal opanowany.'
all_unlocked_msg: 'Odblokowano kazdy klawisz na klawiaturze!'
all_unlocked_desc: 'Kazdy znak, symbol i modyfikator jest teraz dostepny w Twoich treningach.'
keep_practicing_mastery: 'Kontynuuj cwiczenie, aby budowac bieglosc — gdy kazdy klawisz osiagnie pelna'
confidence_complete: 'pewnosc, osiagniesz pelne opanowanie klawiatury!'
keep_practicing_mastery: 'Kontynuuj cwiczenie — gdy kazdy klawisz zostanie opanowany,'
confidence_complete: 'osiagniesz pelne opanowanie klawiatury!'
all_mastered_msg: 'Gratulacje — osiagnales pelne opanowanie klawiatury!'
all_mastered_desc: 'Kazdy klawisz na klawiaturze jest na maksymalnej pewnosci.'
all_mastered_desc: 'Kazdy klawisz na klawiaturze zostal opanowany.'
mastery_takes_practice: 'Bieglosc to nie cel — wymaga ciaglej praktyki.'
keep_drilling: 'Kontynuuj treningi, aby utrzymac swoj poziom.'
hint_skill_tree_continue: 'Otworz drzewo [Inny klawisz] Kontynuuj'
@@ -335,16 +335,24 @@ milestones:
unlock_msg_2: 'Kolejny klawisz w Twoim arsenale!'
unlock_msg_3: 'Twoja klawiatura rosnie! Tak trzymaj.'
unlock_msg_4: 'Krok blizej do pelnego opanowania klawiatury!'
mastery_msg_1: 'Ten klawisz jest teraz na pelnej pewnosci!'
unlock_msg_plural_1: 'Swietna robota! Rozwijaj swoje umiejetnosci pisania.'
unlock_msg_plural_2: 'Wiecej klawiszy w Twoim arsenale!'
unlock_msg_plural_3: 'Twoja klawiatura rosnie! Tak trzymaj.'
unlock_msg_plural_4: 'Kilka krokow blizej do pelnego opanowania klawiatury!'
mastery_msg_1: 'Ten klawisz zostal opanowany.'
mastery_msg_2: 'Ten klawisz masz w malym palcu!'
mastery_msg_3: 'Pamiec miesniowa zablokowana!'
mastery_msg_4: 'Kolejny klawisz podbity!'
mastery_msg_plural_1: 'Te klawisze zostaly opanowane.'
mastery_msg_plural_2: 'Te klawisze masz w malym palcu!'
mastery_msg_plural_3: 'Pamiec miesniowa zablokowana!'
mastery_msg_plural_4: 'Wiecej klawiszy podbitych!'
# Adaptive drill intro overlay
adaptive_intro:
title: ' Witaj w keydr! '
how_it_works: 'Jak dzialaja treningi adaptacyjne:'
description: 'Zaczynasz z malym zestawem najczestszych liter odblokowanych. Trening generuje tylko slowa, ktore uzywaja tych liter. W miare jak piszesz je pewnie, nowe klawisze sa stopniowo odblokowywane, az opanujesz cala klawiature.'
description: 'Zaczynasz z malym zestawem najczestszych liter odblokowanych. Trening generuje tylko slowa, ktore uzywaja tych liter. W miare jak je opanowujesz, nowe klawisze sa stopniowo odblokowywane, az opanujesz cala klawiature.'
target_wpm_label: 'Docelowy WPM:'
target_wpm_desc: 'Wplywa to na szybkosc odblokowywania klawiszy — wyzsze cele wymagaja szybszego pisania. 35 WPM to dobry punkt startowy, jesli nie jestes pewien. Zawsze mozesz to zmienic pozniej w ustawieniach.'
hint_adjust: 'Dostosuj WPM'
@@ -390,6 +398,9 @@ keyboard:
in_focus_label: 'W fokusie?: '
mastery_label: 'Bieglosc: '
mastery_locked: 'Zablokowany'
mastery_mastered: 'Opanowany'
mastery_in_progress: 'W toku'
speed_confidence_label: 'Pewnosc szybkosci: '
ranked_avg_time: 'Rankingowy sr czas: '
ranked_best_time: 'Rankingowy najl czas: '
ranked_samples: 'Rankingowe probki: '
+17 -6
View File
@@ -319,13 +319,13 @@ milestones:
open_skill_tree: 'Pressione [t] para abrir a Árvore de Habilidades'
branch_complete_msg: 'Você completou o ramo %{branch}!'
all_levels_mastered: 'Todos os %{count} níveis dominados.'
all_keys_confident: 'Cada tecla neste ramo está em confiança máxima.'
all_keys_confident: 'Cada tecla neste ramo foi dominada.'
all_unlocked_msg: 'Você desbloqueou todas as teclas do teclado!'
all_unlocked_desc: 'Cada caractere, símbolo e modificador está disponível nos seus exercícios.'
keep_practicing_mastery: 'Continue praticando para alcançar o domínio — quando cada tecla atingir'
confidence_complete: 'confiança máxima, você terá alcançado o domínio total do teclado!'
keep_practicing_mastery: 'Continue praticando — quando cada tecla for dominada,'
confidence_complete: 'você terá alcançado o domínio total do teclado!'
all_mastered_msg: 'Parabéns — você alcançou o domínio total do teclado!'
all_mastered_desc: 'Cada tecla do teclado está em confiança máxima.'
all_mastered_desc: 'Cada tecla do teclado foi dominada.'
mastery_takes_practice: 'O domínio não é um destino — requer prática contínua.'
keep_drilling: 'Continue praticando para manter seu nível.'
hint_skill_tree_continue: 'Abrir Árvore de Habilidades [Outra tecla] Continuar'
@@ -335,16 +335,24 @@ milestones:
unlock_msg_2: 'Mais uma tecla adicionada ao seu arsenal!'
unlock_msg_3: 'Seu teclado está crescendo! Continue assim.'
unlock_msg_4: 'Um passo mais perto do domínio total!'
mastery_msg_1: 'Esta tecla está em confiança máxima!'
unlock_msg_plural_1: 'Bom trabalho! Continue melhorando suas habilidades.'
unlock_msg_plural_2: 'Mais teclas adicionadas ao seu arsenal!'
unlock_msg_plural_3: 'Seu teclado está crescendo! Continue assim.'
unlock_msg_plural_4: 'Vários passos mais perto do domínio total!'
mastery_msg_1: 'Esta tecla foi dominada.'
mastery_msg_2: 'Você domina esta tecla perfeitamente!'
mastery_msg_3: 'Memória muscular adquirida!'
mastery_msg_4: 'Mais uma tecla conquistada!'
mastery_msg_plural_1: 'Estas teclas foram dominadas.'
mastery_msg_plural_2: 'Você domina estas teclas perfeitamente!'
mastery_msg_plural_3: 'Memória muscular adquirida!'
mastery_msg_plural_4: 'Mais teclas conquistadas!'
# Sobreposição de introdução ao exercício adaptativo
adaptive_intro:
title: ' Bem-vindo ao keydr! '
how_it_works: 'Como funcionam os exercícios adaptativos:'
description: 'Você começa com um pequeno conjunto das letras mais comuns desbloqueadas. O exercício só gera palavras que usam essas letras. À medida que as digita com confiança, novas teclas são gradualmente desbloqueadas até você dominar o teclado completo.'
description: 'Você começa com um pequeno conjunto das letras mais comuns desbloqueadas. O exercício só gera palavras que usam essas letras. À medida que as domina, novas teclas são gradualmente desbloqueadas até você dominar o teclado completo.'
target_wpm_label: 'WPM Alvo:'
target_wpm_desc: 'Isso afeta a velocidade com que as teclas são desbloqueadas — metas mais altas exigem digitação mais rápida. 35 WPM é um bom ponto de partida se não tiver certeza. Você pode sempre alterar isso mais tarde nas configurações.'
hint_adjust: 'Ajustar WPM'
@@ -390,6 +398,9 @@ keyboard:
in_focus_label: 'Em Foco?: '
mastery_label: 'Domínio: '
mastery_locked: 'Bloqueado'
mastery_mastered: 'Dominada'
mastery_in_progress: 'Em progresso'
speed_confidence_label: 'Confiança de velocidade: '
ranked_avg_time: 'Tempo Méd. Classificado: '
ranked_best_time: 'Melhor Tempo Classificado: '
ranked_samples: 'Amostras Classificadas: '
+17 -6
View File
@@ -319,13 +319,13 @@ milestones:
open_skill_tree: 'Apasa [t] pentru a deschide arborele acum'
branch_complete_msg: 'Ai finalizat ramura %{branch}!'
all_levels_mastered: 'Toate cele %{count} niveluri stapanite.'
all_keys_confident: 'Fiecare tasta din aceasta ramura este la incredere maxima.'
all_keys_confident: 'Fiecare tasta din aceasta ramura a fost stapanita.'
all_unlocked_msg: 'Ai deblocat fiecare tasta de pe tastatura!'
all_unlocked_desc: 'Fiecare caracter, simbol si modificator este acum disponibil in exercitiile tale.'
keep_practicing_mastery: 'Continua sa exersezi pentru a construi stapanirea — cand fiecare tasta atinge'
confidence_complete: 'incredere maxima, vei fi atins stapanirea completa a tastaturii!'
keep_practicing_mastery: 'Continua sa exersezi — cand fiecare tasta este stapanita,'
confidence_complete: 'vei fi atins stapanirea completa a tastaturii!'
all_mastered_msg: 'Felicitari — ai atins stapanirea completa a tastaturii!'
all_mastered_desc: 'Fiecare tasta de pe tastatura este la incredere maxima.'
all_mastered_desc: 'Fiecare tasta de pe tastatura a fost stapanita.'
mastery_takes_practice: 'Stapanirea nu este o destinatie — necesita practica continua.'
keep_drilling: 'Continua sa exersezi pentru a-ti mentine nivelul.'
hint_skill_tree_continue: 'Deschide arborele [Alta tasta] Continua'
@@ -335,16 +335,24 @@ milestones:
unlock_msg_2: 'Inca o tasta in arsenalul tau!'
unlock_msg_3: 'Tastatura ta creste! Continua tot asa.'
unlock_msg_4: 'Un pas mai aproape de stapanirea completa!'
mastery_msg_1: 'Aceasta tasta este acum la incredere maxima!'
unlock_msg_plural_1: 'Bine lucrat! Continua sa-ti dezvolti abilitatile.'
unlock_msg_plural_2: 'Mai multe taste in arsenalul tau!'
unlock_msg_plural_3: 'Tastatura ta creste! Continua tot asa.'
unlock_msg_plural_4: 'Cativa pasi mai aproape de stapanirea completa!'
mastery_msg_1: 'Aceasta tasta este acum stapanita.'
mastery_msg_2: 'Ai aceasta tasta la degetul mic!'
mastery_msg_3: 'Memorie musculara fixata!'
mastery_msg_4: 'Inca o tasta cucerita!'
mastery_msg_plural_1: 'Aceste taste sunt acum stapanite.'
mastery_msg_plural_2: 'Ai aceste taste la degetul mic!'
mastery_msg_plural_3: 'Memorie musculara fixata!'
mastery_msg_plural_4: 'Mai multe taste cucerite!'
# Suprapunere introducere exercitiu adaptiv
adaptive_intro:
title: ' Bine ai venit la keydr! '
how_it_works: 'Cum functioneaza exercitiile adaptive:'
description: 'Incepi cu un set mic de litere comune deblocate. Exercitiul genereaza doar cuvinte care folosesc aceste litere. Pe masura ce le tastezi cu incredere, taste noi sunt deblocate treptat pana cand stapanesti tastatura completa.'
description: 'Incepi cu un set mic de litere comune deblocate. Exercitiul genereaza doar cuvinte care folosesc aceste litere. Pe masura ce le stapanesti, taste noi sunt deblocate treptat pana cand stapanesti tastatura completa.'
target_wpm_label: 'WPM tinta:'
target_wpm_desc: 'Aceasta afecteaza viteza de deblocare a tastelor — tinte mai mari necesita tastare mai rapida. 35 WPM este un punct bun de start daca nu esti sigur. Poti modifica oricand aceasta valoare mai tarziu in setari.'
hint_adjust: 'Ajusteaza WPM'
@@ -390,6 +398,9 @@ keyboard:
in_focus_label: 'In focus?: '
mastery_label: 'Stapanire: '
mastery_locked: 'Blocata'
mastery_mastered: 'Stapanita'
mastery_in_progress: 'In curs'
speed_confidence_label: 'Incredere viteza: '
ranked_avg_time: 'Timp mediu clasat: '
ranked_best_time: 'Cel mai bun timp clasat: '
ranked_samples: 'Esantioane clasate: '
+17 -6
View File
@@ -319,13 +319,13 @@ milestones:
open_skill_tree: 'Pritisnite [t] za odprtje drevesa veščin'
branch_complete_msg: 'Dokončali ste vejo %{branch}!'
all_levels_mastered: 'Vseh %{count} ravni osvojenih.'
all_keys_confident: 'Vse tipke v tej veji so na polnem zaupanju.'
all_keys_confident: 'Vse tipke v tej veji so osvojene.'
all_unlocked_msg: 'Odklenili ste vse tipke na tipkovnici!'
all_unlocked_desc: 'Vsak znak, simbol in modifikator je zdaj na voljo v vajah.'
keep_practicing_mastery: 'Nadaljujte z vadbo za gradnjo osvojitve — ko vsaka tipka doseže polno'
confidence_complete: 'zaupanje, boste dosegli popolno osvojitev tipkovnice!'
keep_practicing_mastery: 'Nadaljujte z vadbo — ko bo vsaka tipka osvojena,'
confidence_complete: 'boste dosegli popolno osvojitev tipkovnice!'
all_mastered_msg: 'Čestitke — dosegli ste popolno osvojitev tipkovnice!'
all_mastered_desc: 'Vsaka tipka na tipkovnici je na maksimalnem zaupanju.'
all_mastered_desc: 'Vsaka tipka na tipkovnici je osvojena.'
mastery_takes_practice: 'Osvojitev ni cilj — zahteva stalno vadbo.'
keep_drilling: 'Nadaljujte z vajami, da ohranite prednost.'
hint_skill_tree_continue: 'Odpri drevo veščin [Katerakoli tipka] Nadaljuj'
@@ -335,16 +335,24 @@ milestones:
unlock_msg_2: 'Še ena tipka dodana v vaš arzenal!'
unlock_msg_3: 'Vaša tipkovnica raste! Kar tako naprej.'
unlock_msg_4: 'En korak bližje popolni osvojitvi tipkovnice!'
mastery_msg_1: 'Ta tipka je zdaj na polnem zaupanju!'
unlock_msg_plural_1: 'Odlično! Nadaljujte z gradenjem veščin tipkanja.'
unlock_msg_plural_2: 'Še nekaj tipk dodanih v vaš arzenal!'
unlock_msg_plural_3: 'Vaša tipkovnica raste! Kar tako naprej.'
unlock_msg_plural_4: 'Nekaj korakov bližje popolni osvojitvi tipkovnice!'
mastery_msg_1: 'Ta tipka je zdaj osvojena.'
mastery_msg_2: 'To tipko obvladate v celoti!'
mastery_msg_3: 'Mišični spomin zaklenjen!'
mastery_msg_4: 'Še ena tipka osvojena!'
mastery_msg_plural_1: 'Te tipke so zdaj osvojene.'
mastery_msg_plural_2: 'Te tipke obvladate v celoti!'
mastery_msg_plural_3: 'Mišični spomin zaklenjen!'
mastery_msg_plural_4: 'Še nekaj tipk osvojenih!'
# Uvodni prekrivnik prilagodljive vaje
adaptive_intro:
title: ' Dobrodošli v keydr! '
how_it_works: 'Kako delujejo prilagodljive vaje:'
description: 'Začnete z majhnim naborom najpogostejših odkljenjenih črk. Vaja ustvarja samo besede, ki uporabljajo te črke. Ko jih tipkate samozavestno, se nove tipke postopoma odklenejo, dokler ne obvladate celotne tipkovnice.'
description: 'Začnete z majhnim naborom najpogostejših odkljenjenih črk. Vaja ustvarja samo besede, ki uporabljajo te črke. Ko jih osvojite, se nove tipke postopoma odklenejo, dokler ne obvladate celotne tipkovnice.'
target_wpm_label: 'Ciljni WPM:'
target_wpm_desc: 'To vpliva na hitrost odklepanja tipk — višji cilji zahtevajo hitrejše tipkanje. 35 WPM je dobro izhodišče, če niste prepričani. To lahko vedno spremenite kasneje v nastavitvah.'
hint_adjust: 'Prilagodi WPM'
@@ -390,6 +398,9 @@ keyboard:
in_focus_label: 'V fokusu?: '
mastery_label: 'Osvojitev: '
mastery_locked: 'Zaklenjeno'
mastery_mastered: 'Osvojena'
mastery_in_progress: 'Poteka'
speed_confidence_label: 'Zaupanje hitrosti: '
ranked_avg_time: 'Ocenjeni povpr. čas: '
ranked_best_time: 'Ocenjeni najb. čas: '
ranked_samples: 'Ocenjeni vzorci: '
+17 -6
View File
@@ -319,13 +319,13 @@ milestones:
open_skill_tree: 'Tryck [t] foer att oeppna Faerdighetsgrenen nu'
branch_complete_msg: 'Du har slutfoert grenen %{branch}!'
all_levels_mastered: 'Alla %{count} nivaaer behaerskade.'
all_keys_confident: 'Varje tangent i denna gren har fullt foertroende.'
all_keys_confident: 'Varje tangent i denna gren aer behaerskad.'
all_unlocked_msg: 'Du har laast upp varje tangent paa tangentbordet!'
all_unlocked_desc: 'Varje tecken, symbol och modifierare aer nu tillgaenglig i dina oevningar.'
keep_practicing_mastery: 'Fortsaett oeva foer att bygga behaerskning — naer varje tangent naar fullt'
confidence_complete: 'foertroende har du uppnaatt fullstaendig tangentbordsbehaerskning!'
keep_practicing_mastery: 'Fortsaett oeva — naer varje tangent aer behaerskad,'
confidence_complete: 'har du uppnaatt fullstaendig tangentbordsbehaerskning!'
all_mastered_msg: 'Grattis — du har uppnaatt fullstaendig tangentbordsbehaerskning!'
all_mastered_desc: 'Varje tangent paa tangentbordet har maximalt foertroende.'
all_mastered_desc: 'Varje tangent paa tangentbordet aer behaerskad.'
mastery_takes_practice: 'Behaerskning aer inte en destination — det kraever staendig oevning.'
keep_drilling: 'Fortsaett oeva foer att behalla din skaerpa.'
hint_skill_tree_continue: 'Faerdighetstraed [Annan tangent] Fortsaett'
@@ -335,16 +335,24 @@ milestones:
unlock_msg_2: 'Annu en tangent i din arsenal!'
unlock_msg_3: 'Ditt tangentbord vaexer! Fortsaett saa.'
unlock_msg_4: 'Ett steg naermare full tangentbordsbehaerskning!'
mastery_msg_1: 'Denna tangent har nu fullt foertroende!'
unlock_msg_plural_1: 'Bra jobbat! Fortsaett bygga dina skrivfaerdigheter.'
unlock_msg_plural_2: 'Fler tangenter i din arsenal!'
unlock_msg_plural_3: 'Ditt tangentbord vaexer! Fortsaett saa.'
unlock_msg_plural_4: 'Flera steg naermare full tangentbordsbehaerskning!'
mastery_msg_1: 'Denna tangent aer nu behaerskad.'
mastery_msg_2: 'Du behaerskar denna tangent perfekt!'
mastery_msg_3: 'Muskelminne foerankrat!'
mastery_msg_4: 'Annu en tangent eroevrrad!'
mastery_msg_plural_1: 'Dessa tangenter aer nu behaerskade.'
mastery_msg_plural_2: 'Du behaerskar dessa tangenter perfekt!'
mastery_msg_plural_3: 'Muskelminne foerankrat!'
mastery_msg_plural_4: 'Fler tangenter eroevrrade!'
# Intro-oeverlagg foer adaptiv oevning
adaptive_intro:
title: ' Vaelkommen till keydr! '
how_it_works: 'Hur adaptiva oevningar fungerar:'
description: 'Du boerjar med en liten uppsaettning av de vanligaste uplaasta bokstaeverna. Oevningen genererar bara ord som anvaender dessa bokstaever. Naer du skriver dem med saekerhet laases nya tangenter gradvis upp tills du behaerskar hela tangentbordet.'
description: 'Du boerjar med en liten uppsaettning av de vanligaste uplaasta bokstaeverna. Oevningen genererar bara ord som anvaender dessa bokstaever. Naer du behaerskar dem laases nya tangenter gradvis upp tills du behaerskar hela tangentbordet.'
target_wpm_label: 'Maal-WPM:'
target_wpm_desc: 'Detta paaverkar hur snabbt tangenter laases upp — hoegre maal kraever snabbare skrivande. 35 WPM aer en bra startpunkt om du aer osaeker. Du kan alltid aendra detta senare i installningarna.'
hint_adjust: 'Justera WPM'
@@ -390,6 +398,9 @@ keyboard:
in_focus_label: 'I fokus?: '
mastery_label: 'Behaerskning: '
mastery_locked: 'Laast'
mastery_mastered: 'Behaerskad'
mastery_in_progress: 'Paagaar'
speed_confidence_label: 'Hastighetsfoertroende: '
ranked_avg_time: 'Rankad snittid: '
ranked_best_time: 'Rankad baesta tid: '
ranked_samples: 'Rankade stickprov: '
+17 -6
View File
@@ -319,13 +319,13 @@ milestones:
open_skill_tree: 'Yetenek Ağacını açmak için [t] basın'
branch_complete_msg: '%{branch} dalını tamamladınız!'
all_levels_mastered: 'Tüm %{count} seviye ustalaşıldı.'
all_keys_confident: 'Bu daldaki her tuş tam güven seviyesinde.'
all_keys_confident: 'Bu daldaki her tuş ustalaşıldı.'
all_unlocked_msg: 'Klavyedeki tüm tuşları açtınız!'
all_unlocked_desc: 'Her karakter, sembol ve değiştirici artık alıştırmalarda mevcut.'
keep_practicing_mastery: 'Ustalık kazanmak için alıştırmaya devam edin — her tuş tam'
confidence_complete: 'güvene ulaştığında, tam klavye ustalığına erişmiş olacaksınız!'
keep_practicing_mastery: 'Alıştırmaya devam edin — her tuş ustalaşıldığında,'
confidence_complete: 'tam klavye ustalığına erişmiş olacaksınız!'
all_mastered_msg: 'Tebrikler — tam klavye ustalığına ulaştınız!'
all_mastered_desc: 'Klavyedeki her tuş maksimum güven seviyesinde.'
all_mastered_desc: 'Klavyedeki her tuş ustalaşıldı.'
mastery_takes_practice: 'Ustalık bir varış noktası değil — sürekli alıştırma gerektirir.'
keep_drilling: 'Avantajınızı korumak için alıştırmaya devam edin.'
hint_skill_tree_continue: 'Yetenek Ağacını Aç [Herhangi bir tuş] Devam'
@@ -335,16 +335,24 @@ milestones:
unlock_msg_2: 'Cephaneliğinize bir tuş daha eklendi!'
unlock_msg_3: 'Klavyeniz büyüyor! Böyle devam edin.'
unlock_msg_4: 'Tam klavye ustalığına bir adım daha yakın!'
mastery_msg_1: 'Bu tuş artık tam güven seviyesinde!'
unlock_msg_plural_1: 'Harika! Yazma becerilerinizi geliştirmeye devam edin.'
unlock_msg_plural_2: 'Cephaneliğinize daha fazla tuş eklendi!'
unlock_msg_plural_3: 'Klavyeniz büyüyor! Böyle devam edin.'
unlock_msg_plural_4: 'Tam klavye ustalığına birkaç adım daha yakın!'
mastery_msg_1: 'Bu tuş artık ustalaşıldı.'
mastery_msg_2: 'Bu tuşu tamamen kavradınız!'
mastery_msg_3: 'Kas hafızası kilitlendi!'
mastery_msg_4: 'Bir tuş daha fethedildi!'
mastery_msg_plural_1: 'Bu tuşlar artık ustalaşıldı.'
mastery_msg_plural_2: 'Bu tuşları tamamen kavradınız!'
mastery_msg_plural_3: 'Kas hafızası kilitlendi!'
mastery_msg_plural_4: 'Daha fazla tuş fethedildi!'
# Uyarlanır alıştırma giriş katmanı
adaptive_intro:
title: ' keydr''e hoş geldiniz! '
how_it_works: 'Uyarlanır alıştırmalar nasıl çalışır:'
description: 'En yaygın harflerin küçük bir kümesiyle başlarsınız. Alıştırma yalnızca bu harfleri kullanan kelimeler üretir. Onları güvenle yazdıkça, tam klavyeye hakim olana kadar yeni tuşlar kademeli olarak açılır.'
description: 'En yaygın harflerin küçük bir kümesiyle başlarsınız. Alıştırma yalnızca bu harfleri kullanan kelimeler üretir. Onları ustalaşıldıkça, tam klavyeye hakim olana kadar yeni tuşlar kademeli olarak açılır.'
target_wpm_label: 'Hedef WPM:'
target_wpm_desc: 'Bu, tuşların ne kadar hızlı açılacağını etkiler — daha yüksek hedefler daha hızlı yazma gerektirir. Emin değilseniz 35 WPM iyi bir başlangıç noktasıdır. Bunu daha sonra ayarlardan her zaman değiştirebilirsiniz.'
hint_adjust: 'WPM Ayarla'
@@ -390,6 +398,9 @@ keyboard:
in_focus_label: 'Odakta mı?: '
mastery_label: 'Ustalık: '
mastery_locked: 'Kilitli'
mastery_mastered: 'Ustalaşıldı'
mastery_in_progress: 'Devam ediyor'
speed_confidence_label: 'Hız güveni: '
ranked_avg_time: 'Sıralı Ort. Süre: '
ranked_best_time: 'Sıralı En İyi Süre: '
ranked_samples: 'Sıralı Örnekler: '
+373 -32
View File
@@ -14,9 +14,7 @@ use crate::config::Config;
use crate::engine::FocusSelection;
use crate::engine::filter::CharFilter;
use crate::engine::key_stats::KeyStatsStore;
use crate::engine::ngram_stats::{
self, BigramStatsStore, extract_ngram_events, select_focus,
};
use crate::engine::ngram_stats::{self, BigramStatsStore, extract_ngram_events, select_focus};
use crate::engine::scoring;
use crate::engine::skill_tree::{BranchId, BranchStatus, DrillScope, SkillTree, SkillTreeProgress};
use crate::generator::TextGenerator;
@@ -192,24 +190,42 @@ pub struct KeyMilestonePopup {
pub branch_ids: Vec<BranchId>,
}
fn unlock_messages() -> Vec<String> {
fn unlock_messages(key_count: usize) -> Vec<String> {
use crate::i18n::t;
if key_count == 1 {
vec![
t!("milestones.unlock_msg_1").to_string(),
t!("milestones.unlock_msg_2").to_string(),
t!("milestones.unlock_msg_3").to_string(),
t!("milestones.unlock_msg_4").to_string(),
]
} else {
vec![
t!("milestones.unlock_msg_plural_1").to_string(),
t!("milestones.unlock_msg_plural_2").to_string(),
t!("milestones.unlock_msg_plural_3").to_string(),
t!("milestones.unlock_msg_plural_4").to_string(),
]
}
}
fn mastery_messages() -> Vec<String> {
fn mastery_messages(key_count: usize) -> Vec<String> {
use crate::i18n::t;
if key_count == 1 {
vec![
t!("milestones.mastery_msg_1").to_string(),
t!("milestones.mastery_msg_2").to_string(),
t!("milestones.mastery_msg_3").to_string(),
t!("milestones.mastery_msg_4").to_string(),
]
} else {
vec![
t!("milestones.mastery_msg_plural_1").to_string(),
t!("milestones.mastery_msg_plural_2").to_string(),
t!("milestones.mastery_msg_plural_3").to_string(),
t!("milestones.mastery_msg_plural_4").to_string(),
]
}
}
const POST_DRILL_INPUT_LOCK_MS: u64 = 800;
@@ -775,7 +791,12 @@ impl App {
if export.keydr_export_version != EXPORT_VERSION {
self.settings_status_message = Some(StatusMessage {
kind: StatusKind::Error,
text: t!("status.unsupported_version", got = export.keydr_export_version, expected = EXPORT_VERSION).to_string(),
text: t!(
"status.unsupported_version",
got = export.keydr_export_version,
expected = EXPORT_VERSION
)
.to_string(),
});
return;
}
@@ -1159,8 +1180,7 @@ impl App {
let drill_index = self.drill_history.len() as u32;
let hesitation_thresh =
ngram_stats::hesitation_threshold(self.user_median_transition_ms);
let bigram_events =
extract_ngram_events(&result.per_key_times, hesitation_thresh);
let bigram_events = extract_ngram_events(&result.per_key_times, hesitation_thresh);
// Collect unique bigram keys for per-drill streak updates
let mut seen_bigrams: std::collections::HashSet<ngram_stats::BigramKey> =
std::collections::HashSet::new();
@@ -1187,9 +1207,9 @@ impl App {
std::collections::HashSet::new();
for kt in &result.per_key_times {
if kt.correct {
self.ranked_key_stats.update_key(kt.key, kt.time_ms);
self.ranked_key_stats.update_key_ranked(kt.key, kt.time_ms);
} else {
self.ranked_key_stats.update_key_error(kt.key);
self.ranked_key_stats.update_key_error_ranked(kt.key);
}
}
for ev in &bigram_events {
@@ -1218,11 +1238,14 @@ impl App {
.newly_unlocked
.iter()
.map(|&ch| {
let desc = self.keyboard_model.finger_for_char(ch).localized_description();
let desc = self
.keyboard_model
.finger_for_char(ch)
.localized_description();
(ch, desc)
})
.collect();
let msgs = unlock_messages();
let msgs = unlock_messages(update.newly_unlocked.len());
let msg = msgs[self.rng.gen_range(0..msgs.len())].clone();
self.milestone_queue.push_back(KeyMilestonePopup {
kind: MilestoneKind::Unlock,
@@ -1239,11 +1262,14 @@ impl App {
.newly_mastered
.iter()
.map(|&ch| {
let desc = self.keyboard_model.finger_for_char(ch).localized_description();
let desc = self
.keyboard_model
.finger_for_char(ch)
.localized_description();
(ch, desc)
})
.collect();
let msgs = mastery_messages();
let msgs = mastery_messages(update.newly_mastered.len());
let msg = msgs[self.rng.gen_range(0..msgs.len())].clone();
self.milestone_queue.push_back(KeyMilestonePopup {
kind: MilestoneKind::Mastery,
@@ -1378,8 +1404,7 @@ impl App {
let drill_index = self.drill_history.len() as u32;
let hesitation_thresh =
ngram_stats::hesitation_threshold(self.user_median_transition_ms);
let bigram_events =
extract_ngram_events(&result.per_key_times, hesitation_thresh);
let bigram_events = extract_ngram_events(&result.per_key_times, hesitation_thresh);
let mut seen_bigrams: std::collections::HashSet<ngram_stats::BigramKey> =
std::collections::HashSet::new();
for ev in &bigram_events {
@@ -1481,8 +1506,7 @@ impl App {
for (drill_index, result) in history.iter().enumerate() {
let hesitation_thresh =
ngram_stats::hesitation_threshold(self.user_median_transition_ms);
let bigram_events =
extract_ngram_events(&result.per_key_times, hesitation_thresh);
let bigram_events = extract_ngram_events(&result.per_key_times, hesitation_thresh);
// Rebuild char-level error/total counts and EMA from history
for kt in &result.per_key_times {
@@ -1655,11 +1679,26 @@ impl App {
.profile
.skill_tree_for_language(&self.config.dictionary_language);
// Sticky mastery is one-way, so it must survive a rebuild even when the
// strokes that earned it are no longer in `drill_history` (the retention
// cap drops old drills, and delete_session() removes arbitrary ones).
// Without this, a rebuild would demote keys while
// merge_skill_tree_progress_non_regressive() below still restores the
// branch as Complete, breaking the "completed branch implies every key
// mastered" invariant and letting a demoted key emit a second mastery
// milestone once it re-qualified.
let previously_mastered = self.ranked_key_stats.mastered_keys();
// Reset all derived state
self.key_stats = KeyStatsStore::default();
self.key_stats.target_cpm = self.config.target_cpm();
self.ranked_key_stats = KeyStatsStore::default();
self.ranked_key_stats.target_cpm = self.config.target_cpm();
// Re-seed before replaying so skill-tree progression sees the same
// mastered set the live path did.
for key in previously_mastered {
self.ranked_key_stats.seed_mastered(key);
}
self.skill_tree = SkillTree::default();
self.profile.total_score = 0.0;
self.profile.total_drills = 0;
@@ -1667,19 +1706,26 @@ impl App {
self.profile.best_streak = 0;
self.profile.last_practice_date = None;
// Replay each remaining session oldest->newest
// Replay each remaining session oldest->newest.
// NOTE: mirror the live path and call update_key_error for incorrect strokes
// so the sticky `mastered` bit reconstructed from history matches the live
// progression logic (which gates on error_rate_ema in addition to speed).
for result in &self.drill_history {
// Update timing stats for all sessions
for kt in &result.per_key_times {
if kt.correct {
self.key_stats.update_key(kt.key, kt.time_ms);
} else {
self.key_stats.update_key_error(kt.key);
}
}
// Only update skill tree for ranked sessions
if result.ranked {
for kt in &result.per_key_times {
if kt.correct {
self.ranked_key_stats.update_key(kt.key, kt.time_ms);
self.ranked_key_stats.update_key_ranked(kt.key, kt.time_ms);
} else {
self.ranked_key_stats.update_key_error_ranked(kt.key);
}
}
self.skill_tree.update(&self.ranked_key_stats, None);
@@ -2017,8 +2063,7 @@ impl App {
// Step 2: Check if we need to download (only if not already attempted)
if self.config.code_downloads_enabled && !self.code_download_attempted {
let queue =
build_code_download_queue(&chosen, &self.config.code_download_dir);
let queue = build_code_download_queue(&chosen, &self.config.code_download_dir);
if !queue.is_empty() {
self.code_intro_download_total = queue.len();
self.code_download_queue = queue;
@@ -2468,7 +2513,10 @@ impl App {
}
SettingItem::UiLanguage => {
let locales = crate::i18n::SUPPORTED_UI_LOCALES;
let idx = locales.iter().position(|&l| l == self.config.ui_language).unwrap_or(0);
let idx = locales
.iter()
.position(|&l| l == self.config.ui_language)
.unwrap_or(0);
let next = (idx + 1) % locales.len();
self.config.ui_language = locales[next].to_string();
crate::i18n::set_ui_locale(&self.config.ui_language);
@@ -2551,7 +2599,10 @@ impl App {
}
SettingItem::UiLanguage => {
let locales = crate::i18n::SUPPORTED_UI_LOCALES;
let idx = locales.iter().position(|&l| l == self.config.ui_language).unwrap_or(0);
let idx = locales
.iter()
.position(|&l| l == self.config.ui_language)
.unwrap_or(0);
let next = if idx == 0 { locales.len() - 1 } else { idx - 1 };
self.config.ui_language = locales[next].to_string();
crate::i18n::set_ui_locale(&self.config.ui_language);
@@ -3073,7 +3124,59 @@ impl App {
#[cfg(test)]
mod tests {
use super::*;
use crate::engine::skill_tree::{BranchId, get_branch_definition};
use crate::engine::skill_tree::{ALL_BRANCHES, BranchId, get_branch_definition};
use chrono::{Duration as ChronoDuration, Utc};
fn make_ranked_result(
key_times: Vec<KeyTime>,
timestamp: chrono::DateTime<chrono::Utc>,
) -> DrillResult {
let correct = key_times.iter().filter(|kt| kt.correct).count();
let total_chars = key_times.len();
let incorrect = total_chars.saturating_sub(correct);
let elapsed_secs = key_times.iter().map(|kt| kt.time_ms).sum::<f64>() / 1000.0;
let accuracy = if total_chars == 0 {
100.0
} else {
(correct as f64 / total_chars as f64) * 100.0
};
let cpm = if elapsed_secs > 0.0 {
correct as f64 / elapsed_secs * 60.0
} else {
0.0
};
DrillResult {
wpm: cpm / 5.0,
cpm,
accuracy,
correct,
incorrect,
total_chars,
elapsed_secs,
timestamp,
per_key_times: key_times,
drill_mode: "adaptive".to_string(),
ranked: true,
partial: false,
completion_percent: 100.0,
}
}
fn apply_ranked_result_live(app: &mut App, result: DrillResult) {
let before_stats = app.ranked_key_stats.clone();
for kt in &result.per_key_times {
if kt.correct {
app.key_stats.update_key(kt.key, kt.time_ms);
app.ranked_key_stats.update_key_ranked(kt.key, kt.time_ms);
} else {
app.key_stats.update_key_error(kt.key);
app.ranked_key_stats.update_key_error_ranked(kt.key);
}
}
app.skill_tree
.update(&app.ranked_key_stats, Some(&before_stats));
app.drill_history.push(result);
}
#[test]
fn adaptive_word_history_clears_on_code_mode_switch() {
@@ -3496,6 +3599,96 @@ mod tests {
);
}
#[test]
fn rebuild_from_history_matches_live_ranked_mastery_state_with_errors() {
let mut live = App::new_test();
live.drill_history.clear();
let t0 = Utc::now();
let initial_mastery: Vec<KeyTime> = ['e', 't', 'a', 'o', 'i', 'n']
.into_iter()
.flat_map(|key| {
let mut times = vec![
KeyTime {
key,
time_ms: 200.0,
correct: true,
};
24
];
times.push(KeyTime {
key,
time_ms: 550.0,
correct: false,
});
times
})
.collect();
let follow_up = vec![
KeyTime {
key: 's',
time_ms: 520.0,
correct: false,
},
KeyTime {
key: 's',
time_ms: 200.0,
correct: true,
},
KeyTime {
key: 's',
time_ms: 205.0,
correct: true,
},
KeyTime {
key: 'e',
time_ms: 210.0,
correct: true,
},
];
let results = vec![
make_ranked_result(initial_mastery, t0),
make_ranked_result(follow_up, t0 + ChronoDuration::minutes(5)),
];
for result in results.iter().cloned() {
apply_ranked_result_live(&mut live, result);
}
let mut rebuilt = App::new_test();
rebuilt.drill_history = results;
rebuilt.rebuild_from_history();
assert_eq!(
serde_json::to_value(&live.key_stats).unwrap(),
serde_json::to_value(&rebuilt.key_stats).unwrap()
);
assert_eq!(
serde_json::to_value(&live.ranked_key_stats).unwrap(),
serde_json::to_value(&rebuilt.ranked_key_stats).unwrap()
);
assert_eq!(
serde_json::to_value(&live.skill_tree.progress).unwrap(),
serde_json::to_value(&rebuilt.skill_tree.progress).unwrap()
);
assert!(
rebuilt.ranked_key_stats.is_mastered('e'),
"rebuild should preserve sticky mastery for ranked keys"
);
assert!(
!rebuilt.ranked_key_stats.is_mastered('s'),
"rebuild should preserve non-mastered keys with recent errors"
);
assert_eq!(
rebuilt
.skill_tree
.branch_progress(BranchId::Lowercase)
.current_level,
1,
"rebuild should preserve lowercase unlock progress after mastering the initial set"
);
}
#[test]
fn rebuild_from_history_preserves_previous_branch_unlocks() {
let mut app = App::new_test();
@@ -3626,19 +3819,21 @@ mod tests {
);
}
/// Helper: make a key just below mastery in ranked stats.
/// Uses timing slightly above target (confidence ≈ 0.98), so one fast drill hit
/// will push it over 1.0. target_time ≈ 342.86ms (60000/175 CPM).
/// Helper: make a key just below sticky mastery in ranked stats.
/// sample_count (30) and error_rate_ema (0.0) already qualify, but confidence
/// stays below `MASTERY_MIN_SPEED_CONFIDENCE` (1.05). One additional fast (200ms)
/// drill hit drops filtered_time enough to push confidence past the threshold,
/// flipping the sticky bit. target_time ≈ 342.86ms (60000/175 CPM).
fn make_key_near_mastery(app: &mut App, ch: char) {
for _ in 0..30 {
app.ranked_key_stats.update_key(ch, 350.0);
app.ranked_key_stats.update_key_ranked(ch, 330.0);
}
}
/// Helper: make a key fully confident in ranked stats.
/// Helper: make a key sticky-mastered in ranked stats.
fn make_key_mastered(app: &mut App, ch: char) {
for _ in 0..50 {
app.ranked_key_stats.update_key(ch, 200.0);
app.ranked_key_stats.update_key_ranked(ch, 200.0);
}
}
@@ -3772,7 +3967,7 @@ mod tests {
.branch_progress_mut(BranchId::Capitals)
.current_level = 2; // Last level (3 levels, 0-indexed)
// Make all capitals except 'Z' fully confident, 'Z' near-mastery
// Make all capitals except 'Z' mastered, with 'Z' just below mastery.
for ch in 'A'..='Y' {
make_key_mastered(&mut app, ch);
}
@@ -3962,4 +4157,150 @@ mod tests {
"Should have AllKeysMastered popup, got: {kinds:?}"
);
}
#[test]
fn rebuild_from_history_does_not_requeue_mastery_milestones() {
let mut app = App::new_test();
seed_near_complete_lowercase(&mut app);
setup_drill_with_events(&mut app, "zz");
app.milestone_queue.clear();
app.finish_drill();
let mastery_count = app
.milestone_queue
.iter()
.filter(|m| m.kind == MilestoneKind::Mastery)
.count();
assert!(
mastery_count > 0,
"live drill should queue a mastery milestone"
);
app.milestone_queue.clear();
app.rebuild_from_history();
assert!(
app.milestone_queue.is_empty(),
"rebuild should not requeue mastery milestones for already-mastered keys"
);
assert_eq!(
*app.skill_tree.branch_status(BranchId::Lowercase),
BranchStatus::Complete,
"rebuild should preserve the previously reached lowercase progression state"
);
}
/// The retention cap (and delete_session) can drop the very drills that
/// earned mastery. Because mastery is one-way, rebuilding from what is left
/// must not demote those keys — otherwise a branch stays Complete while its
/// keys are unmastered, and a demoted key can re-enter focus selection and
/// emit a second mastery milestone.
#[test]
fn rebuild_after_history_truncation_preserves_sticky_mastery() {
let mut app = App::new_test();
app.drill_history.clear();
let t0 = Utc::now();
let earning: Vec<KeyTime> = ['e', 't', 'a', 'o', 'i', 'n']
.into_iter()
.flat_map(|key| {
vec![
KeyTime {
key,
time_ms: 200.0,
correct: true,
};
30
]
})
.collect();
apply_ranked_result_live(&mut app, make_ranked_result(earning, t0));
// A later, much lighter drill that on its own could never qualify a key.
let trailing = vec![
KeyTime {
key: 'e',
time_ms: 210.0,
correct: true,
},
KeyTime {
key: 't',
time_ms: 215.0,
correct: true,
},
];
apply_ranked_result_live(
&mut app,
make_ranked_result(trailing, t0 + ChronoDuration::minutes(5)),
);
let mut mastered_before = app.ranked_key_stats.mastered_keys();
mastered_before.sort_unstable();
assert!(
!mastered_before.is_empty(),
"precondition: live drills earned sticky mastery"
);
let progress_before = serde_json::to_value(&app.skill_tree.progress).unwrap();
// Drop the drill that earned mastery, exactly as the retention cap would.
app.drill_history.remove(0);
app.milestone_queue.clear();
app.rebuild_from_history();
let mut mastered_after = app.ranked_key_stats.mastered_keys();
mastered_after.sort_unstable();
assert_eq!(
mastered_after, mastered_before,
"rebuild from a truncated history must not demote mastered keys"
);
assert_eq!(
serde_json::to_value(&app.skill_tree.progress).unwrap(),
progress_before,
"progression must not regress when the earning drills are gone"
);
// Control: replaying only the retained history really cannot re-earn
// mastery, so the assertions above are exercising the preservation path
// rather than an incidental re-promotion.
let mut control = App::new_test();
control.drill_history = app.drill_history.clone();
control.rebuild_from_history();
assert!(
control.ranked_key_stats.mastered_keys().is_empty(),
"control: retained history alone must not qualify any key"
);
// Completed branches must still imply sticky mastery for every key.
for branch_def in ALL_BRANCHES {
if *app.skill_tree.branch_status(branch_def.id) != BranchStatus::Complete {
continue;
}
for level in branch_def.levels {
for &key in level.keys {
assert!(
app.ranked_key_stats.is_mastered(key),
"completed branch {:?} has unmastered key {key:?} after rebuild",
branch_def.id
);
}
}
}
// A preserved-mastery key must not become the focus target again.
if let Some(focus) = app
.skill_tree
.focused_key(DrillScope::Global, &app.ranked_key_stats)
{
assert!(
!app.ranked_key_stats.is_mastered(focus),
"focus selection returned already-mastered key {focus:?} after rebuild"
);
}
assert!(
app.milestone_queue.is_empty(),
"rebuild must not requeue mastery milestones for preserved keys"
);
}
}
+307 -280
View File
@@ -6,7 +6,7 @@ use rand::rngs::SmallRng;
use rand::{Rng, SeedableRng};
use keydr::config::Config;
use keydr::engine::key_stats::{KeyStat, KeyStatsStore};
use keydr::engine::key_stats::{KeyStatsStore, MASTERY_MIN_SPEED_CONFIDENCE};
use keydr::engine::skill_tree::{
ALL_BRANCHES, BranchId, BranchProgress, BranchStatus, SkillTreeProgress,
};
@@ -15,49 +15,128 @@ use keydr::store::schema::{
DrillHistoryData, EXPORT_VERSION, ExportData, KeyStatsData, ProfileData,
};
const SCHEMA_VERSION: u32 = 3;
const SCHEMA_VERSION: u32 = 4;
const TARGET_CPM: f64 = 175.0;
// ── Helpers ──────────────────────────────────────────────────────────────
/// Generate a KeyStat with plausible jitter around a target confidence.
/// Uses seeded RNG for deterministic fixture output.
fn make_key_stat(rng: &mut SmallRng, confidence: f64, sample_count: usize) -> KeyStat {
let target_time_ms = 60000.0 / TARGET_CPM; // ~342.86 ms
let speed_jitter = rng.gen_range(0.92..1.08);
let filtered_time_ms = (target_time_ms / confidence) * speed_jitter;
let best_time_ms = filtered_time_ms * rng.gen_range(0.78..0.9);
/// Per-key target behaviour used to synthesize keystrokes.
///
/// Fixture stats are *derived by replaying* the generated drills rather than
/// hand-written, so persisted `key_stats`/`ranked_key_stats` agree with
/// `drill_history` by construction. That means the intent here is the only place
/// a profile declares "this key should end up mastered": the promotion gate in
/// `key_stats` decides the actual outcome, exactly as it would at runtime.
#[derive(Clone, Copy)]
struct KeyIntent {
/// Target speed confidence, i.e. target_time_ms / mean correct keystroke time.
confidence: f64,
/// Probability that any given keystroke for this key is incorrect.
error_rate: f64,
/// What the profile expects the promotion gate to decide. Asserted after replay.
expect_mastered: bool,
}
// Generate recent_times: up to 30 entries near filtered_time_ms
let recent_count = sample_count.min(30);
let recent_times: Vec<f64> = (0..recent_count)
.map(|i| {
let trend = (i as f64 - recent_count as f64 / 2.0) * rng.gen_range(1.2..2.6);
let noise = rng.gen_range(-8.0..8.0);
(filtered_time_ms + trend + noise).max(best_time_ms)
})
.collect();
// Error rate scales inversely with confidence
let mut error_rate = if confidence >= 1.0 {
rng.gen_range(0.01..0.04)
} else {
(0.08 + (1.0 - confidence) * rng.gen_range(0.22..0.36)).min(0.48)
};
error_rate = error_rate.clamp(0.005, 0.6);
let error_count = (sample_count as f64 * error_rate * 0.5) as usize;
let total_count = sample_count + error_count;
KeyStat {
filtered_time_ms,
best_time_ms,
impl KeyIntent {
/// Comfortably clears all three gates. Confidence is kept well above the
/// speed threshold so per-drill speed variation cannot flip the outcome.
fn mastered(confidence: f64) -> Self {
debug_assert!(confidence >= MASTERY_MIN_SPEED_CONFIDENCE + 0.1);
Self {
confidence,
sample_count,
recent_times,
error_count,
total_count,
error_rate_ema: error_rate,
error_rate: 0.02,
expect_mastered: true,
}
}
/// Still learning: misses the speed gate and the accuracy gate.
fn in_progress(confidence: f64) -> Self {
debug_assert!(confidence < MASTERY_MIN_SPEED_CONFIDENCE);
Self {
confidence,
error_rate: 0.12,
expect_mastered: false,
}
}
/// Misses *only* the speed gate: accuracy and sample count both qualify.
/// Confidence sits inside [1.0, MASTERY_MIN_SPEED_CONFIDENCE) with enough
/// margin that per-drill speed variation keeps it in that band, and the key
/// is typed cleanly so its error-rate EMA is unambiguously under the gate.
fn near_mastery() -> Self {
Self {
confidence: MASTERY_MIN_SPEED_CONFIDENCE - 0.025,
error_rate: 0.0,
expect_mastered: false,
}
}
}
/// Build an intent map covering every key in `keys`.
fn intents_for(keys: &[char], intent: KeyIntent) -> HashMap<char, KeyIntent> {
keys.iter().map(|&k| (k, intent)).collect()
}
/// Replay drills into a stats store exactly the way the app does.
/// `ranked_only` selects the authoritative progression store.
fn replay_stats(drills: &[DrillResult], ranked_only: bool) -> KeyStatsStore {
let mut store = KeyStatsStore {
target_cpm: TARGET_CPM,
..KeyStatsStore::default()
};
for drill in drills {
if ranked_only && !drill.ranked {
continue;
}
for kt in &drill.per_key_times {
match (kt.correct, ranked_only) {
// Only ranked updates may promote sticky mastery.
(true, true) => store.update_key_ranked(kt.key, kt.time_ms),
(false, true) => store.update_key_error_ranked(kt.key),
(true, false) => store.update_key(kt.key, kt.time_ms),
(false, false) => store.update_key_error(kt.key),
}
}
}
store
}
/// Derive both stores from the generated history and verify the promotion gate
/// produced the mastery outcome each key's intent declared. Panics on drift so a
/// fixture can never silently disagree with the runtime gate.
fn stats_from_drills(
profile: &str,
drills: &[DrillResult],
intents: &HashMap<char, KeyIntent>,
) -> (KeyStatsStore, KeyStatsStore) {
let key_stats = replay_stats(drills, false);
let ranked_key_stats = replay_stats(drills, true);
let mut keys: Vec<char> = intents.keys().copied().collect();
keys.sort_unstable();
for key in keys {
let intent = intents[&key];
let actual = ranked_key_stats.is_mastered(key);
assert_eq!(
actual,
intent.expect_mastered,
"{profile}: key {key:?} expected mastered={} but replay produced {actual} \
(confidence {:.3}, samples {}, error_rate_ema {:.3})",
intent.expect_mastered,
ranked_key_stats.get_confidence(key),
ranked_key_stats
.get_stat(key)
.map(|s| s.sample_count)
.unwrap_or(0),
ranked_key_stats.smoothed_error_rate(key),
);
}
assert!(
key_stats.mastered_keys().is_empty(),
"{profile}: unranked stats must never carry the sticky mastery bit"
);
(key_stats, ranked_key_stats)
}
/// Generate monotonic timestamps: base_date + day_offset days + drill_offset * 2min.
@@ -65,30 +144,50 @@ fn drill_timestamp(base: DateTime<Utc>, day: u32, drill_in_day: u32) -> DateTime
base + chrono::Duration::days(day as i64) + chrono::Duration::seconds(drill_in_day as i64 * 120)
}
/// Generate a DrillResult with deterministic per_key_times.
/// Generate a DrillResult whose keystrokes follow each key's intent.
///
/// `speed_scale` shifts the whole drill slightly so history charts still show
/// natural variation; it is kept small enough that recency-weighted confidence
/// stays on the intended side of the promotion threshold.
fn make_drill_result(
rng: &mut SmallRng,
wpm: f64,
accuracy: f64,
char_count: usize,
keys: &[char],
intents: &HashMap<char, KeyIntent>,
speed_scale: f64,
timestamp: DateTime<Utc>,
mode: &str,
ranked: bool,
) -> DrillResult {
let cpm = wpm * 5.0;
let target_error_rate = (1.0 - accuracy / 100.0).clamp(0.005, 0.2);
let target_time_ms = 60000.0 / TARGET_CPM; // ~342.86 ms
// Generate per_key_times with varied transitions for realistic n-gram data.
let per_key_times: Vec<KeyTime> = (0..char_count)
.map(|i| {
let key = keys[rng.gen_range(0..keys.len())];
let is_correct = !rng.gen_bool(target_error_rate);
let base_transition = 60000.0 / cpm;
// Emit keys in shuffled passes so every key gets near-equal coverage in each
// drill (uniform sampling leaves rare keys short of the sample gate) while
// still varying adjacency for realistic n-gram data.
let mut order: Vec<char> = Vec::with_capacity(char_count);
let mut pass: Vec<char> = keys.to_vec();
while order.len() < char_count {
for i in (1..pass.len()).rev() {
pass.swap(i, rng.gen_range(0..=i));
}
order.extend(pass.iter().copied());
}
order.truncate(char_count);
let per_key_times: Vec<KeyTime> = order
.into_iter()
.enumerate()
.map(|(i, key)| {
let intent = intents
.get(&key)
.unwrap_or_else(|| panic!("no KeyIntent declared for drill key {key:?}"));
let is_correct = !rng.gen_bool(intent.error_rate);
let mean_time = (target_time_ms / intent.confidence) * speed_scale;
let time_ms = if is_correct {
base_transition + rng.gen_range(-14.0..24.0) + (i as f64 % 5.0) * 1.2
mean_time * rng.gen_range(0.96..1.04)
} else {
base_transition + rng.gen_range(120.0..290.0) + (i as f64 % 5.0) * 8.0
// Errors never feed timing/confidence; keep them plausibly slow.
mean_time + rng.gen_range(120.0..290.0) + (i as f64 % 5.0) * 8.0
};
KeyTime {
key,
@@ -97,12 +196,20 @@ fn make_drill_result(
}
})
.collect();
// Derive the reported aggregates from the keystrokes actually generated.
let incorrect = per_key_times.iter().filter(|kt| !kt.correct).count();
let correct = char_count - incorrect;
let elapsed_secs = (char_count as f64 / (cpm / 60.0)).max(1.0);
let elapsed_secs = (per_key_times.iter().map(|kt| kt.time_ms).sum::<f64>() / 1000.0).max(1.0);
let cpm = correct as f64 / elapsed_secs * 60.0;
let accuracy = if char_count == 0 {
100.0
} else {
(correct as f64 / char_count as f64) * 100.0
};
DrillResult {
wpm,
wpm: cpm / 5.0,
cpm,
accuracy,
correct,
@@ -195,6 +302,15 @@ fn branch_keys_up_to(branch_id: BranchId, level_index: usize) -> Vec<char> {
keys
}
/// Get the keys belonging to exactly one level of a branch.
fn branch_level_keys(branch_id: BranchId, level_index: usize) -> Vec<char> {
let def = ALL_BRANCHES
.iter()
.find(|b| b.id == branch_id)
.expect("branch not found");
def.levels[level_index].keys.to_vec()
}
/// Get all keys for all levels of a branch.
fn branch_all_keys(branch_id: BranchId) -> Vec<char> {
let def = ALL_BRANCHES
@@ -231,8 +347,8 @@ fn generate_drills(
total: usize,
streak_days: u32,
keys: &[char],
intents: &HashMap<char, KeyIntent>,
mode_distribution: &[(&str, bool, usize)], // (mode, ranked, count)
base_wpm: f64,
) -> Vec<DrillResult> {
let base = base_date();
let mut drills = Vec::new();
@@ -248,13 +364,27 @@ fn generate_drills(
let drill_in_day = drill_idx as u32 % 15; // max 15 drills per day spacing
let ts = drill_timestamp(base, day, drill_in_day);
// Vary WPM slightly by index
let wpm = (base_wpm + (i as f64 % 10.0) - 5.0 + rng.gen_range(-2.0..2.0)).max(12.0);
let accuracy = (91.5 + (i as f64 % 8.0) + rng.gen_range(-1.5..1.5)).clamp(86.0, 99.2);
let char_count = 80 + (i % 40) + rng.gen_range(0..12);
// Small per-drill speed variation so history charts are not flat.
// Confidence is recency-weighted, so this stays tight enough that a
// key's mastery outcome is decided by its intent, not by which drill
// happened to land last.
let speed_scale = 1.0 + ((i % 7) as f64 - 3.0) * 0.006;
let mut char_count = 80 + (i % 40) + rng.gen_range(0..12);
// Ranked drills must give every key enough correct samples to reach
// the sample gate; unranked drills have no such requirement.
if ranked {
char_count = char_count.max(keys.len() * 2);
}
drills.push(make_drill_result(
rng, wpm, accuracy, char_count, keys, ts, mode, ranked,
rng,
char_count,
keys,
intents,
speed_scale,
ts,
mode,
ranked,
));
drill_idx += 1;
}
@@ -313,44 +443,22 @@ fn build_profile_02() -> ExportData {
make_skill_tree_progress(vec![(BranchId::Lowercase, BranchStatus::InProgress, 4)]);
let all_keys = lowercase_keys(10);
let mastered_keys = &all_keys[..6]; // e,t,a,o,i,n
let partial_keys = &all_keys[6..]; // s,h,r,d
let mut intents = intents_for(&all_keys[..6], KeyIntent::mastered(1.35)); // e,t,a,o,i,n
for (i, &k) in all_keys[6..].iter().enumerate() {
// s,h,r,d — still learning
intents.insert(k, KeyIntent::in_progress([0.3, 0.5, 0.6, 0.7][i]));
}
let mut rng = SmallRng::seed_from_u64(2002);
let mut stats = KeyStatsStore::default();
for &k in mastered_keys {
stats.stats.insert(k, make_key_stat(&mut rng, 1.2, 40));
}
let partial_confidences = [0.3, 0.5, 0.6, 0.7];
for (i, &k) in partial_keys.iter().enumerate() {
stats.stats.insert(
k,
make_key_stat(&mut rng, partial_confidences[i], 10 + i * 3),
);
}
let mut ranked_stats = KeyStatsStore::default();
for (&k, base) in &stats.stats {
let conf = if base.confidence >= 1.0 {
(base.confidence - rng.gen_range(0.0..0.18)).max(1.0)
} else {
(base.confidence + rng.gen_range(-0.1..0.08)).clamp(0.15, 0.95)
};
let sample_count =
((base.sample_count as f64) * rng.gen_range(0.5..0.8)).round() as usize + 6;
ranked_stats
.stats
.insert(k, make_key_stat(&mut rng, conf, sample_count));
}
let drills = generate_drills(
&mut rng,
15,
3,
&all_keys,
&intents,
&[("adaptive", false, 11), ("adaptive", true, 4)],
25.0,
);
let (key_stats, ranked_stats) = stats_from_drills("02-early-lowercase", &drills, &intents);
// total_score: level_from_score(x) = (x/100).sqrt() => for level 2: score ~400
make_export(
@@ -362,7 +470,7 @@ fn build_profile_02() -> ExportData {
3,
last_practice_date_from_drills(&drills),
),
stats,
key_stats,
ranked_stats,
drills,
)
@@ -374,44 +482,22 @@ fn build_profile_03() -> ExportData {
make_skill_tree_progress(vec![(BranchId::Lowercase, BranchStatus::InProgress, 12)]);
let all_keys = lowercase_keys(18);
let mastered_keys = &all_keys[..14];
let partial_keys = &all_keys[14..]; // w,f,g,y
let mut intents = intents_for(&all_keys[..14], KeyIntent::mastered(1.4));
for (i, &k) in all_keys[14..].iter().enumerate() {
// w,f,g,y — still learning
intents.insert(k, KeyIntent::in_progress([0.4, 0.6, 0.7, 0.8][i]));
}
let mut rng = SmallRng::seed_from_u64(2003);
let mut stats = KeyStatsStore::default();
for &k in mastered_keys {
stats.stats.insert(k, make_key_stat(&mut rng, 1.3, 60));
}
let partial_confidences = [0.4, 0.6, 0.7, 0.8];
for (i, &k) in partial_keys.iter().enumerate() {
stats.stats.insert(
k,
make_key_stat(&mut rng, partial_confidences[i], 15 + i * 5),
);
}
let mut ranked_stats = KeyStatsStore::default();
for (&k, base) in &stats.stats {
let conf = if base.confidence >= 1.0 {
(base.confidence - rng.gen_range(0.0..0.2)).max(1.0)
} else {
(base.confidence + rng.gen_range(-0.12..0.1)).clamp(0.2, 0.95)
};
let sample_count =
((base.sample_count as f64) * rng.gen_range(0.52..0.82)).round() as usize + 8;
ranked_stats
.stats
.insert(k, make_key_stat(&mut rng, conf, sample_count));
}
let drills = generate_drills(
&mut rng,
50,
7,
&all_keys,
&intents,
&[("adaptive", false, 35), ("adaptive", true, 15)],
30.0,
);
let (key_stats, ranked_stats) = stats_from_drills("03-mid-lowercase", &drills, &intents);
// level ~3: score ~900
make_export(
@@ -423,7 +509,7 @@ fn build_profile_03() -> ExportData {
7,
last_practice_date_from_drills(&drills),
),
stats,
key_stats,
ranked_stats,
drills,
)
@@ -436,41 +522,23 @@ fn build_profile_03_near_lowercase_complete() -> ExportData {
make_skill_tree_progress(vec![(BranchId::Lowercase, BranchStatus::InProgress, 19)]);
let all_keys = lowercase_keys(25);
let mastered_keys = &all_keys[..24];
let near_mastery_key = all_keys[24];
let mut intents = intents_for(&all_keys[..24], KeyIntent::mastered(1.4));
// Miss exactly the speed gate while already meeting the sample + accuracy gates.
intents.insert(near_mastery_key, KeyIntent::near_mastery());
let mut rng = SmallRng::seed_from_u64(2303);
let mut stats = KeyStatsStore::default();
for &k in mastered_keys {
stats.stats.insert(k, make_key_stat(&mut rng, 1.35, 75));
}
// Slightly below mastery, so one good drill can push over 1.0.
stats
.stats
.insert(near_mastery_key, make_key_stat(&mut rng, 0.97, 28));
let mut ranked_stats = KeyStatsStore::default();
for (&k, base) in &stats.stats {
let conf = if base.confidence >= 1.0 {
(base.confidence - rng.gen_range(0.0..0.2)).max(1.0)
} else {
(base.confidence + rng.gen_range(-0.08..0.06)).clamp(0.85, 0.99)
};
let sample_count =
((base.sample_count as f64) * rng.gen_range(0.5..0.8)).round() as usize + 8;
ranked_stats
.stats
.insert(k, make_key_stat(&mut rng, conf, sample_count));
}
let drills = generate_drills(
&mut rng,
90,
10,
&all_keys,
&intents,
&[("adaptive", false, 62), ("adaptive", true, 28)],
34.0,
);
let (key_stats, ranked_stats) =
stats_from_drills("03-near-lowercase-complete", &drills, &intents);
make_export(
make_profile_data(
@@ -481,7 +549,7 @@ fn build_profile_03_near_lowercase_complete() -> ExportData {
12,
last_practice_date_from_drills(&drills),
),
stats,
key_stats,
ranked_stats,
drills,
)
@@ -499,31 +567,19 @@ fn build_profile_04() -> ExportData {
]);
let all_keys = lowercase_keys(26);
// Lowercase is Complete, so every one of its keys must be sticky-mastered.
let intents = intents_for(&all_keys, KeyIntent::mastered(1.45));
let mut rng = SmallRng::seed_from_u64(2004);
let mut stats = KeyStatsStore::default();
for &k in &all_keys {
stats.stats.insert(k, make_key_stat(&mut rng, 1.4, 80));
}
let mut ranked_stats = KeyStatsStore::default();
for (&k, base) in &stats.stats {
let conf = (base.confidence - rng.gen_range(0.0..0.2)).max(1.0);
let sample_count =
((base.sample_count as f64) * rng.gen_range(0.55..0.85)).round() as usize + 10;
ranked_stats
.stats
.insert(k, make_key_stat(&mut rng, conf, sample_count));
}
let drills = generate_drills(
&mut rng,
100,
14,
&all_keys,
&intents,
&[("adaptive", false, 70), ("adaptive", true, 30)],
35.0,
);
let (key_stats, ranked_stats) = stats_from_drills("04-lowercase-complete", &drills, &intents);
// level ~5: score ~2500
make_export(
@@ -535,7 +591,7 @@ fn build_profile_04() -> ExportData {
14,
last_practice_date_from_drills(&drills),
),
stats,
key_stats,
ranked_stats,
drills,
)
@@ -552,62 +608,46 @@ fn build_profile_05() -> ExportData {
(BranchId::CodeSymbols, BranchStatus::Available, 0),
]);
let mut rng = SmallRng::seed_from_u64(2005);
let mut stats = KeyStatsStore::default();
// All lowercase mastered
for &k in &lowercase_keys(26) {
stats.stats.insert(k, make_key_stat(&mut rng, 1.5, 100));
}
// Capitals L1 mastered: T,I,A,S,W,H,B,M
for &k in &['T', 'I', 'A', 'S', 'W', 'H', 'B', 'M'] {
stats.stats.insert(k, make_key_stat(&mut rng, 1.2, 50));
}
// Capitals L2 partial: J,D,R,C,E
let cap_partial = [('J', 0.4), ('D', 0.5), ('R', 0.6), ('C', 0.3), ('E', 0.7)];
for &(k, conf) in &cap_partial {
stats.stats.insert(k, make_key_stat(&mut rng, conf, 15));
}
// Numbers L1 partial: 1,2,3
let num_partial = [('1', 0.4), ('2', 0.5), ('3', 0.3)];
for &(k, conf) in &num_partial {
stats.stats.insert(k, make_key_stat(&mut rng, conf, 12));
}
// Prose punctuation L1 partial: . , '
let punct_partial = [('.', 0.5), (',', 0.4), ('\'', 0.3)];
for &(k, conf) in &punct_partial {
stats.stats.insert(k, make_key_stat(&mut rng, conf, 10));
}
// Build all unlocked keys for drill history
let mut all_unlocked: Vec<char> = lowercase_keys(26);
all_unlocked.extend(branch_keys_up_to(BranchId::Capitals, 1));
all_unlocked.extend(branch_keys_up_to(BranchId::Numbers, 0));
all_unlocked.extend(branch_keys_up_to(BranchId::ProsePunctuation, 0));
// Lowercase is Complete and Capitals L1 is behind the current level, so those
// are mastered. The keys of each branch's *current* level are the ones still
// being learned — they must not be mastered, or the saved in-progress levels
// would contradict the authoritative ranked stats.
let mut intents = intents_for(&lowercase_keys(26), KeyIntent::mastered(1.55));
intents.extend(intents_for(
&branch_level_keys(BranchId::Capitals, 0),
KeyIntent::mastered(1.3),
));
let in_progress_levels = [
branch_level_keys(BranchId::Capitals, 1),
branch_level_keys(BranchId::Numbers, 0),
branch_level_keys(BranchId::ProsePunctuation, 0),
];
for level_keys in &in_progress_levels {
for (i, &k) in level_keys.iter().enumerate() {
intents.insert(k, KeyIntent::in_progress(0.35 + (i % 5) as f64 * 0.09));
}
}
let mut rng = SmallRng::seed_from_u64(2005);
let drills = generate_drills(
&mut rng,
200,
21,
&all_unlocked,
&intents,
&[
("adaptive", false, 170),
("passage", false, 10),
("adaptive", true, 20),
],
40.0,
);
// Ranked key stats: cover all keys used in ranked drills (all_unlocked)
let mut ranked_stats = KeyStatsStore::default();
for &k in &all_unlocked {
ranked_stats
.stats
.insert(k, make_key_stat(&mut rng, 1.1, 20));
}
let (key_stats, ranked_stats) = stats_from_drills("05-multi-branch", &drills, &intents);
// level ~7: score ~5000
make_export(
@@ -619,7 +659,7 @@ fn build_profile_05() -> ExportData {
21,
last_practice_date_from_drills(&drills),
),
stats,
key_stats,
ranked_stats,
drills,
)
@@ -636,42 +676,6 @@ fn build_profile_06() -> ExportData {
(BranchId::CodeSymbols, BranchStatus::InProgress, 2),
]);
let mut rng = SmallRng::seed_from_u64(2006);
let mut stats = KeyStatsStore::default();
// All lowercase mastered
for &k in &lowercase_keys(26) {
stats.stats.insert(k, make_key_stat(&mut rng, 1.6, 200));
}
// All capitals mastered
for &k in &branch_all_keys(BranchId::Capitals) {
stats.stats.insert(k, make_key_stat(&mut rng, 1.4, 120));
}
// All numbers mastered
for &k in &branch_all_keys(BranchId::Numbers) {
stats.stats.insert(k, make_key_stat(&mut rng, 1.3, 100));
}
// All prose punctuation mastered
for &k in &branch_all_keys(BranchId::ProsePunctuation) {
stats.stats.insert(k, make_key_stat(&mut rng, 1.3, 90));
}
// All whitespace mastered
for &k in &branch_all_keys(BranchId::Whitespace) {
stats.stats.insert(k, make_key_stat(&mut rng, 1.2, 80));
}
// Code Symbols L1 + L2 mastered
for &k in &branch_keys_up_to(BranchId::CodeSymbols, 1) {
stats.stats.insert(k, make_key_stat(&mut rng, 1.2, 60));
}
// Code Symbols L3 partial: &,|,^,~
// Note: '!' is shared with ProsePunctuation L3 (Complete), so it must be mastered
let code_partial = [('&', 0.4), ('|', 0.5), ('^', 0.3), ('~', 0.4)];
for &(k, conf) in &code_partial {
stats.stats.insert(k, make_key_stat(&mut rng, conf, 15));
}
// '!' is mastered (shared with completed ProsePunctuation)
stats.stats.insert('!', make_key_stat(&mut rng, 1.2, 60));
// All unlocked keys for drills
let mut all_unlocked: Vec<char> = lowercase_keys(26);
all_unlocked.extend(branch_all_keys(BranchId::Capitals));
@@ -680,27 +684,56 @@ fn build_profile_06() -> ExportData {
all_unlocked.extend(branch_all_keys(BranchId::Whitespace));
all_unlocked.extend(branch_keys_up_to(BranchId::CodeSymbols, 2));
// Everything in a completed branch is mastered; only the Code Symbols
// current level (L3) is still being learned.
let mut intents = intents_for(&lowercase_keys(26), KeyIntent::mastered(1.65));
intents.extend(intents_for(
&branch_all_keys(BranchId::Capitals),
KeyIntent::mastered(1.45),
));
intents.extend(intents_for(
&branch_all_keys(BranchId::Numbers),
KeyIntent::mastered(1.35),
));
intents.extend(intents_for(
&branch_all_keys(BranchId::ProsePunctuation),
KeyIntent::mastered(1.35),
));
intents.extend(intents_for(
&branch_all_keys(BranchId::Whitespace),
KeyIntent::mastered(1.25),
));
intents.extend(intents_for(
&branch_keys_up_to(BranchId::CodeSymbols, 1),
KeyIntent::mastered(1.25),
));
// Code Symbols L3 is the current level — still in progress. Keys it shares
// with a completed branch (e.g. '!' from ProsePunctuation L3) stay mastered,
// so only insert intents for keys not already claimed by a completed branch.
for (i, &k) in branch_level_keys(BranchId::CodeSymbols, 2)
.iter()
.enumerate()
{
intents
.entry(k)
.or_insert_with(|| KeyIntent::in_progress(0.3 + (i % 4) as f64 * 0.09));
}
let mut rng = SmallRng::seed_from_u64(2006);
let drills = generate_drills(
&mut rng,
500,
45,
&all_unlocked,
&intents,
&[
("adaptive", false, 350),
("passage", false, 50),
("code", false, 50),
("adaptive", true, 50),
],
50.0,
);
// Ranked key stats: cover all keys used in ranked drills (all_unlocked)
let mut ranked_stats = KeyStatsStore::default();
for &k in &all_unlocked {
ranked_stats
.stats
.insert(k, make_key_stat(&mut rng, 1.1, 30));
}
let (key_stats, ranked_stats) = stats_from_drills("06-advanced", &drills, &intents);
// level ~12: score ~15000
make_export(
@@ -712,7 +745,7 @@ fn build_profile_06() -> ExportData {
60,
last_practice_date_from_drills(&drills),
),
stats,
key_stats,
ranked_stats,
drills,
)
@@ -729,29 +762,6 @@ fn build_profile_07() -> ExportData {
(BranchId::CodeSymbols, BranchStatus::Complete, 4),
]);
let mut rng = SmallRng::seed_from_u64(2007);
let mut stats = KeyStatsStore::default();
// All keys mastered with high sample counts
for &k in &lowercase_keys(26) {
stats.stats.insert(k, make_key_stat(&mut rng, 1.8, 400));
}
for &k in &branch_all_keys(BranchId::Capitals) {
stats.stats.insert(k, make_key_stat(&mut rng, 1.5, 200));
}
for &k in &branch_all_keys(BranchId::Numbers) {
stats.stats.insert(k, make_key_stat(&mut rng, 1.4, 180));
}
for &k in &branch_all_keys(BranchId::ProsePunctuation) {
stats.stats.insert(k, make_key_stat(&mut rng, 1.4, 160));
}
for &k in &branch_all_keys(BranchId::Whitespace) {
stats.stats.insert(k, make_key_stat(&mut rng, 1.3, 140));
}
for &k in &branch_all_keys(BranchId::CodeSymbols) {
stats.stats.insert(k, make_key_stat(&mut rng, 1.3, 120));
}
// All keys for drills
let mut all_keys: Vec<char> = lowercase_keys(26);
all_keys.extend(branch_all_keys(BranchId::Capitals));
@@ -760,27 +770,44 @@ fn build_profile_07() -> ExportData {
all_keys.extend(branch_all_keys(BranchId::Whitespace));
all_keys.extend(branch_all_keys(BranchId::CodeSymbols));
// Every branch is Complete, so every key must be sticky-mastered.
let mut intents = intents_for(&lowercase_keys(26), KeyIntent::mastered(1.85));
intents.extend(intents_for(
&branch_all_keys(BranchId::Capitals),
KeyIntent::mastered(1.55),
));
intents.extend(intents_for(
&branch_all_keys(BranchId::Numbers),
KeyIntent::mastered(1.45),
));
intents.extend(intents_for(
&branch_all_keys(BranchId::ProsePunctuation),
KeyIntent::mastered(1.45),
));
intents.extend(intents_for(
&branch_all_keys(BranchId::Whitespace),
KeyIntent::mastered(1.35),
));
intents.extend(intents_for(
&branch_all_keys(BranchId::CodeSymbols),
KeyIntent::mastered(1.35),
));
let mut rng = SmallRng::seed_from_u64(2007);
let drills = generate_drills(
&mut rng,
800,
90,
&all_keys,
&intents,
&[
("adaptive", false, 400),
("passage", false, 150),
("code", false, 150),
("adaptive", true, 100),
],
60.0,
);
// Full ranked stats
let mut ranked_stats = KeyStatsStore::default();
for &k in &all_keys {
ranked_stats
.stats
.insert(k, make_key_stat(&mut rng, 1.4, 80));
}
let (key_stats, ranked_stats) = stats_from_drills("07-fully-complete", &drills, &intents);
// level ~18: score ~35000
make_export(
@@ -792,7 +819,7 @@ fn build_profile_07() -> ExportData {
90,
last_practice_date_from_drills(&drills),
),
stats,
key_stats,
ranked_stats,
drills,
)
+283
View File
@@ -4,6 +4,12 @@ use std::collections::HashMap;
const EMA_ALPHA: f64 = 0.1;
const DEFAULT_TARGET_CPM: f64 = 175.0;
/// Sticky-mastery promotion thresholds. A key is promoted to `mastered: true`
/// only when it meets all three gates on a ranked stats update.
pub const MASTERY_MIN_SPEED_CONFIDENCE: f64 = 1.05;
pub const MASTERY_MIN_SAMPLES: usize = 20;
pub const MASTERY_MAX_ERROR_RATE_EMA: f64 = 0.05;
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct KeyStat {
pub filtered_time_ms: f64,
@@ -17,12 +23,27 @@ pub struct KeyStat {
pub total_count: usize,
#[serde(default = "default_error_rate_ema")]
pub error_rate_ema: f64,
// Sticky mastery: once true, never reset by normal drill updates.
// Only authoritative when read from `ranked_key_stats` (unranked `key_stats`
// stores share the type but are not allowed to drive progression).
#[serde(default)]
pub mastered: bool,
}
fn default_error_rate_ema() -> f64 {
0.5
}
impl KeyStat {
/// Returns true if this stat currently satisfies every sticky-mastery gate.
/// Does not mutate anything — callers promote via `KeyStatsStore` helpers.
pub fn qualifies_for_mastery(&self) -> bool {
self.confidence >= MASTERY_MIN_SPEED_CONFIDENCE
&& self.sample_count >= MASTERY_MIN_SAMPLES
&& self.error_rate_ema <= MASTERY_MAX_ERROR_RATE_EMA
}
}
impl Default for KeyStat {
fn default() -> Self {
Self {
@@ -34,6 +55,7 @@ impl Default for KeyStat {
error_count: 0,
total_count: 0,
error_rate_ema: 0.5,
mastered: false,
}
}
}
@@ -107,6 +129,77 @@ impl KeyStatsStore {
}
}
/// Ranked counterpart of [`update_key`]. Accumulates identical metrics and
/// additionally evaluates sticky-mastery promotion.
///
/// Promotion lives only on the ranked methods: `ranked_key_stats` is the sole
/// authority for progression, so unranked/partial activity must never set the
/// `mastered` bit even when its metrics would satisfy the gate.
pub fn update_key_ranked(&mut self, key: char, time_ms: f64) {
self.update_key(key, time_ms);
self.promote_if_qualified(key);
}
/// Ranked counterpart of [`update_key_error`]. See [`update_key_ranked`] for
/// why promotion is ranked-only.
///
/// Errors never unset sticky mastery, but promotion is still evaluated here so
/// both ranked entry points share one promotion site (a key sitting right on
/// the gate can still promote from already-accumulated samples/confidence).
pub fn update_key_error_ranked(&mut self, key: char) {
self.update_key_error(key);
self.promote_if_qualified(key);
}
/// Single source of truth for promotion. Flips `mastered` from false to true
/// when all thresholds are met. Never unsets mastery.
fn promote_if_qualified(&mut self, key: char) {
if let Some(stat) = self.stats.get_mut(&key)
&& !stat.mastered
&& stat.qualifies_for_mastery()
{
stat.mastered = true;
}
}
/// Every key currently holding the sticky mastery bit.
pub fn mastered_keys(&self) -> Vec<char> {
self.stats
.iter()
.filter(|(_, s)| s.mastered)
.map(|(&k, _)| k)
.collect()
}
/// Re-apply a previously earned sticky mastery bit, creating a bare entry if
/// the key has no stats left. Used when rebuilding from a history window that
/// no longer contains the strokes that originally earned mastery — mastery is
/// one-way, so a rebuild must never demote a key.
pub fn seed_mastered(&mut self, key: char) {
self.stats.entry(key).or_default().mastered = true;
}
/// Returns the sticky persisted mastery bit for a key.
pub fn is_mastered(&self, key: char) -> bool {
self.stats.get(&key).map(|s| s.mastered).unwrap_or(false)
}
/// Returns true if the key's current stats would satisfy the promotion gate.
/// Useful for tests/diagnostics; live updates promote automatically.
#[allow(dead_code)]
pub fn qualifies_for_mastery(&self, key: char) -> bool {
self.stats
.get(&key)
.map(|s| s.qualifies_for_mastery())
.unwrap_or(false)
}
/// Count of keys with sticky mastery set.
#[allow(dead_code)]
pub fn mastered_count(&self) -> usize {
self.stats.values().filter(|s| s.mastered).count()
}
/// EMA-based error rate for a key.
pub fn smoothed_error_rate(&self, key: char) -> f64 {
match self.stats.get(&key) {
@@ -209,4 +302,194 @@ mod tests {
let stat: KeyStat = serde_json::from_str(json).unwrap();
assert!((stat.error_rate_ema - 0.5).abs() < f64::EPSILON);
}
#[test]
fn test_mastered_defaults_false_on_deserialize() {
let json = r#"{"filtered_time_ms":200.0,"best_time_ms":200.0,"confidence":1.2,"sample_count":50,"recent_times":[],"error_count":0,"total_count":50,"error_rate_ema":0.0}"#;
let stat: KeyStat = serde_json::from_str(json).unwrap();
assert!(
!stat.mastered,
"mastered should default to false when absent from JSON"
);
}
#[test]
fn test_no_mastery_from_speed_alone_with_high_error_rate() {
let mut store = KeyStatsStore::default();
// Inject many errors first to drive error_rate_ema high.
for _ in 0..30 {
store.update_key_error('a');
}
// Then just enough fast correct strokes to satisfy speed + samples,
// but not so many that the error-rate EMA decays below the threshold.
for _ in 0..20 {
store.update_key_ranked('a', 200.0);
}
let stat = store.get_stat('a').unwrap();
assert!(
stat.confidence >= MASTERY_MIN_SPEED_CONFIDENCE,
"precondition: fast enough"
);
assert!(
stat.sample_count >= MASTERY_MIN_SAMPLES,
"precondition: enough samples"
);
// error_rate_ema decays with correct strokes but should still be above 0.05 here.
assert!(
stat.error_rate_ema > MASTERY_MAX_ERROR_RATE_EMA,
"error_rate_ema should still exceed 0.05, got {}",
stat.error_rate_ema
);
assert!(
!store.is_mastered('a'),
"key must not be mastered while error rate too high"
);
}
#[test]
fn test_no_mastery_with_too_few_samples() {
let mut store = KeyStatsStore::default();
// Few fast strokes (below MASTERY_MIN_SAMPLES)
for _ in 0..(MASTERY_MIN_SAMPLES - 1) {
store.update_key_ranked('b', 150.0);
}
let stat = store.get_stat('b').unwrap();
assert!(stat.confidence >= MASTERY_MIN_SPEED_CONFIDENCE);
assert!(stat.error_rate_ema <= MASTERY_MAX_ERROR_RATE_EMA);
assert!(stat.sample_count < MASTERY_MIN_SAMPLES);
assert!(
!store.is_mastered('b'),
"key must not be mastered without enough samples"
);
}
#[test]
fn test_mastery_when_all_thresholds_met() {
let mut store = KeyStatsStore::default();
for _ in 0..30 {
store.update_key_ranked('c', 150.0);
}
let stat = store.get_stat('c').unwrap();
assert!(stat.confidence >= MASTERY_MIN_SPEED_CONFIDENCE);
assert!(stat.sample_count >= MASTERY_MIN_SAMPLES);
assert!(stat.error_rate_ema <= MASTERY_MAX_ERROR_RATE_EMA);
assert!(store.is_mastered('c'));
}
#[test]
fn test_mastery_sticks_after_slow_strokes() {
let mut store = KeyStatsStore::default();
for _ in 0..30 {
store.update_key_ranked('d', 150.0);
}
assert!(store.is_mastered('d'));
// Many slow strokes — confidence will drop below 1.0 eventually.
for _ in 0..50 {
store.update_key_ranked('d', 2000.0);
}
let conf = store.get_confidence('d');
assert!(conf < 1.0, "confidence should have dropped, got {conf}");
assert!(
store.is_mastered('d'),
"sticky mastery must persist after slow strokes"
);
}
#[test]
fn test_mastery_sticks_after_errors() {
let mut store = KeyStatsStore::default();
for _ in 0..30 {
store.update_key_ranked('e', 150.0);
}
assert!(store.is_mastered('e'));
for _ in 0..10 {
store.update_key_error_ranked('e');
}
assert!(
store.is_mastered('e'),
"sticky mastery must persist after later errors"
);
}
#[test]
fn test_mastered_count_tracks_promotions() {
let mut store = KeyStatsStore::default();
assert_eq!(store.mastered_count(), 0);
for _ in 0..30 {
store.update_key_ranked('x', 150.0);
}
assert_eq!(store.mastered_count(), 1);
for _ in 0..30 {
store.update_key_ranked('y', 150.0);
}
assert_eq!(store.mastered_count(), 2);
}
#[test]
fn test_unranked_updates_never_promote_mastery() {
let mut unranked = KeyStatsStore::default();
let mut ranked = KeyStatsStore::default();
// Identical, comfortably qualifying activity applied to both stores.
for _ in 0..30 {
unranked.update_key('m', 150.0);
ranked.update_key_ranked('m', 150.0);
}
assert!(
unranked.qualifies_for_mastery('m'),
"precondition: unranked metrics do satisfy the gate"
);
assert!(
!unranked.is_mastered('m'),
"unranked activity must never set the sticky bit — only ranked stats are authoritative"
);
assert!(
ranked.is_mastered('m'),
"the same activity on ranked stats must promote"
);
assert_eq!(unranked.mastered_count(), 0);
assert_eq!(ranked.mastered_count(), 1);
}
#[test]
fn test_unranked_error_updates_never_promote_mastery() {
let mut unranked = KeyStatsStore::default();
for _ in 0..30 {
unranked.update_key('n', 150.0);
}
// An error update is the other promotion entry point; the unranked
// variant must not promote on it either.
unranked.update_key_error('n');
assert!(
!unranked.is_mastered('n'),
"unranked error updates must not promote mastery"
);
}
#[test]
fn test_seed_mastered_restores_bit_without_stats() {
let mut store = KeyStatsStore::default();
store.seed_mastered('z');
assert!(store.is_mastered('z'));
assert_eq!(store.get_stat('z').unwrap().sample_count, 0);
// Seeding must not be undone by subsequent ordinary activity.
for _ in 0..10 {
store.update_key_ranked('z', 2000.0);
}
assert!(store.is_mastered('z'));
}
#[test]
fn test_mastered_keys_lists_only_promoted_keys() {
let mut store = KeyStatsStore::default();
for _ in 0..30 {
store.update_key_ranked('p', 150.0);
}
for _ in 0..10 {
store.update_key_ranked('q', 150.0);
}
let mut mastered = store.mastered_keys();
mastered.sort_unstable();
assert_eq!(mastered, vec!['p']);
}
}
+1
View File
@@ -1550,6 +1550,7 @@ mod tests {
stat.sample_count = 200;
stat.total_count = 200;
stat.error_rate_ema = 0.01;
stat.mastered = true;
}
assert!(
+78 -65
View File
@@ -218,7 +218,9 @@ pub const ALL_BRANCHES: &[BranchDefinition] = &[
/// Find which branch and level a key belongs to.
/// Returns (branch_def, level_def, 1-based position in level).
pub fn find_key_branch(ch: char) -> Option<(&'static BranchDefinition, &'static LevelDefinition, usize)> {
pub fn find_key_branch(
ch: char,
) -> Option<(&'static BranchDefinition, &'static LevelDefinition, usize)> {
for branch in ALL_BRANCHES {
for level in branch.levels {
if let Some(pos) = level.keys.iter().position(|&k| k == ch) {
@@ -340,7 +342,6 @@ impl SkillTree {
}
}
all_keys.extend(primary_letters.iter().copied());
all_keys.extend(ALWAYS_UNLOCKED_KEYS.iter().copied());
all_keys.len()
}
@@ -546,7 +547,7 @@ impl SkillTree {
fn weakest_key(keys: &[char], stats: &KeyStatsStore) -> Option<char> {
keys.iter()
.filter(|&&ch| stats.get_confidence(ch) < 1.0)
.filter(|&&ch| !stats.is_mastered(ch))
.min_by(|&&a, &&b| {
stats
.get_confidence(a)
@@ -560,7 +561,9 @@ impl SkillTree {
/// Call after updating KeyStatsStore.
///
/// `before_stats` is an optional snapshot of key stats *before* this drill's data was added.
/// When provided, it's used to detect which keys were newly mastered (confidence crossing 1.0).
/// When provided, it's used to detect which keys transitioned from unmastered to mastered
/// (the sticky `mastered` bit flipping false → true). Because mastery is sticky, a mastery
/// milestone can fire at most once per key ever.
/// Returns a `SkillTreeUpdate` describing which keys were newly unlocked or mastered.
pub fn update(
&mut self,
@@ -662,12 +665,13 @@ impl SkillTree {
.copied()
.collect();
// Detect mastery: keys that were unlocked before, had confidence < 1.0 in before_stats,
// but now have confidence >= 1.0 in current stats
// Detect mastery: keys that were unlocked before and whose sticky `mastered` bit
// transitioned from false to true. Because mastery is sticky, this fires at most
// once per key across its lifetime.
let newly_mastered: Vec<char> = if let Some(before) = before_stats {
before_unlocked
.iter()
.filter(|&&ch| before.get_confidence(ch) < 1.0 && stats.get_confidence(ch) >= 1.0)
.filter(|&&ch| !before.is_mastered(ch) && stats.is_mastered(ch))
.copied()
.collect()
} else {
@@ -694,9 +698,9 @@ impl SkillTree {
let current_count = LOWERCASE_MIN_KEYS + bp.current_level;
if current_count >= all_keys.len() {
// All primary letters unlocked, check if all confident
let all_confident = all_keys.iter().all(|&ch| stats.get_confidence(ch) >= 1.0);
if all_confident {
// All primary letters unlocked, check if all mastered (sticky)
let all_mastered = all_keys.iter().all(|&ch| stats.is_mastered(ch));
if all_mastered {
let bp_mut = self.branch_progress_mut(BranchId::Lowercase);
bp_mut.status = BranchStatus::Complete;
bp_mut.current_level = all_keys.len() - LOWERCASE_MIN_KEYS;
@@ -704,13 +708,11 @@ impl SkillTree {
return;
}
// Check if all current keys are confident -> unlock next
// Check if all current keys are mastered -> unlock next
let current_keys = &all_keys[..current_count];
let all_confident = current_keys
.iter()
.all(|&ch| stats.get_confidence(ch) >= 1.0);
let all_mastered = current_keys.iter().all(|&ch| stats.is_mastered(ch));
if all_confident {
if all_mastered {
let bp_mut = self.branch_progress_mut(BranchId::Lowercase);
bp_mut.current_level += 1;
}
@@ -725,13 +727,11 @@ impl SkillTree {
return;
}
// Check if all keys in current level are confident
// Check if all keys in current level are mastered (sticky)
let current_level_keys = branch_def.levels[bp.current_level].keys;
let all_confident = current_level_keys
.iter()
.all(|&ch| stats.get_confidence(ch) >= 1.0);
let all_mastered = current_level_keys.iter().all(|&ch| stats.is_mastered(ch));
if all_confident {
if all_mastered {
let bp_mut = self.branch_progress_mut(branch_def.id);
bp_mut.current_level += 1;
if bp_mut.current_level >= branch_def.levels.len() {
@@ -743,7 +743,6 @@ impl SkillTree {
/// Total number of unlocked unique keys across all branches.
pub fn total_unlocked_count(&self) -> usize {
let mut keys: HashSet<char> = HashSet::new();
keys.extend(ALWAYS_UNLOCKED_KEYS.iter().copied());
for branch_def in ALL_BRANCHES {
let bp = self.branch_progress(branch_def.id);
match bp.status {
@@ -838,16 +837,17 @@ impl SkillTree {
}
}
/// Count of unique confident keys across all branches.
pub fn total_confident_keys(&self, stats: &KeyStatsStore) -> usize {
/// Count of unique keys with sticky mastery across all branches.
///
/// This intentionally excludes always-unlocked helper keys like space and
/// backspace so aggregate totals match progression completion milestones.
/// Invariant: when every branch's progression keys all satisfy
/// `is_mastered`, the branch is Complete — so this count is monotonic
/// across drills.
pub fn total_mastered_keys(&self, stats: &KeyStatsStore) -> usize {
let mut keys: HashSet<char> = HashSet::new();
for &ch in ALWAYS_UNLOCKED_KEYS {
if stats.get_confidence(ch) >= 1.0 {
keys.insert(ch);
}
}
for &ch in self.primary_letters() {
if stats.get_confidence(ch) >= 1.0 {
if stats.is_mastered(ch) {
keys.insert(ch);
}
}
@@ -857,7 +857,7 @@ impl SkillTree {
}
for level in branch_def.levels {
for &ch in level.keys {
if stats.get_confidence(ch) >= 1.0 {
if stats.is_mastered(ch) {
keys.insert(ch);
}
}
@@ -866,19 +866,19 @@ impl SkillTree {
keys.len()
}
/// Count of confident keys in a branch.
pub fn branch_confident_keys(&self, id: BranchId, stats: &KeyStatsStore) -> usize {
/// Count of sticky-mastered keys in a branch.
pub fn branch_mastered_keys(&self, id: BranchId, stats: &KeyStatsStore) -> usize {
if id == BranchId::Lowercase {
self.primary_letters()
.iter()
.filter(|&&ch| stats.get_confidence(ch) >= 1.0)
.filter(|&&ch| stats.is_mastered(ch))
.count()
} else {
let def = get_branch_definition(id);
def.levels
.iter()
.flat_map(|l| l.keys.iter())
.filter(|&&ch| stats.get_confidence(ch) >= 1.0)
.filter(|&&ch| stats.is_mastered(ch))
.count()
}
}
@@ -895,10 +895,10 @@ mod tests {
use super::*;
use crate::l10n::language_pack::language_packs;
fn make_stats_confident(stats: &mut KeyStatsStore, keys: &[char]) {
fn make_stats_mastered(stats: &mut KeyStatsStore, keys: &[char]) {
for &ch in keys {
for _ in 0..50 {
stats.update_key(ch, 200.0);
stats.update_key_ranked(ch, 200.0);
}
}
}
@@ -920,7 +920,20 @@ mod tests {
#[test]
fn test_total_unique_keys() {
let tree = SkillTree::default();
assert_eq!(tree.total_unique_keys, 98);
assert_eq!(tree.total_unique_keys, 96);
}
#[test]
fn test_progress_totals_exclude_always_unlocked_helper_keys() {
let tree = SkillTree::default();
let mut stats = KeyStatsStore::default();
for _ in 0..50 {
stats.update_key(SPACE, 200.0);
stats.update_key(BACKSPACE, 200.0);
}
assert_eq!(tree.total_unlocked_count(), LOWERCASE_MIN_KEYS);
assert_eq!(tree.total_mastered_keys(&stats), 0);
}
#[test]
@@ -953,8 +966,8 @@ mod tests {
let mut tree = SkillTree::default();
let mut stats = KeyStatsStore::default();
// Make initial 6 keys confident
make_stats_confident(&mut stats, &['e', 't', 'a', 'o', 'i', 'n']);
// Make initial 6 keys mastered
make_stats_mastered(&mut stats, &['e', 't', 'a', 'o', 'i', 'n']);
tree.update(&stats, None);
// Should unlock 7th key ('s')
@@ -968,11 +981,11 @@ mod tests {
let mut tree = SkillTree::default();
let mut stats = KeyStatsStore::default();
// Make all primary letters confident.
// Make all primary letters mastered.
let all_primary = tree.primary_letters().to_vec();
make_stats_confident(&mut stats, &all_primary);
make_stats_mastered(&mut stats, &all_primary);
// Need to repeatedly update as each unlock requires all current keys confident
// Need to repeatedly update as each unlock requires all current keys mastered
for _ in 0..30 {
tree.update(&stats, None);
}
@@ -1027,8 +1040,8 @@ mod tests {
bp.status = BranchStatus::InProgress;
bp.current_level = 0;
// Make level 1 capitals confident: T I A S W H B M
make_stats_confident(&mut stats, &['T', 'I', 'A', 'S', 'W', 'H', 'B', 'M']);
// Make level 1 capitals mastered: T I A S W H B M
make_stats_mastered(&mut stats, &['T', 'I', 'A', 'S', 'W', 'H', 'B', 'M']);
tree.update(&stats, None);
assert_eq!(tree.branch_progress(BranchId::Capitals).current_level, 1);
@@ -1047,9 +1060,9 @@ mod tests {
bp.status = BranchStatus::InProgress;
bp.current_level = 0;
// Make all capital letter levels confident
// Make all capital letter levels mastered
let all_caps: Vec<char> = ('A'..='Z').collect();
make_stats_confident(&mut stats, &all_caps);
make_stats_mastered(&mut stats, &all_caps);
// Update multiple times for level advancement
for _ in 0..5 {
@@ -1069,9 +1082,9 @@ mod tests {
// '-' is shared between ProsePunctuation L2 and CodeSymbols L1
// Master it once
make_stats_confident(&mut stats, &['-']);
make_stats_mastered(&mut stats, &['-']);
// Both branches should see it as confident
// Both branches should see the same live confidence
assert!(stats.get_confidence('-') >= 1.0);
}
@@ -1217,7 +1230,7 @@ mod tests {
// Master keys in configured sequence order and verify unlocked count never decreases.
for &ch in &primary {
make_stats_confident(&mut stats, &[ch]);
make_stats_mastered(&mut stats, &[ch]);
for _ in 0..3 {
tree.update(&stats, None);
let current_count = tree.lowercase_unlocked_count();
@@ -1265,8 +1278,8 @@ mod tests {
let mut tree = SkillTree::default();
let mut stats = KeyStatsStore::default();
// Make initial 6 keys confident
make_stats_confident(&mut stats, &['e', 't', 'a', 'o', 'i', 'n']);
// Make initial 6 keys mastered
make_stats_mastered(&mut stats, &['e', 't', 'a', 'o', 'i', 'n']);
let result = tree.update(&stats, None);
// Should unlock 7th key ('s')
@@ -1285,11 +1298,11 @@ mod tests {
// Snapshot before any key stats are added
let before_stats = stats.clone();
// Make first 5 keys confident
make_stats_confident(&mut stats, &['e', 't', 'a', 'o', 'i']);
// Make first 5 keys mastered
make_stats_mastered(&mut stats, &['e', 't', 'a', 'o', 'i']);
let result = tree.update(&stats, Some(&before_stats));
// The 5 keys that went from <1.0 to >=1.0 should be in newly_mastered
// The 5 keys that flipped their sticky mastered bit should be reported.
for &ch in &['e', 't', 'a', 'o', 'i'] {
assert!(
result.newly_mastered.contains(&ch),
@@ -1331,7 +1344,7 @@ mod tests {
let mut stats = KeyStatsStore::default();
let all_primary = tree.primary_letters().to_vec();
make_stats_confident(&mut stats, &all_primary);
make_stats_mastered(&mut stats, &all_primary);
// Run updates to advance through progressive unlock
let mut found_available = false;
@@ -1388,9 +1401,9 @@ mod tests {
// Set up: capitals InProgress
tree.branch_progress_mut(BranchId::Capitals).status = BranchStatus::InProgress;
// Make all capital letters confident
// Make all capital letters mastered
let all_caps: Vec<char> = ('A'..='Z').collect();
make_stats_confident(&mut stats, &all_caps);
make_stats_mastered(&mut stats, &all_caps);
// Advance through levels
let mut found_complete = false;
@@ -1424,15 +1437,15 @@ mod tests {
let mut tree = SkillTree::default();
let mut stats = KeyStatsStore::default();
// Set all branches to InProgress at last level with all keys confident
// Set all branches to InProgress at last level with all keys mastered
// First complete lowercase
let all_primary = tree.primary_letters().to_vec();
make_stats_confident(&mut stats, &all_primary);
make_stats_mastered(&mut stats, &all_primary);
for _ in 0..30 {
tree.update(&stats, None);
}
// Start all branches and make their keys confident
// Start all branches and make their keys mastered
for &id in &[
BranchId::Capitals,
BranchId::Numbers,
@@ -1443,7 +1456,7 @@ mod tests {
tree.start_branch(id);
let def = get_branch_definition(id);
for level in def.levels {
make_stats_confident(&mut stats, level.keys);
make_stats_mastered(&mut stats, level.keys);
}
}
@@ -1474,10 +1487,10 @@ mod tests {
let mut tree = SkillTree::default();
let mut stats = KeyStatsStore::default();
// Make all keys across all branches confident
// Make all keys across all branches mastered
for branch_def in ALL_BRANCHES {
for level in branch_def.levels {
make_stats_confident(&mut stats, level.keys);
make_stats_mastered(&mut stats, level.keys);
}
}
@@ -1524,7 +1537,7 @@ mod tests {
let mut stats = KeyStatsStore::default();
let all_primary = tree.primary_letters().to_vec();
make_stats_confident(&mut stats, &all_primary);
make_stats_mastered(&mut stats, &all_primary);
for _ in 0..30 {
let result = tree.update(&stats, None);
@@ -1547,7 +1560,7 @@ mod tests {
let mut stats = KeyStatsStore::default();
let all_primary = tree.primary_letters().to_vec();
make_stats_confident(&mut stats, &all_primary);
make_stats_mastered(&mut stats, &all_primary);
for _ in 0..30 {
let result = tree.update(&stats, None);
+24 -8
View File
@@ -452,7 +452,9 @@ pub const CODE_LANGUAGES: &[CodeLanguage] = &[
},
CodeRepo {
key: "jq",
urls: &["https://raw.githubusercontent.com/jqlang/jq/cec6b0f34603edc5cd12db1f63dacdf547b4bb4a/src/builtin.c"],
urls: &[
"https://raw.githubusercontent.com/jqlang/jq/cec6b0f34603edc5cd12db1f63dacdf547b4bb4a/src/builtin.c",
],
},
CodeRepo {
key: "sqlite",
@@ -505,7 +507,9 @@ pub const CODE_LANGUAGES: &[CodeLanguage] = &[
},
CodeRepo {
key: "fmt",
urls: &["https://raw.githubusercontent.com/fmtlib/fmt/cdb8dc76d936a12aacc20b6d283d7c24ee4307fe/include/fmt/format.h"],
urls: &[
"https://raw.githubusercontent.com/fmtlib/fmt/cdb8dc76d936a12aacc20b6d283d7c24ee4307fe/include/fmt/format.h",
],
},
CodeRepo {
key: "abseil",
@@ -759,7 +763,9 @@ pub const CODE_LANGUAGES: &[CodeLanguage] = &[
repos: &[
CodeRepo {
key: "nvm",
urls: &["https://raw.githubusercontent.com/nvm-sh/nvm/001ea8cac1eb61c8f0e29889ea05ab0af69546d8/nvm.sh"],
urls: &[
"https://raw.githubusercontent.com/nvm-sh/nvm/001ea8cac1eb61c8f0e29889ea05ab0af69546d8/nvm.sh",
],
},
CodeRepo {
key: "oh-my-zsh",
@@ -859,7 +865,9 @@ pub const CODE_LANGUAGES: &[CodeLanguage] = &[
repos: &[
CodeRepo {
key: "kong",
urls: &["https://raw.githubusercontent.com/Kong/kong/58f2daa56b90615f78d5953229936192cd1128e9/kong/init.lua"],
urls: &[
"https://raw.githubusercontent.com/Kong/kong/58f2daa56b90615f78d5953229936192cd1128e9/kong/init.lua",
],
},
CodeRepo {
key: "luarocks",
@@ -1476,7 +1484,9 @@ pub const CODE_LANGUAGES: &[CodeLanguage] = &[
repos: &[
CodeRepo {
key: "mojolicious",
urls: &["https://raw.githubusercontent.com/mojolicious/mojo/19fc4f19a0d83204a458ae4a19d192b7eaf4ba81/lib/Mojolicious.pm"],
urls: &[
"https://raw.githubusercontent.com/mojolicious/mojo/19fc4f19a0d83204a458ae4a19d192b7eaf4ba81/lib/Mojolicious.pm",
],
},
CodeRepo {
key: "moose",
@@ -1640,7 +1650,9 @@ pub const CODE_LANGUAGES: &[CodeLanguage] = &[
repos: &[
CodeRepo {
key: "julia-stdlib",
urls: &["https://raw.githubusercontent.com/JuliaLang/julia/e8208497f7f8b4c2ff1282233a65def720328579/base/array.jl"],
urls: &[
"https://raw.githubusercontent.com/JuliaLang/julia/e8208497f7f8b4c2ff1282233a65def720328579/base/array.jl",
],
},
CodeRepo {
key: "flux",
@@ -1731,7 +1743,9 @@ pub const CODE_LANGUAGES: &[CodeLanguage] = &[
repos: &[
CodeRepo {
key: "nim-stdlib",
urls: &["https://raw.githubusercontent.com/nim-lang/Nim/7a82c5920c46fa7a3393ebdecc54716cb1015366/lib/pure/strutils.nim"],
urls: &[
"https://raw.githubusercontent.com/nim-lang/Nim/7a82c5920c46fa7a3393ebdecc54716cb1015366/lib/pure/strutils.nim",
],
},
CodeRepo {
key: "nim-json",
@@ -1822,7 +1836,9 @@ pub const CODE_LANGUAGES: &[CodeLanguage] = &[
repos: &[
CodeRepo {
key: "ocaml-stdlib",
urls: &["https://raw.githubusercontent.com/ocaml/ocaml/d7ee697596b9688569c4db06fc32d3f9fffdeeef/stdlib/list.ml"],
urls: &[
"https://raw.githubusercontent.com/ocaml/ocaml/d7ee697596b9688569c4db06fc32d3f9fffdeeef/stdlib/list.ml",
],
},
CodeRepo {
key: "ocaml-map",
+4 -1
View File
@@ -72,7 +72,10 @@ pub fn passage_options() -> Vec<(&'static str, String)> {
("builtin", t!("select.passage_builtin").to_string()),
];
for book in GUTENBERG_BOOKS {
out.push((book.key, t!("select.passage_book_prefix", title = book.title).to_string()));
out.push((
book.key,
t!("select.passage_book_prefix", title = book.title).to_string(),
));
}
out
}
+15 -9
View File
@@ -2,8 +2,8 @@ pub use rust_i18n::t;
/// Available UI locale codes. Separate from dictionary language support.
pub const SUPPORTED_UI_LOCALES: &[&str] = &[
"en", "de", "es", "fr", "it", "pt", "nl", "sv", "da", "nb", "fi", "pl", "cs", "ro", "hr",
"hu", "lt", "lv", "sl", "et", "tr",
"en", "de", "es", "fr", "it", "pt", "nl", "sv", "da", "nb", "fi", "pl", "cs", "ro", "hr", "hu",
"lt", "lv", "sl", "et", "tr",
];
pub fn set_ui_locale(locale: &str) {
@@ -18,7 +18,11 @@ pub fn set_ui_locale(locale: &str) {
/// Retrieve the set of all translation keys for a given locale.
/// Used by the catalog parity test to verify every key exists in every locale.
#[cfg(test)]
fn collect_yaml_keys(value: &serde_yaml::Value, prefix: &str, keys: &mut std::collections::BTreeSet<String>) {
fn collect_yaml_keys(
value: &serde_yaml::Value,
prefix: &str,
keys: &mut std::collections::BTreeSet<String>,
) {
match value {
serde_yaml::Value::Mapping(map) => {
for (k, v) in map {
@@ -54,9 +58,7 @@ pub fn localized_language_layout_error(
layout = layout_key
)
.to_string(),
LanguageBlockedBySupportLevel(key) => {
t!("errors.language_blocked", key = key).to_string()
}
LanguageBlockedBySupportLevel(key) => t!("errors.language_blocked", key = key).to_string(),
}
}
@@ -67,8 +69,8 @@ mod tests {
fn locale_keys(locale: &str) -> BTreeSet<String> {
let path = format!("locales/{locale}.yml");
let content = std::fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("Failed to read {path}: {e}"));
let content =
std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("Failed to read {path}: {e}"));
let root: serde_yaml::Value = serde_yaml::from_str(&content)
.unwrap_or_else(|e| panic!("Failed to parse {path}: {e}"));
let mut keys = BTreeSet::new();
@@ -111,7 +113,11 @@ mod tests {
}
}
assert!(errors.is_empty(), "Catalog parity errors:\n{}", errors.join("\n"));
assert!(
errors.is_empty(),
"Catalog parity errors:\n{}",
errors.join("\n")
);
}
#[test]
+27 -10
View File
@@ -20,6 +20,7 @@ use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Padding, Paragraph, Widget, Wrap};
use crate::app::{App, AppScreen, DrillMode, MilestoneKind, SettingItem, StatusKind};
use crate::engine::key_stats::MASTERY_MIN_SPEED_CONFIDENCE;
use crate::engine::skill_tree::{BranchStatus, DrillScope, find_key_branch, get_branch_definition};
use crate::event::{AppEvent, EventHandler};
use crate::generator::code_syntax::{code_language_options, is_language_cached, language_by_key};
@@ -3671,7 +3672,7 @@ fn render_menu(frame: &mut ratatui::Frame, app: &App) {
};
let total_keys = app.skill_tree.total_unique_keys;
let unlocked = app.skill_tree.total_unlocked_count();
let mastered = app.skill_tree.total_confident_keys(&app.ranked_key_stats);
let mastered = app.skill_tree.total_mastered_keys(&app.ranked_key_stats);
let header_info = t!(
"menu.key_progress",
unlocked = unlocked,
@@ -5822,7 +5823,7 @@ fn render_stats(frame: &mut ratatui::Frame, app: &App) {
app.stats_tab,
app.config.target_wpm,
app.skill_tree.total_unlocked_count(),
app.skill_tree.total_confident_keys(&app.ranked_key_stats),
app.skill_tree.total_mastered_keys(&app.ranked_key_stats),
app.skill_tree.total_unique_keys,
app.theme,
app.history_selected,
@@ -7718,16 +7719,23 @@ fn render_keyboard_detail_panel(frame: &mut ratatui::Frame, app: &App, area: Rec
)
});
// Ranked-only mastery display (same semantics as skill tree per-key progress)
let ranked_conf = app.ranked_key_stats.get_confidence(selected).min(1.0);
let mastery_bar_width = 10usize;
let filled = (ranked_conf * mastery_bar_width as f64).round() as usize;
let mastery_bar = format!(
// Ranked-only mastery display (sticky mastery bit + a live speed-confidence bar
// scaled to the promotion threshold).
let is_mastered_ranked = app.ranked_key_stats.is_mastered(selected);
let ranked_conf_raw = app.ranked_key_stats.get_confidence(selected);
let confidence_bar_fill = if is_mastered_ranked {
1.0
} else {
(ranked_conf_raw / MASTERY_MIN_SPEED_CONFIDENCE).min(1.0)
};
let confidence_bar_width = 10usize;
let filled = (confidence_bar_fill * confidence_bar_width as f64).round() as usize;
let confidence_bar = format!(
"{}{}",
"\u{2588}".repeat(filled),
"\u{2591}".repeat(mastery_bar_width.saturating_sub(filled))
"\u{2591}".repeat(confidence_bar_width.saturating_sub(filled))
);
let mastery_text = format!("{mastery_bar} {:>3.0}%", ranked_conf * 100.0);
let confidence_text = format!("{confidence_bar} {:>3.0}%", confidence_bar_fill * 100.0);
let mut left_col: Vec<String> = vec![
format!(
@@ -7788,7 +7796,16 @@ fn render_keyboard_detail_panel(frame: &mut ratatui::Frame, app: &App, area: Rec
}
));
if is_unlocked {
right_col.push(format!("{}{mastery_text}", t!("keyboard.mastery_label")));
let mastery_status = if is_mastered_ranked {
t!("keyboard.mastery_mastered").to_string()
} else {
t!("keyboard.mastery_in_progress").to_string()
};
right_col.push(format!("{}{mastery_status}", t!("keyboard.mastery_label")));
right_col.push(format!(
"{}{confidence_text}",
t!("keyboard.speed_confidence_label")
));
} else {
right_col.push(format!(
"{}{}",
+1 -1
View File
@@ -7,7 +7,7 @@ use crate::engine::key_stats::KeyStatsStore;
use crate::engine::skill_tree::SkillTreeProgress;
use crate::session::result::DrillResult;
pub const SCHEMA_VERSION: u32 = 3;
pub const SCHEMA_VERSION: u32 = 4;
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ProfileData {
+9 -8
View File
@@ -89,7 +89,7 @@ impl Widget for BranchProgressList<'_> {
let unlocked = self.skill_tree.branch_unlocked_count(branch_id);
let mastered = self
.skill_tree
.branch_confident_keys(branch_id, self.key_stats);
.branch_mastered_keys(branch_id, self.key_stats);
let (m_bar, u_bar, e_bar) = compact_dual_bar_parts(mastered, unlocked, total, 12);
lines.push(Line::from(vec![
Span::styled(
@@ -113,7 +113,7 @@ impl Widget for BranchProgressList<'_> {
}
let total = self.skill_tree.total_unique_keys;
let unlocked = self.skill_tree.total_unlocked_count();
let mastered = self.skill_tree.total_confident_keys(self.key_stats);
let mastered = self.skill_tree.total_mastered_keys(self.key_stats);
let left_pad = if area.width >= 90 {
3
} else if area.width >= 70 {
@@ -126,12 +126,13 @@ impl Widget for BranchProgressList<'_> {
let right_pad = if area.width >= 75 { 2 } else { 0 };
let overall_label = t!("progress.overall_key_progress");
let label = format!("{}{} ", " ".repeat(left_pad), overall_label);
let unlocked_mastered = t!("progress.unlocked_mastered", unlocked = unlocked, total = total, mastered = mastered);
let suffix = format!(
" {}{}",
unlocked_mastered,
" ".repeat(right_pad)
let unlocked_mastered = t!(
"progress.unlocked_mastered",
unlocked = unlocked,
total = total,
mastered = mastered
);
let suffix = format!(" {}{}", unlocked_mastered, " ".repeat(right_pad));
let reserved = label.len() + suffix.len();
let bar_width = (area.width as usize).saturating_sub(reserved).max(6);
let (m_bar, u_bar, e_bar) =
@@ -170,7 +171,7 @@ fn render_branch_cell<'a>(
let def = get_branch_definition(branch_id);
let total = SkillTree::branch_total_keys(branch_id);
let unlocked = skill_tree.branch_unlocked_count(branch_id);
let mastered = skill_tree.branch_confident_keys(branch_id, key_stats);
let mastered = skill_tree.branch_mastered_keys(branch_id, key_stats);
let prefix = if is_active { "\u{25b6} " } else { "\u{00b7} " };
let label_color = if is_active {
+17 -4
View File
@@ -45,7 +45,10 @@ impl Widget for Dashboard<'_> {
let footer_line_count = if self.input_lock_remaining_ms.is_some() {
1u16
} else {
let hint_continue = hint::hint(hint::K_C_ENTER_SPACE, t!("dashboard.hint_continue").as_ref());
let hint_continue = hint::hint(
hint::K_C_ENTER_SPACE,
t!("dashboard.hint_continue").as_ref(),
);
let hint_retry = hint::hint(hint::K_R, t!("dashboard.hint_retry").as_ref());
let hint_menu = hint::hint(hint::K_Q, t!("dashboard.hint_menu").as_ref());
let hint_stats = hint::hint(hint::K_S, t!("dashboard.hint_stats").as_ref());
@@ -118,14 +121,21 @@ impl Widget for Dashboard<'_> {
};
let accuracy_label = t!("dashboard.accuracy_label");
let acc_text = format!("{:.1}%", self.result.accuracy);
let acc_detail = t!("dashboard.correct_detail", correct = self.result.correct, total = self.result.total_chars);
let acc_detail = t!(
"dashboard.correct_detail",
correct = self.result.correct,
total = self.result.total_chars
);
let acc_line = Line::from(vec![
Span::styled(accuracy_label.to_string(), Style::default().fg(colors.fg())),
Span::styled(
&*acc_text,
Style::default().fg(acc_color).add_modifier(Modifier::BOLD),
),
Span::styled(acc_detail.to_string(), Style::default().fg(colors.text_pending())),
Span::styled(
acc_detail.to_string(),
Style::default().fg(colors.text_pending()),
),
]);
Paragraph::new(acc_line).render(layout[2], buf);
@@ -168,7 +178,10 @@ impl Widget for Dashboard<'_> {
),
]))
} else {
let hint_continue = hint::hint(hint::K_C_ENTER_SPACE, t!("dashboard.hint_continue").as_ref());
let hint_continue = hint::hint(
hint::K_C_ENTER_SPACE,
t!("dashboard.hint_continue").as_ref(),
);
let hint_retry = hint::hint(hint::K_R, t!("dashboard.hint_retry").as_ref());
let hint_menu = hint::hint(hint::K_Q, t!("dashboard.hint_menu").as_ref());
let hint_stats = hint::hint(hint::K_S, t!("dashboard.hint_stats").as_ref());
+2 -9
View File
@@ -24,10 +24,7 @@ pub struct Menu<'a> {
impl<'a> Menu<'a> {
pub fn new(theme: &'a Theme) -> Self {
Self {
selected: 0,
theme,
}
Self { selected: 0, theme }
}
pub fn item_count() -> usize {
@@ -110,11 +107,7 @@ impl Widget for &Menu<'_> {
" {indicator} [{key:<key_width$}] {label}",
key_width = key_width,
);
let desc_text = format!(
" {:indent$}{description}",
"",
indent = key_width + 4
);
let desc_text = format!(" {:indent$}{description}", "", indent = key_width + 4);
let lines = vec![
Line::from(Span::styled(
+80 -45
View File
@@ -4,11 +4,11 @@ use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Paragraph, Widget, Wrap};
use crate::i18n::t;
use crate::engine::key_stats::KeyStatsStore;
use crate::engine::key_stats::{KeyStatsStore, MASTERY_MIN_SPEED_CONFIDENCE};
use crate::engine::skill_tree::{
BranchId, BranchStatus, DrillScope, SkillTree as SkillTreeEngine, get_branch_definition,
};
use crate::i18n::t;
use crate::ui::hint;
use crate::ui::layout::{pack_hint_lines, wrapped_line_count};
use crate::ui::theme::Theme;
@@ -40,7 +40,11 @@ impl<'a> SkillTreeWidget<'a> {
}
fn locked_branch_notice(skill_tree: &SkillTreeEngine) -> String {
t!("skill_tree.locked_notice", count = skill_tree.primary_letters().len()).to_string()
t!(
"skill_tree.locked_notice",
count = skill_tree.primary_letters().len()
)
.to_string()
}
/// Get the list of selectable branch IDs (Lowercase first, then other branches).
@@ -148,11 +152,7 @@ impl Widget for SkillTreeWidget<'_> {
let bp = self.skill_tree.branch_progress(branches[self.selected]);
if *self.skill_tree.branch_status(branches[self.selected]) == BranchStatus::Locked {
(
vec![
h_navigate.as_str(),
h_scroll.as_str(),
h_back.as_str(),
],
vec![h_navigate.as_str(), h_scroll.as_str(), h_back.as_str()],
Some(locked_branch_notice(self.skill_tree)),
)
} else if bp.status == BranchStatus::Available {
@@ -177,21 +177,13 @@ impl Widget for SkillTreeWidget<'_> {
)
} else {
(
vec![
h_navigate.as_str(),
h_scroll.as_str(),
h_back.as_str(),
],
vec![h_navigate.as_str(), h_scroll.as_str(), h_back.as_str()],
None,
)
}
} else {
(
vec![
h_navigate.as_str(),
h_scroll.as_str(),
h_back.as_str(),
],
vec![h_navigate.as_str(), h_scroll.as_str(), h_back.as_str()],
None,
)
};
@@ -312,9 +304,9 @@ impl SkillTreeWidget<'_> {
let bp = self.skill_tree.branch_progress(branch_id);
let def = get_branch_definition(branch_id);
let total_keys = self.skill_tree.branch_total_keys_for(branch_id);
let confident_keys = self
let mastered_keys = self
.skill_tree
.branch_confident_keys(branch_id, self.key_stats);
.branch_mastered_keys(branch_id, self.key_stats);
let is_selected = i == self.selected;
let (prefix, style) = match bp.status {
@@ -335,18 +327,24 @@ impl SkillTreeWidget<'_> {
};
let unlocked = self.skill_tree.branch_unlocked_count(branch_id);
let mastered_text = if confident_keys > 0 {
format!(" ({confident_keys} {})", t!("skill_tree.mastered"))
let mastered_text = if mastered_keys > 0 {
format!(" ({mastered_keys} {})", t!("skill_tree.mastered"))
} else {
String::new()
};
let status_text = match bp.status {
BranchStatus::Complete => {
format!("{unlocked}/{total_keys} {}{mastered_text}", t!("skill_tree.unlocked"))
format!(
"{unlocked}/{total_keys} {}{mastered_text}",
t!("skill_tree.unlocked")
)
}
BranchStatus::InProgress => {
if branch_id == BranchId::Lowercase {
format!("{unlocked}/{total_keys} {}{mastered_text}", t!("skill_tree.unlocked"))
format!(
"{unlocked}/{total_keys} {}{mastered_text}",
t!("skill_tree.unlocked")
)
} else {
format!(
"{} {}/{} {unlocked}/{total_keys} {}{mastered_text}",
@@ -364,7 +362,10 @@ impl SkillTreeWidget<'_> {
let sel_indicator = if is_selected { "> " } else { " " };
lines.push(Line::from(vec![
Span::styled(format!("{sel_indicator}{prefix}{}", def.display_name()), style),
Span::styled(
format!("{sel_indicator}{prefix}{}", def.display_name()),
style,
),
Span::styled(
format!(" {status_text}"),
Style::default().fg(colors.text_pending()),
@@ -372,7 +373,7 @@ impl SkillTreeWidget<'_> {
]));
let (mastered_bar, unlocked_bar, empty_bar) =
dual_progress_bar_parts(confident_keys, unlocked, total_keys, 30);
dual_progress_bar_parts(mastered_keys, unlocked, total_keys, 30);
lines.push(Line::from(vec![
Span::styled(" ", style),
Span::styled(mastered_bar, Style::default().fg(colors.text_correct())),
@@ -388,7 +389,10 @@ impl SkillTreeWidget<'_> {
lines.push(Line::from(Span::styled(
format!(
" \u{2500}\u{2500} {} \u{2500}\u{2500}",
t!("skill_tree.branches_separator", count = self.skill_tree.primary_letters().len())
t!(
"skill_tree.branches_separator",
count = self.skill_tree.primary_letters().len()
)
),
Style::default().fg(colors.text_pending()),
)));
@@ -429,15 +433,26 @@ impl SkillTreeWidget<'_> {
let level_text = if branch_id == BranchId::Lowercase {
let unlocked = self.skill_tree.branch_unlocked_count(BranchId::Lowercase);
let total = self.skill_tree.branch_total_keys_for(BranchId::Lowercase);
t!("skill_tree.unlocked_letters", unlocked = unlocked, total = total).to_string()
t!(
"skill_tree.unlocked_letters",
unlocked = unlocked,
total = total
)
.to_string()
} else {
match bp.status {
BranchStatus::InProgress => {
t!("skill_tree.level", current = bp.current_level + 1, total = def.levels.len()).to_string()
}
BranchStatus::Complete => {
t!("skill_tree.level", current = def.levels.len(), total = def.levels.len()).to_string()
}
BranchStatus::InProgress => t!(
"skill_tree.level",
current = bp.current_level + 1,
total = def.levels.len()
)
.to_string(),
BranchStatus::Complete => t!(
"skill_tree.level",
current = def.levels.len(),
total = def.levels.len()
)
.to_string(),
_ => t!("skill_tree.level_zero", total = def.levels.len()).to_string(),
}
};
@@ -468,9 +483,11 @@ impl SkillTreeWidget<'_> {
};
for (level_idx, level) in def.levels.iter().enumerate() {
let level_is_locked = !(bp.status == BranchStatus::Complete || level_idx < bp.current_level
let level_is_locked = !(bp.status == BranchStatus::Complete
|| level_idx < bp.current_level
|| (bp.status == BranchStatus::InProgress && level_idx == bp.current_level));
let level_status_owned = if bp.status == BranchStatus::Complete || level_idx < bp.current_level {
let level_status_owned =
if bp.status == BranchStatus::Complete || level_idx < bp.current_level {
t!("skill_tree.complete").to_string()
} else if bp.status == BranchStatus::InProgress && level_idx == bp.current_level {
t!("skill_tree.in_progress").to_string()
@@ -481,7 +498,11 @@ impl SkillTreeWidget<'_> {
// Level header
lines.push(Line::from(Span::styled(
format!(" L{}: {} ({level_status})", level_idx + 1, level.display_name()),
format!(
" L{}: {} ({level_status})",
level_idx + 1,
level.display_name()
),
Style::default().fg(colors.fg()),
)));
@@ -493,8 +514,15 @@ impl SkillTreeWidget<'_> {
};
for &key in &level_keys {
let is_focused = focused == Some(key);
let confidence = self.key_stats.get_confidence(key).min(1.0);
let is_confident = confidence >= 1.0;
let is_mastered = self.key_stats.is_mastered(key);
let raw_confidence = self.key_stats.get_confidence(key);
// Bar reflects progress toward the promotion speed threshold; mastered keys
// always display as full.
let bar_fill = if is_mastered {
1.0
} else {
(raw_confidence / MASTERY_MIN_SPEED_CONFIDENCE).min(1.0)
};
// For Lowercase, check if this specific key is unlocked
let is_locked = if branch_id == BranchId::Lowercase {
@@ -517,28 +545,35 @@ impl SkillTreeWidget<'_> {
format!(" {display} "),
Style::default().fg(colors.text_pending()),
),
Span::styled(t!("skill_tree.locked_status").to_string(), Style::default().fg(colors.text_pending())),
Span::styled(
t!("skill_tree.locked_status").to_string(),
Style::default().fg(colors.text_pending()),
),
]));
} else {
let bar_width = 10;
let filled = (confidence * bar_width as f64).round() as usize;
let filled = (bar_fill * bar_width as f64).round() as usize;
let empty = bar_width - filled;
let bar = format!("{}{}", "\u{2588}".repeat(filled), "\u{2591}".repeat(empty));
let pct_str = format!("{:>3.0}%", confidence * 100.0);
let focus_label = if is_focused { t!("skill_tree.in_focus").to_string() } else { String::new() };
let pct_str = format!("{:>3.0}%", bar_fill * 100.0);
let focus_label = if is_focused {
t!("skill_tree.in_focus").to_string()
} else {
String::new()
};
let key_style = if is_focused {
Style::default()
.fg(colors.bg())
.bg(colors.focused_key())
.add_modifier(Modifier::BOLD)
} else if is_confident {
} else if is_mastered {
Style::default().fg(colors.text_correct())
} else {
Style::default().fg(colors.fg())
};
let bar_color = if is_confident {
let bar_color = if is_mastered {
colors.text_correct()
} else {
colors.accent()
+81 -17
View File
@@ -8,11 +8,11 @@ use std::collections::{BTreeSet, HashMap};
use crate::engine::key_stats::KeyStatsStore;
use crate::engine::ngram_stats::{AnomalyType, FocusSelection};
use crate::i18n::t;
use crate::keyboard::display::{self, BACKSPACE, ENTER, MODIFIER_SENTINELS, SPACE, TAB};
use crate::keyboard::model::KeyboardModel;
use crate::session::result::DrillResult;
use crate::ui::components::activity_heatmap::ActivityHeatmap;
use crate::i18n::t;
use crate::ui::hint;
use crate::ui::layout::pack_hint_lines;
use crate::ui::theme::Theme;
@@ -58,6 +58,10 @@ pub struct StatsDashboard<'a> {
}
impl<'a> StatsDashboard<'a> {
fn format_focus_detail(detail: String, anomaly_label: &str) -> String {
detail.replace("%{type}", anomaly_label).replace("%%", "%")
}
pub fn new(
history: &'a [DrillResult],
key_stats: &'a KeyStatsStore,
@@ -333,7 +337,10 @@ impl StatsDashboard<'_> {
colors.error()
}),
),
Span::styled(total_time_label.to_string(), Style::default().fg(colors.fg())),
Span::styled(
total_time_label.to_string(),
Style::default().fg(colors.fg()),
),
Span::styled(&*time_str, Style::default().fg(colors.text_pending())),
]),
];
@@ -585,7 +592,13 @@ impl StatsDashboard<'_> {
} else {
colors.accent()
};
let wpm_label = t!("stats.wpm_label", avg = format!("{avg_wpm:.0}"), target = self.target_wpm, pct = format!("{wpm_pct:.0}")).to_string();
let wpm_label = t!(
"stats.wpm_label",
avg = format!("{avg_wpm:.0}"),
target = self.target_wpm,
pct = format!("{wpm_pct:.0}")
)
.to_string();
render_text_bar(
&wpm_label,
wpm_pct / 100.0,
@@ -620,7 +633,13 @@ impl StatsDashboard<'_> {
} else {
0.0
};
let level_label = t!("stats.keys_label", unlocked = self.overall_unlocked, total = self.overall_total, mastered = self.overall_mastered).to_string();
let level_label = t!(
"stats.keys_label",
unlocked = self.overall_unlocked,
total = self.overall_total,
mastered = self.overall_mastered
)
.to_string();
render_text_bar(
&level_label,
key_pct,
@@ -688,7 +707,11 @@ impl StatsDashboard<'_> {
" "
};
let rank_label = if result.ranked { t!("stats.yes") } else { t!("stats.no") };
let rank_label = if result.ranked {
t!("stats.yes")
} else {
t!("stats.no")
};
let rank_str = rank_label.as_ref();
let partial_pct = if result.partial {
result.completion_percent
@@ -1308,7 +1331,10 @@ impl StatsDashboard<'_> {
.fg(colors.accent())
.add_modifier(Modifier::BOLD),
),
Span::styled(active_days_label.to_string(), Style::default().fg(colors.fg())),
Span::styled(
active_days_label.to_string(),
Style::default().fg(colors.fg()),
),
Span::styled(
format!("{active_days_count}"),
Style::default().fg(colors.text_pending()),
@@ -1414,14 +1440,20 @@ impl StatsDashboard<'_> {
let bigram_label = format!("\"{}{}\"", key.0[0], key.0[1]);
// Line 1: both focuses
lines.push(Line::from(vec![
Span::styled(t!("stats.focus_char_label").to_string(), Style::default().fg(colors.fg())),
Span::styled(
t!("stats.focus_char_label").to_string(),
Style::default().fg(colors.fg()),
),
Span::styled(
t!("stats.focus_char_value", ch = ch).to_string(),
Style::default()
.fg(colors.focused_key())
.add_modifier(Modifier::BOLD),
),
Span::styled(t!("stats.focus_plus").to_string(), Style::default().fg(colors.fg())),
Span::styled(
t!("stats.focus_plus").to_string(),
Style::default().fg(colors.fg()),
),
Span::styled(
t!("stats.focus_bigram_value", label = &bigram_label).to_string(),
Style::default()
@@ -1435,16 +1467,28 @@ impl StatsDashboard<'_> {
AnomalyType::Error => t!("stats.anomaly_error").to_string(),
AnomalyType::Speed => t!("stats.anomaly_speed").to_string(),
};
let detail = t!("stats.focus_detail_both", ch = ch, label = &bigram_label, r#type = &type_label, pct = format!("{anomaly_pct:.0}"));
let detail = Self::format_focus_detail(
t!(
"stats.focus_detail_both",
ch = ch,
label = &bigram_label,
pct = format!("{anomaly_pct:.0}")
)
.to_string(),
&type_label,
);
lines.push(Line::from(Span::styled(
detail.to_string(),
detail,
Style::default().fg(colors.text_pending()),
)));
}
}
(Some(ch), None) => {
lines.push(Line::from(vec![
Span::styled(t!("stats.focus_char_label").to_string(), Style::default().fg(colors.fg())),
Span::styled(
t!("stats.focus_char_label").to_string(),
Style::default().fg(colors.fg()),
),
Span::styled(
t!("stats.focus_char_value", ch = ch).to_string(),
Style::default()
@@ -1466,7 +1510,10 @@ impl StatsDashboard<'_> {
AnomalyType::Speed => t!("stats.anomaly_speed").to_string(),
};
lines.push(Line::from(vec![
Span::styled(t!("stats.focus_char_label").to_string(), Style::default().fg(colors.fg())),
Span::styled(
t!("stats.focus_char_label").to_string(),
Style::default().fg(colors.fg()),
),
Span::styled(
t!("stats.focus_bigram_value", label = &bigram_label).to_string(),
Style::default()
@@ -1474,7 +1521,14 @@ impl StatsDashboard<'_> {
.add_modifier(Modifier::BOLD),
),
Span::styled(
t!("stats.focus_detail_bigram_only", r#type = &type_label, pct = format!("{anomaly_pct:.0}")).to_string(),
Self::format_focus_detail(
t!(
"stats.focus_detail_bigram_only",
pct = format!("{anomaly_pct:.0}")
)
.to_string(),
&type_label,
),
Style::default().fg(colors.text_pending()),
),
]));
@@ -1606,7 +1660,10 @@ impl StatsDashboard<'_> {
}
fn render_error_anomalies(&self, data: &NgramTabData, area: Rect, buf: &mut Buffer) {
let title = t!("stats.error_anomalies_title", count = data.error_anomalies.len());
let title = t!(
"stats.error_anomalies_title",
count = data.error_anomalies.len()
);
let empty_msg = t!("stats.no_error_anomalies");
self.render_anomaly_panel(
title.as_ref(),
@@ -1619,7 +1676,10 @@ impl StatsDashboard<'_> {
}
fn render_speed_anomalies(&self, data: &NgramTabData, area: Rect, buf: &mut Buffer) {
let title = t!("stats.speed_anomalies_title", count = data.speed_anomalies.len());
let title = t!(
"stats.speed_anomalies_title",
count = data.speed_anomalies.len()
);
let empty_msg = t!("stats.no_speed_anomalies");
self.render_anomaly_panel(
title.as_ref(),
@@ -1636,9 +1696,13 @@ impl StatsDashboard<'_> {
let w = area.width as usize;
// Build segments from most to least important, progressively drop from the right
let scope = t!("stats.scope_label_prefix", ).to_string() + &data.scope_label;
let scope = t!("stats.scope_label_prefix",).to_string() + &data.scope_label;
let bigrams = t!("stats.bi_label", count = data.total_bigrams).to_string();
let hesitation = t!("stats.hes_label", ms = format!("{:.0}", data.hesitation_threshold_ms)).to_string();
let hesitation = t!(
"stats.hes_label",
ms = format!("{:.0}", data.hesitation_threshold_ms)
)
.to_string();
let segments: &[&str] = &[&scope, &bigrams, &hesitation];
let mut line = String::new();
+5 -2
View File
@@ -4,9 +4,9 @@ use ratatui::style::Style;
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Paragraph, Widget};
use crate::i18n::t;
use crate::session::drill::DrillState;
use crate::session::result::DrillResult;
use crate::i18n::t;
use crate::ui::theme::Theme;
pub struct StatsSidebar<'a> {
@@ -198,7 +198,10 @@ impl Widget for StatsSidebar<'_> {
if prior_count > 0 {
lines.push(Line::from(vec![
Span::styled(vs_avg_label.as_ref(), Style::default().fg(colors.text_pending())),
Span::styled(
vs_avg_label.as_ref(),
Style::default().fg(colors.text_pending()),
),
Span::styled(wpm_delta_str, Style::default().fg(wpm_delta_color)),
]));
}
+1 -2
View File
@@ -111,8 +111,7 @@ fn choose_cursor_style(colors: &crate::ui::theme::ThemeColors) -> Style {
// REVERSED modifier so the terminal itself swaps fg/bg, which guarantees a
// visible cursor in every colour scheme.
if colors.text_cursor_bg() == Color::Reset && colors.text_cursor_fg() == Color::Reset {
return Style::default()
.add_modifier(Modifier::REVERSED | Modifier::BOLD);
return Style::default().add_modifier(Modifier::REVERSED | Modifier::BOLD);
}
let base_bg = colors.bg();
+277 -76
View File
@@ -1,10 +1,13 @@
use std::collections::{BTreeSet, HashSet};
use std::fs;
use std::path::{Path, PathBuf};
use std::path::PathBuf;
use std::process::Command;
use std::sync::Once;
use chrono::Datelike;
use keydr::engine::key_stats::{
KeyStatsStore, MASTERY_MAX_ERROR_RATE_EMA, MASTERY_MIN_SAMPLES, MASTERY_MIN_SPEED_CONFIDENCE,
};
use keydr::engine::scoring::level_from_score;
use keydr::engine::skill_tree::{ALL_BRANCHES, BranchId, BranchStatus, DrillScope, SkillTree};
use keydr::store::json_store::JsonStore;
@@ -26,9 +29,6 @@ static GENERATE: Once = Once::new();
/// Ensure test-profiles/ exists by running the generator binary (once per test run).
fn ensure_profiles_generated() {
GENERATE.call_once(|| {
if Path::new("test-profiles/03-near-lowercase-complete.json").exists() {
return;
}
let status = Command::new("cargo")
.args(["run", "--bin", "generate_test_profiles"])
.status()
@@ -147,19 +147,40 @@ fn assert_profile_valid(name: &str) {
);
}
// Invariant #2 + #8: all keys in completed branches have confidence >= 1.0
// and completed branches have stats for all their keys
// Invariant #2 + #8: all keys in completed branches are sticky-mastered
// and completed branches have stats for all their keys.
//
// Progression invariants are checked against `ranked_key_stats`: that is the
// only authoritative store for mastery. Unranked `key_stats` shares the type
// but must never carry the sticky bit.
let completed_keys = completed_branch_keys(&data);
for &key in &completed_keys {
assert!(
data.key_stats.stats.stats.contains_key(&key),
"{name}: key '{key}' in completed branch has no stats entry"
data.ranked_key_stats.stats.stats.contains_key(&key),
"{name}: key '{key}' in completed branch has no ranked stats entry"
);
let stat = &data.key_stats.stats.stats[&key];
let stat = &data.ranked_key_stats.stats.stats[&key];
assert!(
stat.confidence >= 1.0,
"{name}: key '{key}' in completed branch has confidence {} < 1.0",
stat.confidence
stat.mastered,
"{name}: key '{key}' in completed branch must have mastered == true, got stat with confidence {} sample_count {} error_rate_ema {}",
stat.confidence, stat.sample_count, stat.error_rate_ema
);
// Deliberately NOT asserting qualifies_for_mastery() here: mastery is
// sticky, so a key that earned it may currently sit below the gate (a
// recent error spikes error_rate_ema, a slow stretch drops confidence).
// What must hold is that the key accumulated real evidence.
assert!(
stat.sample_count >= MASTERY_MIN_SAMPLES,
"{name}: key '{key}' in completed branch is mastered with only {} samples",
stat.sample_count
);
}
// Only ranked activity may promote mastery.
for (&key, stat) in &data.key_stats.stats.stats {
assert!(
!stat.mastered,
"{name}: unranked key_stats entry '{key}' must not carry the sticky mastery bit"
);
}
@@ -284,10 +305,10 @@ fn ranked_stats_cover_ranked_drill_keys() {
}
}
// ── Invariant #2: in-progress keys have confidence < 1.0 ────────────────
// ── Invariant #2: in-progress keys have mastered == false ──────────────
#[test]
fn in_progress_keys_have_partial_confidence() {
fn in_progress_keys_are_not_mastered() {
// Profiles 2, 3, 5, 6 have in-progress branches with partial keys
for name in &[
"02-early-lowercase.json",
@@ -299,21 +320,22 @@ fn in_progress_keys_have_partial_confidence() {
let data = load_profile(name);
let ip_keys = in_progress_level_keys(&data);
// At least some in-progress keys should have confidence < 1.0
// At least some in-progress keys should not yet be sticky-mastered.
// Mastery is only authoritative in ranked stats.
let partial_count = ip_keys
.iter()
.filter(|&&k| {
data.key_stats
data.ranked_key_stats
.stats
.stats
.get(&k)
.is_some_and(|s| s.confidence < 1.0)
.is_some_and(|s| !s.mastered)
})
.count();
assert!(
partial_count > 0,
"{name}: expected some in-progress keys with confidence < 1.0, \
but all {} in-progress keys are mastered",
"{name}: expected some in-progress keys with mastered == false, \
but all {} in-progress keys are already mastered",
ip_keys.len()
);
}
@@ -415,109 +437,288 @@ fn streak_and_last_practice_date_consistent_with_history() {
}
}
// ── Profile-specific confidence bands ────────────────────────────────────
/// Mastery assertions read `ranked_key_stats` — the authoritative progression
/// store. Unranked `key_stats` never carries the sticky bit.
fn assert_sticky_mastered(name: &str, key: char, data: &ExportData) {
let stat = &data.ranked_key_stats.stats.stats[&key];
assert!(
stat.mastered,
"{name}: key '{key}' should have mastered == true"
);
// Sticky mastery survives later dips, so assert durable evidence rather than
// that the key still clears the gate right now.
assert!(
stat.sample_count >= MASTERY_MIN_SAMPLES,
"{name}: key '{key}' is mastered with only {} samples",
stat.sample_count
);
}
fn assert_not_mastered(name: &str, key: char, data: &ExportData) {
let stat = &data.ranked_key_stats.stats.stats[&key];
assert!(
!stat.mastered,
"{name}: key '{key}' should have mastered == false"
);
assert!(
!stat.qualifies_for_mastery(),
"{name}: key '{key}' should miss the mastery gate"
);
}
// ── Profile-specific mastery semantics ───────────────────────────────────
#[test]
fn profile_specific_confidence_bands() {
// Profile 02: s,h,r,d should be partial (0.3-0.7); e,t,a,o,i,n should be mastered
fn profile_specific_mastery_semantics() {
// Profile 02: s,h,r,d are partial; e,t,a,o,i,n are sticky-mastered.
{
let data = load_profile("02-early-lowercase.json");
let stats = &data.key_stats.stats.stats;
for &k in &['e', 't', 'a', 'o', 'i', 'n'] {
let conf = stats[&k].confidence;
assert!(conf >= 1.0, "02: key '{k}' should be mastered, got {conf}");
assert_sticky_mastered("02", k, &data);
}
for &k in &['s', 'h', 'r', 'd'] {
let conf = stats[&k].confidence;
assert!(
(0.2..1.0).contains(&conf),
"02: key '{k}' should be partial (0.2-1.0), got {conf}"
);
assert_not_mastered("02", k, &data);
}
}
// Profile 03: first 14 keys mastered, w,f,g,y partial (0.4-0.8)
// Profile 03: first 14 keys mastered; w,f,g,y remain partial.
{
let data = load_profile("03-mid-lowercase.json");
let stats = &data.key_stats.stats.stats;
let all_lc: Vec<char> = "etaoinshrdlcum".chars().collect();
for &k in &all_lc {
let conf = stats[&k].confidence;
assert!(conf >= 1.0, "03: key '{k}' should be mastered, got {conf}");
assert_sticky_mastered("03", k, &data);
}
for &k in &['w', 'f', 'g', 'y'] {
let conf = stats[&k].confidence;
assert!(
(0.3..1.0).contains(&conf),
"03: key '{k}' should be partial (0.3-1.0), got {conf}"
);
assert_not_mastered("03", k, &data);
}
}
// Profile 03-near: first 24 lowercase keys mastered, one key near mastery.
// Profile 03-near: first 24 lowercase keys mastered; one key misses only speed.
{
let data = load_profile("03-near-lowercase-complete.json");
let stats = &data.key_stats.stats.stats;
let stats = &data.ranked_key_stats.stats.stats;
let almost_all_lc: Vec<char> = "etaoinshrdlcumwfgypbvkjx".chars().collect();
for &k in &almost_all_lc {
let conf = stats[&k].confidence;
assert!(
conf >= 1.0,
"03-near: key '{k}' should be mastered, got {conf}"
);
assert_sticky_mastered("03-near", k, &data);
}
let q_conf = stats[&'q'].confidence;
let q_stat = &stats[&'q'];
assert!(
(0.8..1.0).contains(&q_conf),
"03-near: key 'q' should be near mastery (0.8-1.0), got {q_conf}"
!q_stat.mastered,
"03-near: key 'q' should not yet be sticky-mastered"
);
assert!(
q_stat.sample_count >= MASTERY_MIN_SAMPLES,
"03-near: key 'q' should already meet the sample gate"
);
assert!(
q_stat.error_rate_ema <= MASTERY_MAX_ERROR_RATE_EMA,
"03-near: key 'q' should already meet the accuracy gate"
);
assert!(
q_stat.confidence < MASTERY_MIN_SPEED_CONFIDENCE && q_stat.confidence >= 1.0,
"03-near: key 'q' should miss only the speed gate, got confidence {}",
q_stat.confidence
);
}
// Profile 05: capitals L2 partial (J,D,R,C,E), numbers partial (1,2,3),
// punctuation partial (.,',')
// Profile 05: capitals L2 partial (J,D,R,C), numbers partial (1,2,3),
// punctuation partial (.,',').
{
let data = load_profile("05-multi-branch.json");
let stats = &data.key_stats.stats.stats;
for &k in &['J', 'D', 'R', 'C'] {
let conf = stats[&k].confidence;
assert!(
(0.2..1.0).contains(&conf),
"05: key '{k}' should be partial, got {conf}"
);
assert_not_mastered("05", k, &data);
}
for &k in &['1', '2', '3'] {
let conf = stats[&k].confidence;
assert!(
(0.2..1.0).contains(&conf),
"05: key '{k}' should be partial, got {conf}"
);
assert_not_mastered("05", k, &data);
}
for &k in &['.', ',', '\''] {
let conf = stats[&k].confidence;
assert!(
(0.2..1.0).contains(&conf),
"05: key '{k}' should be partial, got {conf}"
);
assert_not_mastered("05", k, &data);
}
}
// Profile 06: code symbols L3 partial (&,|,^,~)
{
let data = load_profile("06-advanced.json");
let stats = &data.key_stats.stats.stats;
for &k in &['&', '|', '^', '~'] {
let conf = stats[&k].confidence;
assert_not_mastered("06", k, &data);
}
assert_sticky_mastered("06", '!', &data);
}
}
/// A branch parked at an in-progress level claims that level is still being
/// learned. If its keys were already mastered in the authoritative store, the
/// saved progression and the ranked stats would contradict each other.
#[test]
fn current_level_keys_are_not_mastered_in_ranked_stats() {
for name in ALL_PROFILES {
let data = load_profile(name);
for branch_def in ALL_BRANCHES {
// Lowercase unlocks key-by-key rather than by level, so its frontier
// legitimately mixes mastered and unmastered keys.
if branch_def.id == BranchId::Lowercase {
continue;
}
let Some(bp) = data.profile.skill_tree.branches.get(branch_def.id.to_key()) else {
continue;
};
if bp.status != BranchStatus::InProgress || bp.current_level >= branch_def.levels.len()
{
continue;
}
// Keys shared with an already-completed branch are legitimately mastered.
let completed = completed_branch_keys(&data);
for &key in branch_def.levels[bp.current_level].keys {
if completed.contains(&key) {
continue;
}
if let Some(stat) = data.ranked_key_stats.stats.stats.get(&key) {
assert!(
(0.2..1.0).contains(&conf),
"06: key '{k}' should be partial, got {conf}"
!stat.mastered,
"{name}: key '{key}' is in {:?}'s current (in-progress) level {} \
but is already mastered in ranked stats",
branch_def.id, bp.current_level
);
}
// '!' is shared with completed ProsePunctuation, must be mastered
let bang_conf = stats[&'!'].confidence;
assert!(
bang_conf >= 1.0,
"06: key '!' should be mastered (shared with complete branch), got {bang_conf}"
}
}
}
}
/// The persisted stores must be reproducible from the fixture's own history.
///
/// Replaying `drill_history` the way the app does has to yield exactly the
/// persisted `key_stats`/`ranked_key_stats` — including the sticky `mastered`
/// bits — otherwise a fixture encodes a state the engine could never produce.
#[test]
fn fixture_stats_match_replay_of_their_drill_history() {
for name in ALL_PROFILES {
let data = load_profile(name);
let mut replayed_global = KeyStatsStore {
target_cpm: data.key_stats.stats.target_cpm,
..KeyStatsStore::default()
};
let mut replayed_ranked = KeyStatsStore {
target_cpm: data.ranked_key_stats.stats.target_cpm,
..KeyStatsStore::default()
};
for drill in &data.drill_history.drills {
for kt in &drill.per_key_times {
if kt.correct {
replayed_global.update_key(kt.key, kt.time_ms);
} else {
replayed_global.update_key_error(kt.key);
}
if drill.ranked {
if kt.correct {
replayed_ranked.update_key_ranked(kt.key, kt.time_ms);
} else {
replayed_ranked.update_key_error_ranked(kt.key);
}
}
}
}
assert_stores_match(
name,
"ranked_key_stats",
&replayed_ranked,
&data.ranked_key_stats.stats,
);
assert_stores_match(name, "key_stats", &replayed_global, &data.key_stats.stats);
}
}
/// Compare a replayed store against a persisted one.
///
/// Discrete state (mastery, counts) must match exactly. Float fields are
/// compared with a tolerance: the persisted values made a round trip through
/// JSON before being recomputed, so requiring bit-identical results would be
/// asserting something about float formatting rather than about the engine.
fn assert_stores_match(
name: &str,
label: &str,
replayed: &KeyStatsStore,
persisted: &KeyStatsStore,
) {
let replayed_keys: BTreeSet<char> = replayed.stats.keys().copied().collect();
let persisted_keys: BTreeSet<char> = persisted.stats.keys().copied().collect();
assert_eq!(
replayed_keys, persisted_keys,
"{name}: {label} covers different keys than a replay of its drill history"
);
for (&key, expected) in &persisted.stats {
let actual = &replayed.stats[&key];
assert_eq!(
actual.mastered, expected.mastered,
"{name}: {label} key '{key}' mastered bit disagrees with replay"
);
assert_eq!(
actual.sample_count, expected.sample_count,
"{name}: {label} key '{key}' sample_count disagrees with replay"
);
assert_eq!(
actual.error_count, expected.error_count,
"{name}: {label} key '{key}' error_count disagrees with replay"
);
assert_eq!(
actual.total_count, expected.total_count,
"{name}: {label} key '{key}' total_count disagrees with replay"
);
for (field, a, e) in [
("confidence", actual.confidence, expected.confidence),
(
"filtered_time_ms",
actual.filtered_time_ms,
expected.filtered_time_ms,
),
("best_time_ms", actual.best_time_ms, expected.best_time_ms),
(
"error_rate_ema",
actual.error_rate_ema,
expected.error_rate_ema,
),
] {
assert!(
(a - e).abs() <= 1e-9 * e.abs().max(1.0),
"{name}: {label} key '{key}' {field} disagrees with replay: {a} vs {e}"
);
}
}
}
/// Replaying a fixture's ranked history through the skill tree must reproduce
/// the progression state the fixture ships, at least to the extent that every
/// completed branch is backed by genuinely mastered keys.
#[test]
fn fixture_progression_is_backed_by_replayed_mastery() {
for name in ALL_PROFILES {
let data = load_profile(name);
let mut replayed = KeyStatsStore {
target_cpm: data.ranked_key_stats.stats.target_cpm,
..KeyStatsStore::default()
};
for drill in data.drill_history.drills.iter().filter(|d| d.ranked) {
for kt in &drill.per_key_times {
if kt.correct {
replayed.update_key_ranked(kt.key, kt.time_ms);
} else {
replayed.update_key_error_ranked(kt.key);
}
}
}
for &key in &completed_branch_keys(&data) {
assert!(
replayed.is_mastered(key),
"{name}: key '{key}' is in a completed branch but replaying the fixture's \
ranked history does not earn mastery for it"
);
}
}
}