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.
This commit is contained in:
2026-08-12 01:09:52 -04:00
parent 0f8493eb02
commit 5daa644d3b
47 changed files with 2683 additions and 722 deletions
+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))
})
});
}