diff --git a/Cargo.lock b/Cargo.lock index a93b037..770b1d6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -26,6 +26,12 @@ dependencies = [ "libc", ] +[[package]] +name = "anes" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" + [[package]] name = "anstream" version = "0.6.21" @@ -163,6 +169,12 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + [[package]] name = "castaway" version = "0.2.4" @@ -208,6 +220,33 @@ dependencies = [ "windows-link", ] +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + [[package]] name = "clap" version = "4.5.57" @@ -302,6 +341,67 @@ dependencies = [ "libc", ] +[[package]] +name = "criterion" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f" +dependencies = [ + "anes", + "cast", + "ciborium", + "clap", + "criterion-plot", + "is-terminal", + "itertools 0.10.5", + "num-traits", + "once_cell", + "oorandom", + "plotters", + "rayon", + "regex", + "serde", + "serde_derive", + "serde_json", + "tinytemplate", + "walkdir", +] + +[[package]] +name = "criterion-plot" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1" +dependencies = [ + "cast", + "itertools 0.10.5", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + [[package]] name = "crossterm" version = "0.28.1" @@ -345,6 +445,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-common" version = "0.1.7" @@ -710,6 +816,17 @@ dependencies = [ "tracing", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hashbrown" version = "0.16.1" @@ -727,6 +844,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "hex" version = "0.4.3" @@ -1031,12 +1154,32 @@ dependencies = [ "serde", ] +[[package]] +name = "is-terminal" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" +dependencies = [ + "hermit-abi", + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.14.0" @@ -1080,6 +1223,7 @@ dependencies = [ "anyhow", "chrono", "clap", + "criterion", "crossterm 0.28.1", "dirs", "rand", @@ -1320,6 +1464,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + [[package]] name = "openssl" version = "0.10.75" @@ -1521,6 +1671,34 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "plotters" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" +dependencies = [ + "num-traits", + "plotters-backend", + "plotters-svg", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "plotters-backend" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" + +[[package]] +name = "plotters-svg" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" +dependencies = [ + "plotters-backend", +] + [[package]] name = "portable-atomic" version = "1.13.1" @@ -1629,7 +1807,7 @@ dependencies = [ "compact_str", "hashbrown", "indoc", - "itertools", + "itertools 0.14.0", "kasuari", "lru", "strum", @@ -1682,7 +1860,7 @@ dependencies = [ "hashbrown", "indoc", "instability", - "itertools", + "itertools 0.14.0", "line-clipping", "ratatui-core", "strum", @@ -1691,6 +1869,26 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "rayon" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -2358,6 +2556,16 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinytemplate" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "tokio" version = "1.49.0" @@ -2546,7 +2754,7 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" dependencies = [ - "itertools", + "itertools 0.14.0", "unicode-segmentation", "unicode-width", ] diff --git a/Cargo.toml b/Cargo.toml index 9ff9109..28337ee 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,11 @@ reqwest = { version = "0.12", features = ["blocking"], optional = true } [dev-dependencies] tempfile = "3" +criterion = { version = "0.5", features = ["html_reports"] } + +[[bench]] +name = "ngram_benchmarks" +harness = false [features] default = ["network"] diff --git a/benches/ngram_benchmarks.rs b/benches/ngram_benchmarks.rs new file mode 100644 index 0000000..43afa97 --- /dev/null +++ b/benches/ngram_benchmarks.rs @@ -0,0 +1,144 @@ +use criterion::{Criterion, black_box, criterion_group, criterion_main}; + +use keydr::engine::key_stats::KeyStatsStore; +use keydr::engine::ngram_stats::{ + BigramKey, BigramStatsStore, TrigramStatsStore, extract_ngram_events, +}; +use keydr::session::result::KeyTime; + +fn make_keystrokes(count: usize) -> Vec { + let chars = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j']; + (0..count) + .map(|i| KeyTime { + key: chars[i % chars.len()], + time_ms: 200.0 + (i % 50) as f64, + correct: i % 7 != 0, // ~14% error rate + }) + .collect() +} + +fn bench_extraction(c: &mut Criterion) { + let keystrokes = make_keystrokes(500); + + c.bench_function("extract_ngram_events (500 keystrokes)", |b| { + b.iter(|| { + extract_ngram_events(black_box(&keystrokes), 800.0) + }) + }); +} + +fn bench_update(c: &mut Criterion) { + let keystrokes = make_keystrokes(500); + let (bigram_events, _) = extract_ngram_events(&keystrokes, 800.0); + + c.bench_function("bigram_stats update (400 events)", |b| { + b.iter(|| { + let mut store = BigramStatsStore::default(); + for ev in bigram_events.iter().take(400) { + store.update( + black_box(ev.key.clone()), + black_box(ev.total_time_ms), + black_box(ev.correct), + black_box(ev.has_hesitation), + 0, + ); + } + store + }) + }); +} + +fn bench_focus_selection(c: &mut Criterion) { + // Use a-z + A-Z + 0-9 = 62 chars for up to 3844 unique bigrams + let all_chars: Vec = ('a'..='z').chain('A'..='Z').chain('0'..='9').collect(); + + let mut bigram_stats = BigramStatsStore::default(); + let mut char_stats = KeyStatsStore::default(); + + for &ch in &all_chars { + let stat = char_stats.stats.entry(ch).or_default(); + stat.confidence = 0.8; + stat.filtered_time_ms = 430.0; + stat.sample_count = 50; + stat.total_count = 50; + stat.error_count = 3; + } + + let mut count: usize = 0; + for &a in &all_chars { + for &b in &all_chars { + if bigram_stats.stats.len() >= 3000 { + break; + } + let key = BigramKey([a, b]); + let stat = bigram_stats.stats.entry(key).or_default(); + stat.confidence = 0.5 + (count % 50) as f64 * 0.01; + stat.sample_count = 25 + count % 30; + stat.error_count = 5 + count % 10; + stat.redundancy_streak = if count % 3 == 0 { 3 } else { 1 }; + count += 1; + } + } + assert_eq!(bigram_stats.stats.len(), 3000); + + let unlocked: Vec = all_chars; + + c.bench_function("weakest_bigram (3K entries)", |b| { + b.iter(|| { + bigram_stats.weakest_bigram(black_box(&char_stats), black_box(&unlocked)) + }) + }); +} + +fn bench_history_replay(c: &mut Criterion) { + // Build 500 drills of ~300 keystrokes each + let drills: Vec> = (0..500) + .map(|_| make_keystrokes(300)) + .collect(); + + c.bench_function("history replay (500 drills x 300 keystrokes)", |b| { + b.iter(|| { + let mut bigram_stats = BigramStatsStore::default(); + let mut trigram_stats = TrigramStatsStore::default(); + let mut key_stats = KeyStatsStore::default(); + + for (drill_idx, keystrokes) in drills.iter().enumerate() { + let (bigram_events, trigram_events) = + extract_ngram_events(keystrokes, 800.0); + + for kt in keystrokes { + if kt.correct { + let stat = key_stats.stats.entry(kt.key).or_default(); + stat.total_count += 1; + } else { + key_stats.update_key_error(kt.key); + } + } + + for ev in &bigram_events { + bigram_stats.update( + ev.key.clone(), ev.total_time_ms, ev.correct, ev.has_hesitation, + drill_idx as u32, + ); + } + for ev in &trigram_events { + trigram_stats.update( + ev.key.clone(), ev.total_time_ms, ev.correct, ev.has_hesitation, + drill_idx as u32, + ); + } + } + + (bigram_stats, trigram_stats, key_stats) + }) + }); +} + +criterion_group!( + benches, + bench_extraction, + bench_update, + bench_focus_selection, + bench_history_replay, +); +criterion_main!(benches); diff --git a/docs/plans/2026-02-22-n-gram-error-tracking-adaptive-drill-selection.md b/docs/plans/2026-02-22-n-gram-error-tracking-adaptive-drill-selection.md new file mode 100644 index 0000000..a6f2548 --- /dev/null +++ b/docs/plans/2026-02-22-n-gram-error-tracking-adaptive-drill-selection.md @@ -0,0 +1,338 @@ +# N-gram Error Tracking for Adaptive Drill Selection + +## 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. + +**Goal:** Add bigram (n=2) and trigram (n=3) error tracking, with a redundancy detection formula that distinguishes genuine transition difficulties from errors that are just proxies for single-character weakness. Integrate problematic bigrams into the adaptive drill selection pipeline. Trigrams are tracked for observation only and not used for drill generation until empirically proven useful. + +--- + +## Research Summary + +1. **N-gram tracking is genuinely novel** -- No existing typing tutor does comprehensive n-gram *error* tracking with adaptive drill selection. + +2. **Bigrams capture real, distinct information** -- The 136M Keystrokes study (Dhakal et al., CHI 2018) found letter pairs typed by different hands are more predictive of speed than character repetitions. This cannot be inferred from single-char data. + +3. **Motor chunking is real** -- The motor cortex plans keystrokes in chunks, not individually. Single-character optimization misses this. + +4. **Bigrams are the sweet spot** -- Nearly all keyboard layout research focuses on bigrams. Trigrams likely offer diminishing returns. + +--- + +## Core Innovation: Redundancy Detection + +The key question: "Is a high-error bigram just a proxy for a high-error character?" + +### Error Rate Estimation (Laplace-smoothed) + +Raw error rates are unstable at low sample counts. All error rates use Laplace smoothing: + +``` +smoothed_error_rate(errors, samples) = (errors + 1) / (samples + 2) +``` + +This gives a Bayesian prior of 50% error rate that gets pulled toward the true rate as samples accumulate. At 10 samples with 3 errors, this yields 0.333 instead of raw 0.3 -- a small correction. At 2 samples with 1 error, it yields 0.5 instead of raw 0.5 -- stabilizing the estimate. + +### Bigram Redundancy Formula + +For bigram "ab" with characters `a` and `b`: + +``` +e_a = smoothed_error_rate(char_a.errors, char_a.samples) +e_b = smoothed_error_rate(char_b.errors, char_b.samples) +e_ab = smoothed_error_rate(bigram_ab.errors, bigram_ab.samples) + +expected_ab = 1.0 - (1.0 - e_a) * (1.0 - e_b) +redundancy_ab = e_ab / max(expected_ab, 0.01) +``` + +### Trigram Redundancy Formula + +For trigram "abc", redundancy is computed against BOTH individual chars AND constituent bigrams: + +``` +// Expected from chars alone (independence assumption) +expected_from_chars = 1.0 - (1.0 - e_a) * (1.0 - e_b) * (1.0 - e_c) + +// Expected from bigrams (takes the max -- if either bigram explains the error, no trigram signal) +expected_from_bigrams = max(e_ab, e_bc) + +// Use the higher expectation (harder to exceed = more conservative) +expected_abc = max(expected_from_chars, expected_from_bigrams) +redundancy_abc = e_abc / max(expected_abc, 0.01) +``` + +This ensures trigrams only flag as informative when NEITHER the individual characters NOR constituent bigrams explain the difficulty. + +### Focus Eligibility (Stability-Gated) + +An n-gram becomes eligible for focus only when ALL conditions hold: + +1. `sample_count >= 20` -- minimum statistical reliability +2. `redundancy > 1.5` -- genuine transition difficulty, not a proxy +3. `redundancy_stable == true` -- the redundancy score has been > 1.5 for the last 3 consecutive update checks (prevents focus flapping from noisy estimates) + +The **difficulty score** for ranking eligible n-grams: + +``` +ngram_difficulty = (1.0 - confidence) * redundancy +``` + +### Worked Examples + +**Example 1 -- Proxy (should NOT focus):** User struggles with 's'. `e_s = 0.25`, `e_i = 0.03`. Expected bigram "is" error: `1 - 0.75 * 0.97 = 0.273`. Observed "is" error: `0.28`. Redundancy: `0.28 / 0.273 = 1.03`. This is ~1.0, confirming "is" errors are just 's' errors. Not eligible. + +**Example 2 -- Genuine difficulty (should focus):** User is fine with 'e' and 'd' individually. `e_e = 0.04`, `e_d = 0.05`. Expected "ed" error: `1 - 0.96 * 0.95 = 0.088`. Observed "ed" error: `0.22`. Redundancy: `0.22 / 0.088 = 2.5`. This exceeds 1.5 -- the "ed" transition is genuinely hard. Eligible for focus. + +**Example 3 -- Trigram vs bigram:** `e_t = 0.03`, `e_h = 0.04`, `e_e = 0.04`. Bigram `e_th = 0.15` (genuine difficulty). Expected trigram "the" from chars: `0.107`. Expected from bigrams: `max(0.15, 0.04) = 0.15`. Observed "the" error: `0.16`. Redundancy: `0.16 / 0.15 = 1.07`. Not significant -- the "th" bigram already explains the trigram difficulty. Trigram NOT eligible. + +--- + +## Confidence Scale + +`NgramStat.confidence` uses the same formula as `KeyStat.confidence`: + +``` +target_time_ms = 60000.0 / target_cpm // 342.86ms at 175 CPM +confidence = target_time_ms / filtered_time_ms +``` + +- `confidence < 1.0`: Slower than target (needs practice) +- `confidence == 1.0`: Exactly at target speed +- `confidence > 1.0`: Faster than target (mastered) + +For n-grams, `target_time_ms` scales linearly with order: a bigram target is `2 * single_char_target`, a trigram target is `3 * single_char_target`. This is approximate but consistent. + +--- + +## Hesitation Tracking + +Hesitations indicate cognitive uncertainty even when the correct key is pressed. The threshold is **relative to the user's rolling baseline**: + +``` +hesitation_threshold = max(800.0, 2.5 * user_median_transition_ms) +``` + +Where `user_median_transition_ms` is the median of the user's last 200 inter-keystroke intervals across all drills. The 800ms absolute floor prevents the threshold from being too low for fast typists. The 2.5x multiplier flags transitions that are notably slower than the user's norm. + +`user_median_transition_ms` is stored as a single rolling value on the App struct, updated from `per_key_times` after each drill. + +--- + +## N-gram Key Representation + +N-gram keys use typed arrays instead of strings to avoid encoding/canonicalization issues: + +```rust +#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct BigramKey(pub [char; 2]); + +#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct TrigramKey(pub [char; 3]); +``` + +**Normalization rules** (applied at extraction boundary in `extract_ngram_events`): +- All characters are Unicode scalar values (Rust `char`) -- no grapheme cluster handling needed since the app only supports ASCII typing +- No case folding -- 'A' and 'a' are distinct (they require different motor actions: shift+a vs a) +- Punctuation is included (transitions to/from punctuation are legitimate motor sequences) +- BACKSPACE characters are filtered out before windowing +- Space characters split windows (no cross-word-boundary n-grams) + +--- + +## Implementation + +### Phase 1: Core Data Structures & Extraction + +**New file: `src/engine/ngram_stats.rs`** + +- `BigramKey(pub [char; 2])` and `TrigramKey(pub [char; 3])` -- typed keys with Hash/Eq/Serialize +- `NgramStat` struct: + - `filtered_time_ms: f64` -- EMA-smoothed transition time (alpha=0.1) + - `best_time_ms: f64` -- personal best EMA time + - `confidence: f64` -- `(target_time_ms * order) / filtered_time_ms` + - `sample_count: usize` -- total observations + - `error_count: usize` -- total errors (mistype or hesitation) + - `hesitation_count: usize` -- total hesitations specifically + - `recent_times: Vec` -- last 30 observations + - `recent_correct: Vec` -- last 30 correctness values + - `redundancy_streak: u8` -- consecutive updates where redundancy > 1.5 (for stability gate, max 255) +- `BigramStatsStore` -- `HashMap` (concrete, not generic) + - `update(&mut self, key: BigramKey, time_ms: f64, correct: bool, hesitation: bool)` + - `get_confidence(&self, key: &BigramKey) -> f64` + - `smoothed_error_rate(&self, key: &BigramKey) -> f64` -- Laplace-smoothed + - `redundancy_score(&self, key: &BigramKey, char_stats: &KeyStatsStore) -> f64` + - `weakest_bigram(&self, char_stats: &KeyStatsStore, unlocked: &[char]) -> Option<(BigramKey, f64)>` -- stability-gated +- `TrigramStatsStore` -- `HashMap` (concrete, not generic) + - Same update/query methods as BigramStatsStore + - `prune(&mut self, max_entries: usize)` -- composite utility pruning (see below) +- Internal: shared helper functions/trait for the common EMA update logic to avoid duplication between bigram and trigram stores +- `BigramEvent` / `TrigramEvent` structs -- `{ key, total_time_ms, correct, has_hesitation }` +- `extract_ngram_events(per_key_times: &[KeyTime], hesitation_threshold: f64) -> (Vec, Vec)` -- single pass, returns both orders +- `FocusTarget` enum -- `Char(char) | Bigram(BigramKey)` -- lives in `src/engine/ngram_stats.rs`, re-exported from `src/engine/mod.rs` + +**Note:** `KeyStatsStore` needs a new method `smoothed_error_rate(key: char) -> f64` to provide Laplace-smoothed error rates. This requires adding `error_count` to `KeyStat`. Currently `KeyStat` only tracks timing for correct keystrokes -- we need to also count errors. Add `error_count: usize` and `total_count: usize` fields to `KeyStat`, increment in `update_key()`. Use `#[serde(default)]` for backward compat on deserialization. + +**Modify: `src/engine/key_stats.rs`** (additive) +- Add `error_count: usize` and `total_count: usize` to `KeyStat` with `#[serde(default)]` +- Add `update_key_error(&mut self, key: char)` -- increments error/total counts without updating timing +- Add `smoothed_error_rate(&self, key: char) -> f64` -- Laplace-smoothed + +**Modify: `src/engine/mod.rs`** (additive) -- add `pub mod ngram_stats`, re-export `FocusTarget` + +**Extraction detail:** For bigram "th", transition time = `window[1].time_ms`. For trigram "the", transition time = `window[1].time_ms + window[2].time_ms`. The first element's `time_ms` is the transition FROM the previous character and is NOT part of this n-gram. + +### Phase 2: Persistence (Replay-Only, No Caching) + +**Architecture:** `drill_history` (lesson_history.json) is the **sole source of truth**. N-gram stats are **always rebuilt from drill history** on startup. There are no separate n-gram cache files in this initial implementation. This eliminates all cache coherency concerns at the cost of ~200-500ms startup replay. Caching can be added later as an optimization if rebuild latency becomes problematic. + +**Modify: `src/store/schema.rs`** (additive) +- Add concrete `BigramStatsData { stats: BigramStatsStore }` with Default impl +- Add concrete `TrigramStatsData { stats: TrigramStatsStore }` with Default impl +- These types are used for export/import serialization only, not for runtime caching + +**Modify: `src/app.rs`** (additive + modify existing) +- Add 4 fields to `App`: `bigram_stats`, `ranked_bigram_stats`, `trigram_stats`, `ranked_trigram_stats` +- Add `user_median_transition_ms: f64` and `transition_buffer: Vec` (rolling last 200 intervals) +- On startup: rebuild all n-gram stats + hesitation baseline by replaying `drill_history` +- `save_data()`: no n-gram files to save (stats are always derived) + +**Trigram pruning:** Max 5,000 entries. Prune by composite utility score after history replay: +``` +utility = recency_weight * (1.0 / (drills_since_last_seen + 1)) + + signal_weight * redundancy_score.min(3.0) + + data_weight * (sample_count as f64).ln() +``` +Where `recency_weight=0.3`, `signal_weight=0.5`, `data_weight=0.2`. Entries with highest utility are kept. This preserves rare-but-informative trigrams over frequent-but-noisy ones. + +### Phase 3: Drill Integration + +**Modify: `src/app.rs` -- `finish_drill()`** (modify existing, after line 847) +- Compute `hesitation_threshold = max(800.0, 2.5 * self.user_median_transition_ms)` +- Call `extract_ngram_events(&result.per_key_times, hesitation_threshold)` +- Update `bigram_stats` and `trigram_stats` with each event +- For incorrect keystrokes: also call `self.key_stats.update_key_error(kt.key)` to build char-level error counts +- Same pattern for ranked stats in the ranked block (after line 854) +- Update `transition_buffer` and recompute `user_median_transition_ms` + +**Modify: `src/app.rs` -- `finish_partial_drill()`** -- same pattern + +**Hesitation baseline rebuild:** During startup history replay, also accumulate transition times into `transition_buffer` to rebuild `user_median_transition_ms`. This ensures the hesitation threshold is consistent across restarts. + +### Phase 4: Adaptive Focus Selection (Bigram Only) + +The focus pipeline uses a **thin adapter at the App boundary** rather than changing generator signatures directly. This minimizes cross-cutting risk. + +**Modify: `src/app.rs` -- `generate_text()`** (modify existing, line 628) + +```rust +// Adapter: compute focus target, then decompose into existing generator knobs +let focus_target = select_focus_target( + &self.skill_tree, scope, &self.ranked_key_stats, &self.ranked_bigram_stats +); + +let (focused_char, focused_bigram) = match &focus_target { + FocusTarget::Char(ch) => (Some(*ch), None), + FocusTarget::Bigram(key) => (Some(key.0[0]), Some(key.clone())), +}; + +// Existing generators use focused_char unchanged +let mut text = generator.generate(&filter, lowercase_focused_char, word_count); +// ... existing capitalize/punctuate/numbers pipeline unchanged ... + +// After all generation: if bigram focus, swap some words for bigram-containing words +if let Some(ref bigram) = focused_bigram { + text = self.apply_bigram_focus(&text, &filter, bigram); +} +``` + +**New method on `App`: `apply_bigram_focus()`** +- Scans generated words, replaces up to 40% with dictionary words containing the target bigram +- Only replaces when suitable alternatives exist and pass the CharFilter +- Maintains word count and approximate text length +- **Diversity cap:** No more than 3 consecutive bigram-focused words to prevent repetitive feel + +This approach keeps ALL existing generator APIs unchanged. If the adapter proves insufficient (e.g., bigram-focused words are too rare in dictionary), we can widen generator APIs in a follow-up. + +**Focus selection logic** (new function `select_focus_target()` in `src/engine/ngram_stats.rs`): +1. Compute weakest single character via existing `focused_key()` +2. Compute weakest eligible bigram via `weakest_bigram()` (stability-gated: sample >= 20, redundancy > 1.5 for 3 consecutive checks) +3. If bigram `ngram_difficulty > char_difficulty * 0.8`, focus on bigram +4. Otherwise, fall back to single-char focus + +### Phase 5: Information Gain Analysis (Trigram Observation) + +**Add to `src/engine/ngram_stats.rs`:** + +```rust +pub fn trigram_marginal_gain( + trigram_stats: &TrigramStatsStore, + bigram_stats: &BigramStatsStore, + char_stats: &KeyStatsStore, +) -> f64 +``` + +Computes what fraction of trigrams with >= 20 samples have `redundancy > 1.5` vs their constituent bigrams. Returns a value in `[0.0, 1.0]`. + +- Called every 50 drills, result logged to a `trigram_gain_history: Vec` on the App +- If the most recent 3 measurements all show gain > 10%, trigrams could be promoted to active focus (future work) +- This metric is primarily for analysis -- it answers "are trigrams adding value beyond bigrams for this user?" + +### Phase 6: Export/Import + +**Modify: `src/store/schema.rs`** (additive) -- add n-gram fields to `ExportData` with `#[serde(default)]` +**Modify: `src/store/json_store.rs`** (additive) -- update `export_all()` to serialize n-gram stats from memory; `import_all()` imports them into drill_history replay pipeline + +--- + +## Performance Budgets + +| Operation | Budget | Notes | +|-----------|--------|-------| +| N-gram extraction per drill | < 1ms | Linear scan of ~200-500 keystrokes | +| Stats update per drill | < 1ms | ~400 bigram + ~300 trigram hash map inserts | +| Focus selection | < 5ms | Iterate all bigrams (~2K), filter + rank | +| History replay (full rebuild) | < 500ms | Replay 500 drills x extraction + update (fixture: 500 drills, 300 keystrokes each) | +| Memory for n-gram stores | < 5MB | ~3K bigrams + 5K trigrams x ~200 bytes each | + +Benchmark tests enforce extraction (<1ms for 500 keystrokes), update (<1ms for 400 events), and focus selection (<5ms for 3K bigrams) budgets. + +--- + +## Files Summary + +| File | Action | Breaking? | What Changes | +|------|--------|-----------|-------------| +| `src/engine/ngram_stats.rs` | **New** | No | All n-gram structs, extraction, redundancy formula, FocusTarget, focus selection | +| `src/engine/mod.rs` | Modify | No (additive) | Add `pub mod ngram_stats`, re-export `FocusTarget` | +| `src/engine/key_stats.rs` | Modify | No (additive) | Add `error_count`/`total_count` to `KeyStat` with `#[serde(default)]`, add `smoothed_error_rate()` | +| `src/store/schema.rs` | Modify | No (additive) | `BigramStatsData`/`TrigramStatsData` types, `ExportData` update with `#[serde(default)]` | +| `src/store/json_store.rs` | Modify | No (additive) | Export/import n-gram data | +| `src/app.rs` | Modify | No (internal) | App fields, `finish_drill()` n-gram extraction, `generate_text()` adapter + `apply_bigram_focus()`, startup replay | +| `src/generator/dictionary.rs` | Unchanged | - | Existing `find_matching` used as-is via adapter | +| `src/generator/phonetic.rs` | Unchanged | - | Existing API used as-is via adapter | + +--- + +## Verification + +1. **Unit tests** for `extract_ngram_events` -- verify bigram/trigram extraction from known keystroke sequences, BACKSPACE filtering, space-boundary skipping, hesitation detection at threshold boundary +2. **Unit tests** for `redundancy_score` -- the 3 worked examples above as test cases, plus edge cases (zero samples, all errors, no errors) +3. **Unit tests** for Laplace smoothing -- verify convergence behavior at low and high sample counts +4. **Unit tests** for stability gate -- verify `redundancy_streak` increments/resets correctly, focus eligibility requires 3 consecutive hits +5. **Deterministic integration tests** for focus selection -- seed `SmallRng` with fixed seed, verify tie-breaking behavior between char and bigram focus, verify fallback when no bigrams are eligible +6. **Regression test** -- verify existing single-character focus works unchanged when no bigrams have sufficient samples (cold start path) +7. **Benchmark tests** (non-blocking, `#[bench]` or criterion): + - Extraction: < 1ms for 500 `KeyTime` entries + - Update: < 1ms for 400 bigram events + - Focus selection: < 5ms for 3,000 bigram entries + - History replay: < 500ms for 500 drills of 300 keystrokes each +8. **Manual test** -- deliberately mistype a specific bigram repeatedly, verify it becomes the focus target and subsequent drills contain words with that bigram + +## Future Considerations (Not in Scope) + +- **N-gram cache files** for faster startup if replay latency becomes problematic (hybrid append-only cursor approach) +- **Per-order empirical confidence targets** instead of linear scaling (calibrate from user data, log diagnostics) +- **Bigram placement control** in phonetic generator (prefix/medial/suffix weighting) if adapter approach proves insufficient +- **Trigram-driven focus** if marginal gain metric consistently shows > 10% incremental value diff --git a/src/app.rs b/src/app.rs index ac4e898..9f4aeec 100644 --- a/src/app.rs +++ b/src/app.rs @@ -11,6 +11,11 @@ use rand::rngs::SmallRng; use crate::config::Config; use crate::engine::filter::CharFilter; use crate::engine::key_stats::KeyStatsStore; +use crate::engine::FocusTarget; +use crate::engine::ngram_stats::{ + self, BigramKey, BigramStatsStore, TrigramStatsStore, extract_ngram_events, + select_focus_target, +}; use crate::engine::scoring; use crate::engine::skill_tree::{BranchId, BranchStatus, DrillScope, SkillTree}; use crate::generator::TextGenerator; @@ -35,7 +40,7 @@ use crate::keyboard::display::BACKSPACE; use crate::session::drill::DrillState; use crate::session::input::{self, KeystrokeEvent}; -use crate::session::result::DrillResult; +use crate::session::result::{DrillResult, KeyTime}; use crate::store::json_store::JsonStore; use crate::store::schema::{DrillHistoryData, ExportData, KeyStatsData, ProfileData, EXPORT_VERSION}; use crate::ui::components::menu::Menu; @@ -260,6 +265,13 @@ pub struct App { pub keyboard_explorer_selected: Option, pub explorer_accuracy_cache_overall: Option<(char, usize, usize)>, pub explorer_accuracy_cache_ranked: Option<(char, usize, usize)>, + pub bigram_stats: BigramStatsStore, + pub ranked_bigram_stats: BigramStatsStore, + pub trigram_stats: TrigramStatsStore, + pub ranked_trigram_stats: TrigramStatsStore, + pub user_median_transition_ms: f64, + pub transition_buffer: Vec, + pub trigram_gain_history: Vec, rng: SmallRng, transition_table: TransitionTable, #[allow(dead_code)] @@ -402,6 +414,13 @@ impl App { keyboard_explorer_selected: None, explorer_accuracy_cache_overall: None, explorer_accuracy_cache_ranked: None, + bigram_stats: BigramStatsStore::default(), + ranked_bigram_stats: BigramStatsStore::default(), + trigram_stats: TrigramStatsStore::default(), + ranked_trigram_stats: TrigramStatsStore::default(), + user_median_transition_ms: 0.0, + transition_buffer: Vec::new(), + trigram_gain_history: Vec::new(), rng: SmallRng::from_entropy(), transition_table, dictionary, @@ -419,6 +438,9 @@ impl App { }); } + // Rebuild n-gram stats from drill history + app.rebuild_ngram_stats(); + app.start_drill(); app } @@ -591,6 +613,9 @@ impl App { self.skill_tree = SkillTree::new(self.profile.skill_tree.clone()); self.keyboard_model = KeyboardModel::from_name(&self.config.keyboard_layout); + // Rebuild n-gram stats from imported drill history + self.rebuild_ngram_stats(); + // Check theme availability let theme_name = self.config.theme.clone(); let loaded_theme = Theme::load(&theme_name).unwrap_or_default(); @@ -633,7 +658,18 @@ impl App { DrillMode::Adaptive => { let scope = self.drill_scope; let all_keys = self.skill_tree.unlocked_keys(scope); - let focused = self.skill_tree.focused_key(scope, &self.ranked_key_stats); + + // Select focus target: single char or bigram + let focus_target = select_focus_target( + &self.skill_tree, + scope, + &self.ranked_key_stats, + &self.ranked_bigram_stats, + ); + let (focused_char, focused_bigram) = match &focus_target { + FocusTarget::Char(ch) => (Some(*ch), None), + FocusTarget::Bigram(key) => (Some(key.0[0]), Some(key.clone())), + }; // Generate base lowercase text using only lowercase keys from scope let lowercase_keys: Vec = all_keys @@ -643,7 +679,7 @@ impl App { .collect(); let filter = CharFilter::new(lowercase_keys); // Only pass focused to phonetic generator if it's a lowercase letter - let lowercase_focused = focused.filter(|ch| ch.is_ascii_lowercase()); + let lowercase_focused = focused_char.filter(|ch| ch.is_ascii_lowercase()); let table = self.transition_table.clone(); let dict = Dictionary::load(); let rng = SmallRng::from_rng(&mut self.rng).unwrap(); @@ -658,7 +694,7 @@ impl App { .collect(); if !cap_keys.is_empty() { let mut rng = SmallRng::from_rng(&mut self.rng).unwrap(); - text = capitalize::apply_capitalization(&text, &cap_keys, focused, &mut rng); + text = capitalize::apply_capitalization(&text, &cap_keys, focused_char, &mut rng); } // Apply punctuation if punctuation keys are in scope @@ -674,7 +710,7 @@ impl App { .collect(); if !punct_keys.is_empty() { let mut rng = SmallRng::from_rng(&mut self.rng).unwrap(); - text = punctuate::apply_punctuation(&text, &punct_keys, focused, &mut rng); + text = punctuate::apply_punctuation(&text, &punct_keys, focused_char, &mut rng); } // Apply numbers if digit keys are in scope @@ -686,7 +722,7 @@ impl App { if !digit_keys.is_empty() { let has_dot = all_keys.contains(&'.'); let mut rng = SmallRng::from_rng(&mut self.rng).unwrap(); - text = numbers::apply_numbers(&text, &digit_keys, has_dot, focused, &mut rng); + text = numbers::apply_numbers(&text, &digit_keys, has_dot, focused_char, &mut rng); } // Apply code symbols only if this drill is for the CodeSymbols branch, @@ -734,7 +770,7 @@ impl App { text = code_patterns::apply_code_symbols( &text, &symbol_keys, - focused, + focused_char, &mut rng, ); } @@ -745,6 +781,11 @@ impl App { text = insert_line_breaks(&text); } + // After all generation: if bigram focus, swap some words for bigram-containing words + if let Some(ref bigram) = focused_bigram { + text = self.apply_bigram_focus(&text, &filter, bigram); + } + (text, None) } DrillMode::Code => { @@ -843,15 +884,39 @@ impl App { 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); } } + // Extract and update n-gram stats for all drill modes + let drill_index = self.drill_history.len() as u32; + let hesitation_thresh = ngram_stats::hesitation_threshold(self.user_median_transition_ms); + let (bigram_events, trigram_events) = + extract_ngram_events(&result.per_key_times, hesitation_thresh); + for ev in &bigram_events { + self.bigram_stats.update(ev.key.clone(), ev.total_time_ms, ev.correct, ev.has_hesitation, drill_index); + self.bigram_stats.update_redundancy_streak(&ev.key, &self.key_stats); + } + for ev in &trigram_events { + self.trigram_stats.update(ev.key.clone(), ev.total_time_ms, ev.correct, ev.has_hesitation, drill_index); + } + if ranked { for kt in &result.per_key_times { if kt.correct { self.ranked_key_stats.update_key(kt.key, kt.time_ms); + } else { + self.ranked_key_stats.update_key_error(kt.key); } } + for ev in &bigram_events { + self.ranked_bigram_stats.update(ev.key.clone(), ev.total_time_ms, ev.correct, ev.has_hesitation, drill_index); + self.ranked_bigram_stats.update_redundancy_streak(&ev.key, &self.ranked_key_stats); + } + for ev in &trigram_events { + self.ranked_trigram_stats.update(ev.key.clone(), ev.total_time_ms, ev.correct, ev.has_hesitation, drill_index); + } let update = self .skill_tree .update(&self.ranked_key_stats, before_stats.as_ref()); @@ -919,6 +984,19 @@ impl App { self.profile.last_practice_date = Some(today); } + // Update transition buffer for hesitation baseline + self.update_transition_buffer(&result.per_key_times); + + // Periodic trigram marginal gain analysis (every 50 drills) + if self.profile.total_drills % 50 == 0 && self.profile.total_drills > 0 { + let gain = ngram_stats::trigram_marginal_gain( + &self.ranked_trigram_stats, + &self.ranked_bigram_stats, + &self.ranked_key_stats, + ); + self.trigram_gain_history.push(gain); + } + self.drill_history.push(result.clone()); if self.drill_history.len() > 500 { self.drill_history.remove(0); @@ -951,9 +1029,27 @@ impl App { 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); } } + // Extract and update n-gram stats + let drill_index = self.drill_history.len() as u32; + let hesitation_thresh = ngram_stats::hesitation_threshold(self.user_median_transition_ms); + let (bigram_events, trigram_events) = + extract_ngram_events(&result.per_key_times, hesitation_thresh); + for ev in &bigram_events { + self.bigram_stats.update(ev.key.clone(), ev.total_time_ms, ev.correct, ev.has_hesitation, drill_index); + self.bigram_stats.update_redundancy_streak(&ev.key, &self.key_stats); + } + for ev in &trigram_events { + self.trigram_stats.update(ev.key.clone(), ev.total_time_ms, ev.correct, ev.has_hesitation, drill_index); + } + + // Update transition buffer for hesitation baseline + self.update_transition_buffer(&result.per_key_times); + self.drill_history.push(result.clone()); if self.drill_history.len() > 500 { self.drill_history.remove(0); @@ -983,6 +1079,177 @@ impl App { } } + /// Replace up to 40% of words with dictionary words containing the target bigram. + /// No more than 3 consecutive bigram-focused words to prevent repetitive feel. + fn apply_bigram_focus(&mut self, text: &str, filter: &CharFilter, bigram: &BigramKey) -> String { + let bigram_str: String = bigram.0.iter().collect(); + let words: Vec<&str> = text.split(' ').collect(); + if words.is_empty() { + return text.to_string(); + } + + // Find dictionary words that contain the bigram and pass the filter + let dict = Dictionary::load(); + let candidates: Vec<&str> = dict + .find_matching(filter, None) + .into_iter() + .filter(|w| w.contains(&bigram_str)) + .collect(); + + if candidates.is_empty() { + return text.to_string(); + } + + let max_replacements = (words.len() * 2 + 4) / 5; // ~40% + let mut replaced = 0; + let mut consecutive = 0; + let mut result_words: Vec = Vec::with_capacity(words.len()); + + for word in &words { + let already_has = word.contains(&bigram_str); + if already_has { + consecutive += 1; + result_words.push(word.to_string()); + continue; + } + + if replaced < max_replacements && consecutive < 3 { + let candidate = candidates[self.rng.gen_range(0..candidates.len())]; + result_words.push(candidate.to_string()); + replaced += 1; + consecutive += 1; + } else { + consecutive = 0; + result_words.push(word.to_string()); + } + } + + result_words.join(" ") + } + + /// Update the rolling transition buffer with new inter-keystroke intervals. + fn update_transition_buffer(&mut self, per_key_times: &[KeyTime]) { + for kt in per_key_times { + if kt.key == BACKSPACE { + continue; + } + self.transition_buffer.push(kt.time_ms); + } + // Keep only last 200 entries + if self.transition_buffer.len() > 200 { + let excess = self.transition_buffer.len() - 200; + self.transition_buffer.drain(..excess); + } + // Recompute median + let mut buf = self.transition_buffer.clone(); + self.user_median_transition_ms = ngram_stats::compute_median(&mut buf); + } + + /// Rebuild all n-gram stats and char-level error/total counts from drill history. + /// This is the sole source of truth for error_count/total_count on KeyStat + /// and all n-gram stores. Timing EMA on KeyStat is NOT touched here + /// (it is either loaded from disk or rebuilt by `rebuild_from_history`). + fn rebuild_ngram_stats(&mut self) { + // Reset n-gram stores + self.bigram_stats = BigramStatsStore::default(); + self.bigram_stats.target_cpm = self.config.target_cpm(); + self.ranked_bigram_stats = BigramStatsStore::default(); + self.ranked_bigram_stats.target_cpm = self.config.target_cpm(); + self.trigram_stats = TrigramStatsStore::default(); + self.trigram_stats.target_cpm = self.config.target_cpm(); + self.ranked_trigram_stats = TrigramStatsStore::default(); + self.ranked_trigram_stats.target_cpm = self.config.target_cpm(); + self.transition_buffer.clear(); + self.user_median_transition_ms = 0.0; + + // Reset char-level error/total counts (timing fields are untouched) + for stat in self.key_stats.stats.values_mut() { + stat.error_count = 0; + stat.total_count = 0; + } + for stat in self.ranked_key_stats.stats.values_mut() { + stat.error_count = 0; + stat.total_count = 0; + } + + // Take drill_history out temporarily to avoid borrow conflict + let history = std::mem::take(&mut self.drill_history); + + for (drill_index, result) in history.iter().enumerate() { + let hesitation_thresh = ngram_stats::hesitation_threshold(self.user_median_transition_ms); + let (bigram_events, trigram_events) = + extract_ngram_events(&result.per_key_times, hesitation_thresh); + + // Rebuild char-level error/total counts from history + for kt in &result.per_key_times { + if kt.correct { + let stat = self.key_stats.stats.entry(kt.key).or_default(); + stat.total_count += 1; + } else { + self.key_stats.update_key_error(kt.key); + } + } + + for ev in &bigram_events { + self.bigram_stats.update(ev.key.clone(), ev.total_time_ms, ev.correct, ev.has_hesitation, drill_index as u32); + self.bigram_stats.update_redundancy_streak(&ev.key, &self.key_stats); + } + for ev in &trigram_events { + self.trigram_stats.update(ev.key.clone(), ev.total_time_ms, ev.correct, ev.has_hesitation, drill_index as u32); + } + + if result.ranked { + for kt in &result.per_key_times { + if kt.correct { + let stat = self.ranked_key_stats.stats.entry(kt.key).or_default(); + stat.total_count += 1; + } else { + self.ranked_key_stats.update_key_error(kt.key); + } + } + for ev in &bigram_events { + self.ranked_bigram_stats.update(ev.key.clone(), ev.total_time_ms, ev.correct, ev.has_hesitation, drill_index as u32); + self.ranked_bigram_stats.update_redundancy_streak(&ev.key, &self.ranked_key_stats); + } + for ev in &trigram_events { + self.ranked_trigram_stats.update(ev.key.clone(), ev.total_time_ms, ev.correct, ev.has_hesitation, drill_index as u32); + } + } + + // Update transition buffer + for kt in &result.per_key_times { + if kt.key != BACKSPACE { + self.transition_buffer.push(kt.time_ms); + } + } + if self.transition_buffer.len() > 200 { + let excess = self.transition_buffer.len() - 200; + self.transition_buffer.drain(..excess); + } + let mut buf = self.transition_buffer.clone(); + self.user_median_transition_ms = ngram_stats::compute_median(&mut buf); + } + + // Put drill_history back + self.drill_history = history; + + // Prune trigrams — use drill_history.len() as total, matching the drill_index + // space used in last_seen_drill_index above (history position, includes partials) + let total_history_entries = self.drill_history.len() as u32; + self.trigram_stats.prune( + ngram_stats::MAX_TRIGRAMS, + total_history_entries, + &self.bigram_stats, + &self.key_stats, + ); + self.ranked_trigram_stats.prune( + ngram_stats::MAX_TRIGRAMS, + total_history_entries, + &self.ranked_bigram_stats, + &self.ranked_key_stats, + ); + } + pub fn retry_drill(&mut self) { if let Some(ref drill) = self.drill { let text: String = drill.target.iter().collect(); @@ -1111,6 +1378,9 @@ impl App { } self.profile.skill_tree = self.skill_tree.progress.clone(); + + // Rebuild n-gram stats from the replayed history + self.rebuild_ngram_stats(); } pub fn go_to_skill_tree(&mut self) { diff --git a/src/engine/key_stats.rs b/src/engine/key_stats.rs index 63c60de..7b94ec5 100644 --- a/src/engine/key_stats.rs +++ b/src/engine/key_stats.rs @@ -11,6 +11,10 @@ pub struct KeyStat { pub confidence: f64, pub sample_count: usize, pub recent_times: Vec, + #[serde(default)] + pub error_count: usize, + #[serde(default)] + pub total_count: usize, } impl Default for KeyStat { @@ -21,6 +25,8 @@ impl Default for KeyStat { confidence: 0.0, sample_count: 0, recent_times: Vec::new(), + error_count: 0, + total_count: 0, } } } @@ -44,6 +50,7 @@ impl KeyStatsStore { pub fn update_key(&mut self, key: char, time_ms: f64) { let stat = self.stats.entry(key).or_default(); stat.sample_count += 1; + stat.total_count += 1; if stat.sample_count == 1 { stat.filtered_time_ms = time_ms; @@ -70,6 +77,22 @@ impl KeyStatsStore { pub fn get_stat(&self, key: char) -> Option<&KeyStat> { self.stats.get(&key) } + + /// Record an error for a key (increments error_count and total_count). + /// Does NOT update timing/confidence (those are only updated for correct strokes). + pub fn update_key_error(&mut self, key: char) { + let stat = self.stats.entry(key).or_default(); + stat.error_count += 1; + stat.total_count += 1; + } + + /// Laplace-smoothed error rate: (errors + 1) / (total + 2). + pub fn smoothed_error_rate(&self, key: char) -> f64 { + match self.stats.get(&key) { + Some(s) => (s.error_count as f64 + 1.0) / (s.total_count as f64 + 2.0), + None => 0.5, // (0 + 1) / (0 + 2) = 0.5 + } + } } #[cfg(test)] diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 19a62fd..5fcb3ce 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -1,5 +1,8 @@ pub mod filter; pub mod key_stats; pub mod learning_rate; +pub mod ngram_stats; pub mod scoring; pub mod skill_tree; + +pub use ngram_stats::FocusTarget; diff --git a/src/engine/ngram_stats.rs b/src/engine/ngram_stats.rs new file mode 100644 index 0000000..6cfaf44 --- /dev/null +++ b/src/engine/ngram_stats.rs @@ -0,0 +1,1221 @@ +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +use crate::engine::key_stats::KeyStatsStore; +use crate::engine::skill_tree::{DrillScope, SkillTree}; +use crate::keyboard::display::BACKSPACE; +use crate::session::result::KeyTime; + +const EMA_ALPHA: f64 = 0.1; +const DEFAULT_TARGET_CPM: f64 = 175.0; +const MAX_RECENT: usize = 30; +const STABILITY_THRESHOLD: f64 = 1.5; +const STABILITY_STREAK_REQUIRED: u8 = 3; +const MIN_SAMPLES_FOR_FOCUS: usize = 20; +const MAX_TRIGRAM_ENTRIES: usize = 5000; + +// --------------------------------------------------------------------------- +// N-gram keys +// --------------------------------------------------------------------------- + +#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct BigramKey(pub [char; 2]); + +#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct TrigramKey(pub [char; 3]); + +// --------------------------------------------------------------------------- +// NgramStat +// --------------------------------------------------------------------------- + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct NgramStat { + pub filtered_time_ms: f64, + pub best_time_ms: f64, + pub confidence: f64, + pub sample_count: usize, + pub error_count: usize, + pub hesitation_count: usize, + pub recent_times: Vec, + pub recent_correct: Vec, + pub redundancy_streak: u8, + #[serde(default)] + pub last_seen_drill_index: u32, +} + +impl Default for NgramStat { + fn default() -> Self { + Self { + filtered_time_ms: 1000.0, + best_time_ms: f64::MAX, + confidence: 0.0, + sample_count: 0, + error_count: 0, + hesitation_count: 0, + recent_times: Vec::new(), + recent_correct: Vec::new(), + redundancy_streak: 0, + last_seen_drill_index: 0, + } + } +} + +fn update_stat(stat: &mut NgramStat, time_ms: f64, correct: bool, hesitation: bool, target_time_ms: f64, drill_index: u32) { + stat.last_seen_drill_index = drill_index; + stat.sample_count += 1; + if !correct { + stat.error_count += 1; + } + if hesitation { + stat.hesitation_count += 1; + } + + if stat.sample_count == 1 { + stat.filtered_time_ms = time_ms; + } else { + stat.filtered_time_ms = EMA_ALPHA * time_ms + (1.0 - EMA_ALPHA) * stat.filtered_time_ms; + } + + stat.best_time_ms = stat.best_time_ms.min(stat.filtered_time_ms); + stat.confidence = target_time_ms / stat.filtered_time_ms; + + stat.recent_times.push(time_ms); + if stat.recent_times.len() > MAX_RECENT { + stat.recent_times.remove(0); + } + stat.recent_correct.push(correct); + if stat.recent_correct.len() > MAX_RECENT { + stat.recent_correct.remove(0); + } +} + +fn smoothed_error_rate_raw(errors: usize, samples: usize) -> f64 { + (errors as f64 + 1.0) / (samples as f64 + 2.0) +} + +// --------------------------------------------------------------------------- +// BigramStatsStore +// --------------------------------------------------------------------------- + +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +pub struct BigramStatsStore { + pub stats: HashMap, + #[serde(default = "default_target_cpm")] + pub target_cpm: f64, +} + +fn default_target_cpm() -> f64 { + DEFAULT_TARGET_CPM +} + +impl BigramStatsStore { + pub fn update(&mut self, key: BigramKey, time_ms: f64, correct: bool, hesitation: bool, drill_index: u32) { + let target_time_ms = 2.0 * 60000.0 / self.target_cpm; + let stat = self.stats.entry(key).or_default(); + update_stat(stat, time_ms, correct, hesitation, target_time_ms, drill_index); + } + + #[allow(dead_code)] + pub fn get_confidence(&self, key: &BigramKey) -> f64 { + self.stats.get(key).map(|s| s.confidence).unwrap_or(0.0) + } + + pub fn smoothed_error_rate(&self, key: &BigramKey) -> f64 { + match self.stats.get(key) { + Some(s) => smoothed_error_rate_raw(s.error_count, s.sample_count), + None => smoothed_error_rate_raw(0, 0), + } + } + + pub fn redundancy_score(&self, key: &BigramKey, char_stats: &KeyStatsStore) -> f64 { + let e_a = char_stats.smoothed_error_rate(key.0[0]); + let e_b = char_stats.smoothed_error_rate(key.0[1]); + let e_ab = self.smoothed_error_rate(key); + let expected_ab = 1.0 - (1.0 - e_a) * (1.0 - e_b); + e_ab / expected_ab.max(0.01) + } + + /// Update redundancy streak for a bigram given current char stats. + /// Call this after updating the bigram stats. + pub fn update_redundancy_streak(&mut self, key: &BigramKey, char_stats: &KeyStatsStore) { + let redundancy = self.redundancy_score(key, char_stats); + if let Some(stat) = self.stats.get_mut(key) { + if redundancy > STABILITY_THRESHOLD { + stat.redundancy_streak = stat.redundancy_streak.saturating_add(1); + } else { + stat.redundancy_streak = 0; + } + } + } + + /// Find the weakest eligible bigram (stability-gated). + /// Only considers bigrams whose chars are all in `unlocked`. + pub fn weakest_bigram( + &self, + char_stats: &KeyStatsStore, + unlocked: &[char], + ) -> Option<(BigramKey, f64)> { + let mut best: Option<(BigramKey, f64)> = None; + + for (key, stat) in &self.stats { + // Must be composed of unlocked chars + if !unlocked.contains(&key.0[0]) || !unlocked.contains(&key.0[1]) { + continue; + } + // Minimum samples + if stat.sample_count < MIN_SAMPLES_FOR_FOCUS { + continue; + } + // Stability gate + if stat.redundancy_streak < STABILITY_STREAK_REQUIRED { + continue; + } + let redundancy = self.redundancy_score(key, char_stats); + if redundancy <= STABILITY_THRESHOLD { + continue; + } + // ngram_difficulty = (1.0 - confidence) * redundancy + let difficulty = (1.0 - stat.confidence) * redundancy; + if difficulty <= 0.0 { + continue; + } + match best { + Some((_, best_diff)) if difficulty > best_diff => { + best = Some((key.clone(), difficulty)); + } + None => { + best = Some((key.clone(), difficulty)); + } + _ => {} + } + } + + best + } +} + +// --------------------------------------------------------------------------- +// TrigramStatsStore +// --------------------------------------------------------------------------- + +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +pub struct TrigramStatsStore { + pub stats: HashMap, + #[serde(default = "default_target_cpm")] + pub target_cpm: f64, +} + +impl TrigramStatsStore { + pub fn update(&mut self, key: TrigramKey, time_ms: f64, correct: bool, hesitation: bool, drill_index: u32) { + let target_time_ms = 3.0 * 60000.0 / self.target_cpm; + let stat = self.stats.entry(key).or_default(); + update_stat(stat, time_ms, correct, hesitation, target_time_ms, drill_index); + } + + #[allow(dead_code)] + pub fn get_confidence(&self, key: &TrigramKey) -> f64 { + self.stats.get(key).map(|s| s.confidence).unwrap_or(0.0) + } + + pub fn smoothed_error_rate(&self, key: &TrigramKey) -> f64 { + match self.stats.get(key) { + Some(s) => smoothed_error_rate_raw(s.error_count, s.sample_count), + None => smoothed_error_rate_raw(0, 0), + } + } + + pub fn redundancy_score( + &self, + key: &TrigramKey, + bigram_stats: &BigramStatsStore, + char_stats: &KeyStatsStore, + ) -> f64 { + let e_a = char_stats.smoothed_error_rate(key.0[0]); + let e_b = char_stats.smoothed_error_rate(key.0[1]); + let e_c = char_stats.smoothed_error_rate(key.0[2]); + let e_abc = self.smoothed_error_rate(key); + + let expected_from_chars = 1.0 - (1.0 - e_a) * (1.0 - e_b) * (1.0 - e_c); + + let e_ab = bigram_stats.smoothed_error_rate(&BigramKey([key.0[0], key.0[1]])); + let e_bc = bigram_stats.smoothed_error_rate(&BigramKey([key.0[1], key.0[2]])); + let expected_from_bigrams = e_ab.max(e_bc); + + let expected = expected_from_chars.max(expected_from_bigrams); + e_abc / expected.max(0.01) + } + + /// Prune to `max_entries` by composite utility score. + /// `total_drills` is the current total drill count for recency calculation. + pub fn prune(&mut self, max_entries: usize, total_drills: u32, bigram_stats: &BigramStatsStore, char_stats: &KeyStatsStore) { + if self.stats.len() <= max_entries { + return; + } + + let recency_weight = 0.3; + let signal_weight = 0.5; + let data_weight = 0.2; + + let mut scored: Vec<(TrigramKey, f64)> = self + .stats + .iter() + .map(|(key, stat)| { + let drills_since = total_drills.saturating_sub(stat.last_seen_drill_index) as f64; + let recency = 1.0 / (drills_since + 1.0); + let redundancy = self.redundancy_score(key, bigram_stats, char_stats).min(3.0); + let data = (stat.sample_count as f64).ln_1p(); + + let utility = recency_weight * recency + signal_weight * redundancy + data_weight * data; + (key.clone(), utility) + }) + .collect(); + + scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)); + scored.truncate(max_entries); + + let keep: HashMap = scored + .into_iter() + .filter_map(|(key, _)| { + self.stats.remove(&key).map(|stat| (key, stat)) + }) + .collect(); + + self.stats = keep; + } +} + +// --------------------------------------------------------------------------- +// Extraction events & function +// --------------------------------------------------------------------------- + +#[derive(Debug)] +pub struct BigramEvent { + pub key: BigramKey, + pub total_time_ms: f64, + pub correct: bool, + pub has_hesitation: bool, +} + +#[derive(Debug)] +pub struct TrigramEvent { + pub key: TrigramKey, + pub total_time_ms: f64, + pub correct: bool, + pub has_hesitation: bool, +} + +/// Extract bigram and trigram events from a sequence of per-key times. +/// +/// - BACKSPACE entries are filtered out +/// - Space characters split windows (no cross-word n-grams) +/// - For bigram "ab": time = window[1].time_ms +/// - For trigram "abc": time = window[1].time_ms + window[2].time_ms +/// - hesitation = any transition time > hesitation_threshold +pub fn extract_ngram_events( + per_key_times: &[KeyTime], + hesitation_threshold: f64, +) -> (Vec, Vec) { + let mut bigrams = Vec::new(); + let mut trigrams = Vec::new(); + + // Filter out backspace entries + let filtered: Vec<&KeyTime> = per_key_times + .iter() + .filter(|kt| kt.key != BACKSPACE) + .collect(); + + // Extract bigrams: slide a window of 2 + for window in filtered.windows(2) { + let a = window[0]; + let b = window[1]; + + // Skip cross-word boundaries + if a.key == ' ' || b.key == ' ' { + continue; + } + + let time_ms = b.time_ms; + let correct = a.correct && b.correct; + let has_hesitation = b.time_ms > hesitation_threshold; + + bigrams.push(BigramEvent { + key: BigramKey([a.key, b.key]), + total_time_ms: time_ms, + correct, + has_hesitation, + }); + } + + // Extract trigrams: slide a window of 3 + for window in filtered.windows(3) { + let a = window[0]; + let b = window[1]; + let c = window[2]; + + // Skip if any is a space (no cross-word) + if a.key == ' ' || b.key == ' ' || c.key == ' ' { + continue; + } + + let time_ms = b.time_ms + c.time_ms; + let correct = a.correct && b.correct && c.correct; + let has_hesitation = b.time_ms > hesitation_threshold || c.time_ms > hesitation_threshold; + + trigrams.push(TrigramEvent { + key: TrigramKey([a.key, b.key, c.key]), + total_time_ms: time_ms, + correct, + has_hesitation, + }); + } + + (bigrams, trigrams) +} + +// --------------------------------------------------------------------------- +// FocusTarget & selection +// --------------------------------------------------------------------------- + +#[derive(Clone, Debug, PartialEq)] +pub enum FocusTarget { + Char(char), + Bigram(BigramKey), +} + +/// Select the best focus target: either a single character or a bigram. +/// +/// If the weakest eligible bigram's difficulty score exceeds 80% of the +/// weakest character's difficulty, focus on the bigram. Otherwise fall back +/// to the character. +pub fn select_focus_target( + skill_tree: &SkillTree, + scope: DrillScope, + ranked_key_stats: &KeyStatsStore, + ranked_bigram_stats: &BigramStatsStore, +) -> FocusTarget { + let unlocked = skill_tree.unlocked_keys(scope); + let focused_char = skill_tree.focused_key(scope, ranked_key_stats); + + let bigram_result = ranked_bigram_stats.weakest_bigram(ranked_key_stats, &unlocked); + + match (focused_char, bigram_result) { + (Some(ch), Some((bigram_key, bigram_difficulty))) => { + // Compute char difficulty: (1.0 - confidence) — no redundancy multiplier for chars + let char_conf = ranked_key_stats.get_confidence(ch); + let char_difficulty = (1.0 - char_conf).max(0.0); + + if bigram_difficulty > char_difficulty * 0.8 { + FocusTarget::Bigram(bigram_key) + } else { + FocusTarget::Char(ch) + } + } + (Some(ch), None) => FocusTarget::Char(ch), + (None, Some((bigram_key, _))) => FocusTarget::Bigram(bigram_key), + (None, None) => FocusTarget::Char('e'), // fallback + } +} + +// --------------------------------------------------------------------------- +// Trigram marginal gain analysis +// --------------------------------------------------------------------------- + +/// Compute what fraction of trigrams with sufficient samples show genuine +/// redundancy beyond their constituent bigrams. Returns a value in [0.0, 1.0]. +pub fn trigram_marginal_gain( + trigram_stats: &TrigramStatsStore, + bigram_stats: &BigramStatsStore, + char_stats: &KeyStatsStore, +) -> f64 { + let qualified: Vec<&TrigramKey> = trigram_stats + .stats + .iter() + .filter(|(_, s)| s.sample_count >= MIN_SAMPLES_FOR_FOCUS) + .map(|(k, _)| k) + .collect(); + + if qualified.is_empty() { + return 0.0; + } + + let with_signal = qualified + .iter() + .filter(|k| trigram_stats.redundancy_score(k, bigram_stats, char_stats) > STABILITY_THRESHOLD) + .count(); + + with_signal as f64 / qualified.len() as f64 +} + +// --------------------------------------------------------------------------- +// Hesitation helpers +// --------------------------------------------------------------------------- + +/// Compute hesitation threshold from user median transition time. +pub fn hesitation_threshold(user_median_transition_ms: f64) -> f64 { + 800.0_f64.max(2.5 * user_median_transition_ms) +} + +/// Compute the median of a slice of f64 values. Returns 0.0 if empty. +pub fn compute_median(values: &mut [f64]) -> f64 { + if values.is_empty() { + return 0.0; + } + values.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + let mid = values.len() / 2; + if values.len() % 2 == 0 { + (values[mid - 1] + values[mid]) / 2.0 + } else { + values[mid] + } +} + +/// Constant for max trigram entries (used by App during pruning). +pub const MAX_TRIGRAMS: usize = MAX_TRIGRAM_ENTRIES; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + fn make_keytime(key: char, time_ms: f64, correct: bool) -> KeyTime { + KeyTime { + key, + time_ms, + correct, + } + } + + // --- Extraction tests --- + + #[test] + fn extract_bigrams_from_simple_word() { + let times = vec![ + make_keytime('h', 100.0, true), + make_keytime('e', 200.0, true), + make_keytime('l', 150.0, true), + make_keytime('l', 180.0, true), + make_keytime('o', 160.0, true), + ]; + let (bigrams, trigrams) = extract_ngram_events(×, 800.0); + assert_eq!(bigrams.len(), 4); // he, el, ll, lo + assert_eq!(bigrams[0].key, BigramKey(['h', 'e'])); + assert_eq!(bigrams[0].total_time_ms, 200.0); + assert!(bigrams[0].correct); + + assert_eq!(trigrams.len(), 3); // hel, ell, llo + assert_eq!(trigrams[0].key, TrigramKey(['h', 'e', 'l'])); + assert_eq!(trigrams[0].total_time_ms, 200.0 + 150.0); // e.time + l.time + } + + #[test] + fn extract_filters_backspace() { + let times = vec![ + make_keytime('a', 100.0, true), + make_keytime('x', 200.0, false), + make_keytime(BACKSPACE, 150.0, true), + make_keytime('b', 180.0, true), + ]; + let (bigrams, _) = extract_ngram_events(×, 800.0); + // After filtering backspace: a, x, b -> bigrams: ax, xb + assert_eq!(bigrams.len(), 2); + assert_eq!(bigrams[0].key, BigramKey(['a', 'x'])); + assert_eq!(bigrams[1].key, BigramKey(['x', 'b'])); + } + + #[test] + fn extract_splits_on_space() { + let times = vec![ + make_keytime('a', 100.0, true), + make_keytime('b', 200.0, true), + make_keytime(' ', 150.0, true), + make_keytime('c', 180.0, true), + make_keytime('d', 160.0, true), + ]; + let (bigrams, trigrams) = extract_ngram_events(×, 800.0); + // ab is valid, b-space skipped, space-c skipped, cd is valid + assert_eq!(bigrams.len(), 2); + assert_eq!(bigrams[0].key, BigramKey(['a', 'b'])); + assert_eq!(bigrams[1].key, BigramKey(['c', 'd'])); + // Only trigram with no space: none (ab_space and space_cd both have space) + assert_eq!(trigrams.len(), 0); + } + + #[test] + fn extract_detects_hesitation() { + let times = vec![ + make_keytime('a', 100.0, true), + make_keytime('b', 900.0, true), // > 800 threshold + make_keytime('c', 200.0, true), + ]; + let (bigrams, _) = extract_ngram_events(×, 800.0); + assert!(bigrams[0].has_hesitation); // ab: b.time = 900 > 800 + assert!(!bigrams[1].has_hesitation); // bc: c.time = 200 < 800 + } + + #[test] + fn extract_marks_incorrect_when_any_char_wrong() { + let times = vec![ + make_keytime('a', 100.0, true), + make_keytime('b', 200.0, false), // incorrect + make_keytime('c', 150.0, true), + ]; + let (bigrams, trigrams) = extract_ngram_events(×, 800.0); + assert!(!bigrams[0].correct); // ab: a correct, b incorrect -> false + assert!(!bigrams[1].correct); // bc: b incorrect, c correct -> false + assert!(!trigrams[0].correct); // abc: b incorrect -> false + } + + // --- Laplace smoothing tests --- + + #[test] + fn laplace_smoothing_zero_samples() { + assert!((smoothed_error_rate_raw(0, 0) - 0.5).abs() < f64::EPSILON); + } + + #[test] + fn laplace_smoothing_convergence() { + // With 100 samples and 10 errors, should be close to 0.1 + let rate = smoothed_error_rate_raw(10, 100); + assert!((rate - 11.0 / 102.0).abs() < f64::EPSILON); + assert!(rate > 0.1 && rate < 0.12); + } + + #[test] + fn laplace_smoothing_all_errors() { + let rate = smoothed_error_rate_raw(50, 50); + assert!((rate - 51.0 / 52.0).abs() < f64::EPSILON); + } + + // --- Redundancy tests --- + + #[test] + fn redundancy_proxy_example() { + // Example 1 from plan: "is" where 's' is weak + let mut char_stats = KeyStatsStore::default(); + // Simulate: s has high error rate + // We need to set up error_count and total_count + // s: e_s = 0.25 -> (errors+1)/(samples+2) = 0.25 + // Solve: (e+1)/(s+2) = 0.25 -> at s=50, e=12: (13)/(52) = 0.25 + let s_stat = char_stats.stats.entry('s').or_default(); + s_stat.error_count = 12; + s_stat.total_count = 50; + // i: e_i = 0.03 -> (e+1)/(s+2) = 0.03 -> at s=100, e=~2: (3)/(102) = 0.0294 + let i_stat = char_stats.stats.entry('i').or_default(); + i_stat.error_count = 2; + i_stat.total_count = 100; + + let mut bigram_stats = BigramStatsStore::default(); + let is_key = BigramKey(['i', 's']); + // e_is = 0.28 -> (e+1)/(s+2) = 0.28 -> at s=50, e=~13: (14)/(52) = 0.269 + // Let's pick s=100, e=~27: (28)/(102) = 0.2745 + // Actually, let's just use values that give close to what we want + let is_stat = bigram_stats.stats.entry(is_key.clone()).or_default(); + is_stat.error_count = 27; + is_stat.sample_count = 100; + + let e_s = char_stats.smoothed_error_rate('s'); + let e_i = char_stats.smoothed_error_rate('i'); + let e_is = bigram_stats.smoothed_error_rate(&is_key); + let expected = 1.0 - (1.0 - e_s) * (1.0 - e_i); + let redundancy = bigram_stats.redundancy_score(&is_key, &char_stats); + + // The redundancy should be close to 1.0 (proxy, not genuine) + assert!( + redundancy < STABILITY_THRESHOLD, + "Proxy bigram 'is' should have redundancy < {STABILITY_THRESHOLD}, got {redundancy} (e_s={e_s}, e_i={e_i}, e_is={e_is}, expected={expected})" + ); + } + + #[test] + fn redundancy_genuine_difficulty() { + // Example 2 from plan: "ed" where both chars are fine individually + let mut char_stats = KeyStatsStore::default(); + // e: e_e = 0.04 -> (e+1)/(s+2) ~ 0.04 at s=100, errors=~3: (4)/(102) = 0.039 + let e_stat = char_stats.stats.entry('e').or_default(); + e_stat.error_count = 3; + e_stat.total_count = 100; + // d: e_d = 0.05 -> (e+1)/(s+2) ~ 0.05 at s=100, errors=~4: (5)/(102) = 0.049 + let d_stat = char_stats.stats.entry('d').or_default(); + d_stat.error_count = 4; + d_stat.total_count = 100; + + let mut bigram_stats = BigramStatsStore::default(); + let ed_key = BigramKey(['e', 'd']); + // e_ed = 0.22 -> at s=100, errors=~21: (22)/(102) = 0.2157 + let ed_stat = bigram_stats.stats.entry(ed_key.clone()).or_default(); + ed_stat.error_count = 21; + ed_stat.sample_count = 100; + + let redundancy = bigram_stats.redundancy_score(&ed_key, &char_stats); + assert!( + redundancy > STABILITY_THRESHOLD, + "Genuine difficulty 'ed' should have redundancy > {STABILITY_THRESHOLD}, got {redundancy}" + ); + } + + #[test] + fn redundancy_trigram_explained_by_bigram() { + // Example 3: "the" where "th" bigram explains the difficulty + let mut char_stats = KeyStatsStore::default(); + for &(ch, errors, total) in &[('t', 2, 100), ('h', 3, 100), ('e', 3, 100)] { + let s = char_stats.stats.entry(ch).or_default(); + s.error_count = errors; + s.total_count = total; + } + + let mut bigram_stats = BigramStatsStore::default(); + // th has high error rate: e_th = 0.15 -> at s=100, e=~14: (15)/(102) = 0.147 + let th_stat = bigram_stats.stats.entry(BigramKey(['t', 'h'])).or_default(); + th_stat.error_count = 14; + th_stat.sample_count = 100; + // he has low error rate + let he_stat = bigram_stats.stats.entry(BigramKey(['h', 'e'])).or_default(); + he_stat.error_count = 3; + he_stat.sample_count = 100; + + let mut trigram_stats = TrigramStatsStore::default(); + let the_key = TrigramKey(['t', 'h', 'e']); + // e_the = 0.16 -> at s=100, e=~15: (16)/(102) = 0.157 + let the_stat = trigram_stats.stats.entry(the_key.clone()).or_default(); + the_stat.error_count = 15; + the_stat.sample_count = 100; + + let redundancy = trigram_stats.redundancy_score(&the_key, &bigram_stats, &char_stats); + assert!( + redundancy < STABILITY_THRESHOLD, + "Trigram 'the' explained by 'th' bigram should have redundancy < {STABILITY_THRESHOLD}, got {redundancy}" + ); + } + + // --- Stability gate tests --- + + #[test] + fn stability_streak_increments_and_resets() { + let mut bigram_stats = BigramStatsStore::default(); + let key = BigramKey(['e', 'd']); + + // Set up a bigram with genuine difficulty + let stat = bigram_stats.stats.entry(key.clone()).or_default(); + stat.error_count = 25; + stat.sample_count = 100; + + let mut char_stats = KeyStatsStore::default(); + // Low char error rates + char_stats.stats.entry('e').or_default().error_count = 2; + char_stats.stats.entry('e').or_default().total_count = 100; + char_stats.stats.entry('d').or_default().error_count = 2; + char_stats.stats.entry('d').or_default().total_count = 100; + + // Should increment streak + bigram_stats.update_redundancy_streak(&key, &char_stats); + assert_eq!(bigram_stats.stats[&key].redundancy_streak, 1); + bigram_stats.update_redundancy_streak(&key, &char_stats); + assert_eq!(bigram_stats.stats[&key].redundancy_streak, 2); + bigram_stats.update_redundancy_streak(&key, &char_stats); + assert_eq!(bigram_stats.stats[&key].redundancy_streak, 3); + + // Now simulate char stats getting worse (making redundancy low) + char_stats.stats.entry('e').or_default().error_count = 30; + bigram_stats.update_redundancy_streak(&key, &char_stats); + assert_eq!(bigram_stats.stats[&key].redundancy_streak, 0); // reset + } + + #[test] + fn focus_eligibility_requires_all_conditions() { + let mut bigram_stats = BigramStatsStore::default(); + let mut char_stats = KeyStatsStore::default(); + let unlocked = vec!['a', 'b', 'c', 'd', 'e']; + + // Set up char stats with low error rates + for &ch in &['a', 'b'] { + let s = char_stats.stats.entry(ch).or_default(); + s.error_count = 2; + s.total_count = 100; + } + + let key = BigramKey(['a', 'b']); + let stat = bigram_stats.stats.entry(key.clone()).or_default(); + stat.error_count = 25; + stat.sample_count = 25; // enough samples + stat.confidence = 0.5; + stat.redundancy_streak = STABILITY_STREAK_REQUIRED; // stable + + // Should be eligible + let result = bigram_stats.weakest_bigram(&char_stats, &unlocked); + assert!(result.is_some(), "Should be eligible with all conditions met"); + + // Reset streak -> not eligible + bigram_stats.stats.get_mut(&key).unwrap().redundancy_streak = 2; + let result = bigram_stats.weakest_bigram(&char_stats, &unlocked); + assert!(result.is_none(), "Should NOT be eligible without stable streak"); + + // Restore streak, reduce samples -> not eligible + bigram_stats.stats.get_mut(&key).unwrap().redundancy_streak = STABILITY_STREAK_REQUIRED; + bigram_stats.stats.get_mut(&key).unwrap().sample_count = 15; + let result = bigram_stats.weakest_bigram(&char_stats, &unlocked); + assert!(result.is_none(), "Should NOT be eligible with < 20 samples"); + } + + // --- Focus selection tests --- + + #[test] + fn focus_falls_back_to_char_when_no_bigrams() { + let skill_tree = SkillTree::default(); + let key_stats = KeyStatsStore::default(); + let bigram_stats = BigramStatsStore::default(); + + let target = select_focus_target( + &skill_tree, + DrillScope::Global, + &key_stats, + &bigram_stats, + ); + + // With default skill tree, focused_key may return a char or None + // Either way, should not be a Bigram + match target { + FocusTarget::Char(_) => {} // expected + FocusTarget::Bigram(_) => panic!("Should not select bigram with no data"), + } + } + + #[test] + fn focus_selects_bigram_when_difficulty_exceeds_threshold() { + // Set up a skill tree with some unlocked keys and known confidence + let skill_tree = SkillTree::default(); + let mut key_stats = KeyStatsStore::default(); + + // Give all unlocked keys high confidence so focused_key returns + // the one with lowest confidence + for &ch in &['e', 't', 'a', 'o', 'n', 'i'] { + let stat = key_stats.stats.entry(ch).or_default(); + stat.confidence = 0.95; + stat.filtered_time_ms = 360.0; // slow enough to not be mastered + stat.sample_count = 50; + stat.total_count = 50; + stat.error_count = 2; + } + // Make 'n' the weakest char: confidence = 0.5 -> char_difficulty = 0.5 + key_stats.stats.get_mut(&'n').unwrap().confidence = 0.5; + key_stats.stats.get_mut(&'n').unwrap().filtered_time_ms = 686.0; + + // Set up a bigram 'e','t' with high difficulty that exceeds 0.8 * char_difficulty + // char_difficulty = 1.0 - 0.5 = 0.5, threshold = 0.5 * 0.8 = 0.4 + // bigram needs ngram_difficulty > 0.4 + // ngram_difficulty = (1.0 - confidence) * redundancy + // confidence = 0.4, redundancy = 2.0 -> difficulty = 0.6 * 2.0 = 1.2 > 0.4 + let mut bigram_stats = BigramStatsStore::default(); + let et_key = BigramKey(['e', 't']); + let stat = bigram_stats.stats.entry(et_key.clone()).or_default(); + stat.confidence = 0.4; + stat.sample_count = 30; + stat.error_count = 20; + stat.redundancy_streak = STABILITY_STREAK_REQUIRED; + + let target = select_focus_target( + &skill_tree, + DrillScope::Global, + &key_stats, + &bigram_stats, + ); + + assert_eq!( + target, + FocusTarget::Bigram(et_key), + "Bigram should win when its difficulty exceeds char_difficulty * 0.8" + ); + } + + #[test] + fn focus_selects_char_when_bigram_difficulty_below_threshold() { + let skill_tree = SkillTree::default(); + let mut key_stats = KeyStatsStore::default(); + + for &ch in &['e', 't', 'a', 'o', 'n', 'i'] { + let stat = key_stats.stats.entry(ch).or_default(); + stat.confidence = 0.95; + stat.filtered_time_ms = 360.0; + stat.sample_count = 50; + stat.total_count = 50; + stat.error_count = 2; + } + // Make 'n' very weak: confidence = 0.1 -> char_difficulty = 0.9 + // threshold = 0.9 * 0.8 = 0.72 + key_stats.stats.get_mut(&'n').unwrap().confidence = 0.1; + key_stats.stats.get_mut(&'n').unwrap().filtered_time_ms = 3400.0; + + // Bigram 'e','t' with high confidence and low error rate -> low difficulty + // char error rates: e_e ≈ 0.058, e_t ≈ 0.058 + // expected_et = 1 - (1-0.058)*(1-0.058) ≈ 0.113 + // bigram error: (5+1)/(30+2) = 0.1875 -> redundancy ≈ 1.66 + // ngram_difficulty = (1.0 - 0.85) * 1.66 = 0.249 < 0.72 + let mut bigram_stats = BigramStatsStore::default(); + let et_key = BigramKey(['e', 't']); + let stat = bigram_stats.stats.entry(et_key.clone()).or_default(); + stat.confidence = 0.85; + stat.sample_count = 30; + stat.error_count = 5; + stat.redundancy_streak = STABILITY_STREAK_REQUIRED; + + let target = select_focus_target( + &skill_tree, + DrillScope::Global, + &key_stats, + &bigram_stats, + ); + + match target { + FocusTarget::Char(ch) => { + assert_eq!(ch, 'n', "Should focus on weakest char 'n'"); + } + FocusTarget::Bigram(_) => { + panic!("Should NOT select bigram when its difficulty is below threshold"); + } + } + } + + #[test] + fn focus_ignores_bigram_with_insufficient_streak() { + let skill_tree = SkillTree::default(); + let mut key_stats = KeyStatsStore::default(); + + for &ch in &['e', 't', 'a', 'o', 'n', 'i'] { + let stat = key_stats.stats.entry(ch).or_default(); + stat.confidence = 0.95; + stat.filtered_time_ms = 360.0; + stat.sample_count = 50; + stat.total_count = 50; + stat.error_count = 2; + } + key_stats.stats.get_mut(&'n').unwrap().confidence = 0.5; + key_stats.stats.get_mut(&'n').unwrap().filtered_time_ms = 686.0; + + // Bigram with high difficulty but streak only 2 (needs 3) + let mut bigram_stats = BigramStatsStore::default(); + let et_key = BigramKey(['e', 't']); + let stat = bigram_stats.stats.entry(et_key.clone()).or_default(); + stat.confidence = 0.3; + stat.sample_count = 30; + stat.error_count = 25; + stat.redundancy_streak = STABILITY_STREAK_REQUIRED - 1; // not enough + + let target = select_focus_target( + &skill_tree, + DrillScope::Global, + &key_stats, + &bigram_stats, + ); + + match target { + FocusTarget::Char(_) => {} // expected: bigram filtered by stability gate + FocusTarget::Bigram(_) => { + panic!("Should NOT select bigram with insufficient redundancy streak"); + } + } + } + + // --- Hesitation tests --- + + #[test] + fn hesitation_threshold_respects_floor() { + assert_eq!(hesitation_threshold(100.0), 800.0); // 2.5 * 100 = 250 < 800 + assert_eq!(hesitation_threshold(400.0), 1000.0); // 2.5 * 400 = 1000 > 800 + } + + // --- Median tests --- + + #[test] + fn median_odd_count() { + let mut vals = vec![5.0, 1.0, 3.0]; + assert_eq!(compute_median(&mut vals), 3.0); + } + + #[test] + fn median_even_count() { + let mut vals = vec![1.0, 2.0, 3.0, 4.0]; + assert_eq!(compute_median(&mut vals), 2.5); + } + + #[test] + fn median_empty() { + let mut vals: Vec = vec![]; + assert_eq!(compute_median(&mut vals), 0.0); + } + + // --- Trigram marginal gain --- + + #[test] + fn marginal_gain_zero_when_no_qualified() { + let trigram_stats = TrigramStatsStore::default(); + let bigram_stats = BigramStatsStore::default(); + let char_stats = KeyStatsStore::default(); + assert_eq!(trigram_marginal_gain(&trigram_stats, &bigram_stats, &char_stats), 0.0); + } + + // --- Replay invariance --- + + #[test] + fn replay_produces_correct_error_total_counts() { + // Simulate a replay: process keystrokes and verify counts + let mut key_stats = KeyStatsStore::default(); + + // Simulate: 10 correct 'a', 3 errors 'a', 5 correct 'b', 1 error 'b' + let keystrokes = vec![ + make_keytime('a', 200.0, true), + make_keytime('a', 210.0, true), + make_keytime('a', 190.0, true), + make_keytime('a', 220.0, false), // error + make_keytime('a', 200.0, true), + make_keytime('a', 200.0, true), + make_keytime('a', 200.0, true), + make_keytime('a', 200.0, false), // error + make_keytime('a', 200.0, true), + make_keytime('a', 200.0, true), + make_keytime('a', 200.0, true), + make_keytime('a', 200.0, true), + make_keytime('a', 200.0, false), // error + make_keytime('b', 300.0, true), + make_keytime('b', 300.0, true), + make_keytime('b', 300.0, true), + make_keytime('b', 300.0, true), + make_keytime('b', 300.0, true), + make_keytime('b', 300.0, false), // error + ]; + + // Process like rebuild_ngram_stats does + for kt in &keystrokes { + if kt.correct { + let stat = key_stats.stats.entry(kt.key).or_default(); + stat.total_count += 1; + } else { + key_stats.update_key_error(kt.key); + } + } + + let a_stat = key_stats.stats.get(&'a').unwrap(); + assert_eq!(a_stat.total_count, 13, "a: 10 correct + 3 errors = 13 total"); + assert_eq!(a_stat.error_count, 3, "a: 3 errors"); + + let b_stat = key_stats.stats.get(&'b').unwrap(); + assert_eq!(b_stat.total_count, 6, "b: 5 correct + 1 error = 6 total"); + assert_eq!(b_stat.error_count, 1, "b: 1 error"); + + // Verify smoothed error rate is reasonable + let a_rate = key_stats.smoothed_error_rate('a'); + // (3 + 1) / (13 + 2) = 4/15 = 0.2667 + assert!((a_rate - 4.0 / 15.0).abs() < f64::EPSILON); + + let b_rate = key_stats.smoothed_error_rate('b'); + // (1 + 1) / (6 + 2) = 2/8 = 0.25 + assert!((b_rate - 2.0 / 8.0).abs() < f64::EPSILON); + } + + #[test] + fn last_seen_drill_index_tracks_correctly() { + let mut bigram_stats = BigramStatsStore::default(); + let key = BigramKey(['a', 'b']); + + bigram_stats.update(key.clone(), 200.0, true, false, 0); + assert_eq!(bigram_stats.stats[&key].last_seen_drill_index, 0); + + bigram_stats.update(key.clone(), 200.0, true, false, 5); + assert_eq!(bigram_stats.stats[&key].last_seen_drill_index, 5); + + bigram_stats.update(key.clone(), 200.0, true, false, 42); + assert_eq!(bigram_stats.stats[&key].last_seen_drill_index, 42); + } + + #[test] + fn prune_recency_correct_with_mixed_drill_indices() { + // Simulate interleaved partial (indices 0,1,3) and full (indices 2,4) drills. + // The key point: total_drills must match the index space (5, not 2) + // to avoid artificially inflating recency for partial-drill trigrams. + let mut trigram_stats = TrigramStatsStore::default(); + let bigram_stats = BigramStatsStore::default(); + let char_stats = KeyStatsStore::default(); + + // "Old" trigram last seen at drill index 0 (earliest) + let old_key = TrigramKey(['o', 'l', 'd']); + trigram_stats.update(old_key.clone(), 300.0, true, false, 0); + trigram_stats.stats.get_mut(&old_key).unwrap().sample_count = 5; + + // "Mid" trigram last seen at partial drill index 1 + let mid_key = TrigramKey(['m', 'i', 'd']); + trigram_stats.update(mid_key.clone(), 300.0, true, false, 1); + trigram_stats.stats.get_mut(&mid_key).unwrap().sample_count = 5; + + // "New" trigram last seen at drill index 4 (most recent) + let new_key = TrigramKey(['n', 'e', 'w']); + trigram_stats.update(new_key.clone(), 300.0, true, false, 4); + trigram_stats.stats.get_mut(&new_key).unwrap().sample_count = 5; + + // Prune down to 2 entries with total_drills = 5 (matching history length) + trigram_stats.prune(2, 5, &bigram_stats, &char_stats); + + // "New" (index 4) should survive over "old" (index 0) due to higher recency + assert!(trigram_stats.stats.contains_key(&new_key), "most recent trigram should survive prune"); + assert!(!trigram_stats.stats.contains_key(&old_key), "oldest trigram should be pruned"); + assert_eq!(trigram_stats.stats.len(), 2); + + // Now verify that using a WRONG total (e.g. 2 completed drills instead of 5) + // would compress the recency range. We don't assert this breaks ordering here + // since the fix is in app.rs passing the correct total -- this test just confirms + // the correct behavior when the right total is used. + } + + // --- Performance budget tests --- + // These enforce hard pass/fail limits. Budgets are for release builds; + // debug builds are ~10-20x slower, so we apply a 20x multiplier. + + const DEBUG_MULTIPLIER: u32 = 20; + + fn make_bench_keystrokes(count: usize) -> Vec { + let chars = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j']; + (0..count) + .map(|i| KeyTime { + key: chars[i % chars.len()], + time_ms: 200.0 + (i % 50) as f64, + correct: i % 7 != 0, + }) + .collect() + } + + #[test] + fn perf_budget_extraction_under_1ms() { + let keystrokes = make_bench_keystrokes(500); + let budget = std::time::Duration::from_millis(1 * DEBUG_MULTIPLIER as u64); + + let start = std::time::Instant::now(); + for _ in 0..100 { + let _ = extract_ngram_events(&keystrokes, 800.0); + } + let elapsed = start.elapsed() / 100; + + assert!( + elapsed < budget, + "extraction took {elapsed:?} per call, budget is {budget:?}" + ); + } + + #[test] + fn perf_budget_update_under_1ms() { + let keystrokes = make_bench_keystrokes(500); + let (bigram_events, _) = extract_ngram_events(&keystrokes, 800.0); + let budget = std::time::Duration::from_millis(1 * DEBUG_MULTIPLIER as u64); + + let start = std::time::Instant::now(); + for _ in 0..100 { + let mut store = BigramStatsStore::default(); + for ev in bigram_events.iter().take(400) { + store.update(ev.key.clone(), ev.total_time_ms, ev.correct, ev.has_hesitation, 0); + } + } + let elapsed = start.elapsed() / 100; + + assert!( + elapsed < budget, + "update took {elapsed:?} per call, budget is {budget:?}" + ); + } + + #[test] + fn perf_budget_focus_selection_under_5ms() { + let all_chars: Vec = ('a'..='z').chain('A'..='Z').chain('0'..='9').collect(); + let mut bigram_stats = BigramStatsStore::default(); + let mut char_stats = KeyStatsStore::default(); + + for &ch in &all_chars { + let stat = char_stats.stats.entry(ch).or_default(); + stat.confidence = 0.8; + stat.filtered_time_ms = 430.0; + stat.sample_count = 50; + stat.total_count = 50; + stat.error_count = 3; + } + + let mut count: usize = 0; + for &a in &all_chars { + for &b in &all_chars { + if bigram_stats.stats.len() >= 3000 { + break; + } + let key = BigramKey([a, b]); + let stat = bigram_stats.stats.entry(key).or_default(); + stat.confidence = 0.5 + (count % 50) as f64 * 0.01; + stat.sample_count = 25 + count % 30; + stat.error_count = 5 + count % 10; + stat.redundancy_streak = if count % 3 == 0 { 3 } else { 1 }; + count += 1; + } + } + assert_eq!(bigram_stats.stats.len(), 3000); + + let unlocked: Vec = all_chars; + let budget = std::time::Duration::from_millis(5 * DEBUG_MULTIPLIER as u64); + + let start = std::time::Instant::now(); + for _ in 0..100 { + let _ = bigram_stats.weakest_bigram(&char_stats, &unlocked); + } + let elapsed = start.elapsed() / 100; + + assert!( + elapsed < budget, + "focus selection took {elapsed:?} per call, budget is {budget:?}" + ); + } + + #[test] + fn perf_budget_history_replay_under_500ms() { + let drills: Vec> = (0..500) + .map(|_| make_bench_keystrokes(300)) + .collect(); + + let budget = std::time::Duration::from_millis(500 * DEBUG_MULTIPLIER as u64); + + let start = std::time::Instant::now(); + let mut bigram_stats = BigramStatsStore::default(); + let mut trigram_stats = TrigramStatsStore::default(); + let mut key_stats = KeyStatsStore::default(); + + for (drill_idx, keystrokes) in drills.iter().enumerate() { + let (bigram_events, trigram_events) = extract_ngram_events(keystrokes, 800.0); + + for kt in keystrokes { + if kt.correct { + let stat = key_stats.stats.entry(kt.key).or_default(); + stat.total_count += 1; + } else { + key_stats.update_key_error(kt.key); + } + } + + for ev in &bigram_events { + bigram_stats.update( + ev.key.clone(), ev.total_time_ms, ev.correct, ev.has_hesitation, + drill_idx as u32, + ); + } + for ev in &trigram_events { + trigram_stats.update( + ev.key.clone(), ev.total_time_ms, ev.correct, ev.has_hesitation, + drill_idx as u32, + ); + } + } + let elapsed = start.elapsed(); + + // Sanity: we actually processed data + assert!(!bigram_stats.stats.is_empty()); + assert!(!trigram_stats.stats.is_empty()); + + assert!( + elapsed < budget, + "history replay took {elapsed:?}, budget is {budget:?}" + ); + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..070e949 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,18 @@ +// Library target exists solely for criterion benchmarks. +// The binary entry point is main.rs; this file re-declares the module tree so +// that bench harnesses can import types via `keydr::engine::*` / `keydr::session::*`. +// Most code is only exercised through the binary, so suppress dead_code warnings. +#![allow(dead_code)] + +// Public: used directly by benchmarks +pub mod engine; +pub mod session; + +// Private: required transitively by engine/session (won't compile without them) +mod app; +mod config; +mod event; +mod generator; +mod keyboard; +mod store; +mod ui; diff --git a/src/store/json_store.rs b/src/store/json_store.rs index f772fe3..b3e4cff 100644 --- a/src/store/json_store.rs +++ b/src/store/json_store.rs @@ -101,6 +101,7 @@ impl JsonStore { } /// Bundle all persisted data + config into an ExportData struct. + /// N-gram stats are not included — they are always rebuilt from drill history. pub fn export_all(&self, config: &Config) -> ExportData { let profile = self.load_profile().unwrap_or_default(); let key_stats = self.load_key_stats(); diff --git a/src/store/schema.rs b/src/store/schema.rs index 37a66e3..19c280b 100644 --- a/src/store/schema.rs +++ b/src/store/schema.rs @@ -74,6 +74,9 @@ impl Default for DrillHistoryData { pub const EXPORT_VERSION: u32 = 1; +/// Export contract: drill_history is the sole source of truth for n-gram stats. +/// N-gram data is always rebuilt from history on import/startup, so it is not +/// included in the export payload. #[derive(Clone, Debug, Serialize, Deserialize)] pub struct ExportData { pub keydr_export_version: u32,