use serde::{Deserialize, Serialize}; use std::collections::HashMap; const EMA_ALPHA: f64 = 0.1; const DEFAULT_TARGET_CPM: f64 = 175.0; /// Sticky-mastery promotion thresholds. A key is promoted to `mastered: true` /// only when it meets all three gates on a ranked stats update. pub const MASTERY_MIN_SPEED_CONFIDENCE: f64 = 1.05; pub const MASTERY_MIN_SAMPLES: usize = 20; pub const MASTERY_MAX_ERROR_RATE_EMA: f64 = 0.05; #[derive(Clone, Debug, Serialize, Deserialize)] pub struct KeyStat { pub filtered_time_ms: f64, pub best_time_ms: f64, pub confidence: f64, pub sample_count: usize, pub recent_times: Vec, #[serde(default)] pub error_count: usize, #[serde(default)] pub total_count: usize, #[serde(default = "default_error_rate_ema")] pub error_rate_ema: f64, // Sticky mastery: once true, never reset by normal drill updates. // Only authoritative when read from `ranked_key_stats` (unranked `key_stats` // stores share the type but are not allowed to drive progression). #[serde(default)] pub mastered: bool, } fn default_error_rate_ema() -> f64 { 0.5 } impl KeyStat { /// Returns true if this stat currently satisfies every sticky-mastery gate. /// Does not mutate anything — callers promote via `KeyStatsStore` helpers. pub fn qualifies_for_mastery(&self) -> bool { self.confidence >= MASTERY_MIN_SPEED_CONFIDENCE && self.sample_count >= MASTERY_MIN_SAMPLES && self.error_rate_ema <= MASTERY_MAX_ERROR_RATE_EMA } } impl Default for KeyStat { fn default() -> Self { Self { filtered_time_ms: 1000.0, best_time_ms: f64::MAX, confidence: 0.0, sample_count: 0, recent_times: Vec::new(), error_count: 0, total_count: 0, error_rate_ema: 0.5, mastered: false, } } } #[derive(Clone, Debug, Serialize, Deserialize)] pub struct KeyStatsStore { pub stats: HashMap, pub target_cpm: f64, } impl Default for KeyStatsStore { fn default() -> Self { Self { stats: HashMap::new(), target_cpm: DEFAULT_TARGET_CPM, } } } 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; } 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); let target_time_ms = 60000.0 / self.target_cpm; stat.confidence = target_time_ms / stat.filtered_time_ms; stat.recent_times.push(time_ms); if stat.recent_times.len() > 30 { stat.recent_times.remove(0); } // Update error rate EMA (correct stroke = 0.0 signal) if stat.total_count == 1 { stat.error_rate_ema = 0.0; } else { stat.error_rate_ema = EMA_ALPHA * 0.0 + (1.0 - EMA_ALPHA) * stat.error_rate_ema; } } pub fn get_confidence(&self, key: char) -> f64 { self.stats.get(&key).map(|s| s.confidence).unwrap_or(0.0) } #[allow(dead_code)] 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; // Update error rate EMA (error stroke = 1.0 signal) if stat.total_count == 1 { stat.error_rate_ema = 1.0; } else { stat.error_rate_ema = EMA_ALPHA * 1.0 + (1.0 - EMA_ALPHA) * stat.error_rate_ema; } } /// Ranked counterpart of [`update_key`]. Accumulates identical metrics and /// additionally evaluates sticky-mastery promotion. /// /// Promotion lives only on the ranked methods: `ranked_key_stats` is the sole /// authority for progression, so unranked/partial activity must never set the /// `mastered` bit even when its metrics would satisfy the gate. pub fn update_key_ranked(&mut self, key: char, time_ms: f64) { self.update_key(key, time_ms); self.promote_if_qualified(key); } /// Ranked counterpart of [`update_key_error`]. See [`update_key_ranked`] for /// why promotion is ranked-only. /// /// Errors never unset sticky mastery, but promotion is still evaluated here so /// both ranked entry points share one promotion site (a key sitting right on /// the gate can still promote from already-accumulated samples/confidence). pub fn update_key_error_ranked(&mut self, key: char) { self.update_key_error(key); self.promote_if_qualified(key); } /// Single source of truth for promotion. Flips `mastered` from false to true /// when all thresholds are met. Never unsets mastery. fn promote_if_qualified(&mut self, key: char) { if let Some(stat) = self.stats.get_mut(&key) && !stat.mastered && stat.qualifies_for_mastery() { stat.mastered = true; } } /// Every key currently holding the sticky mastery bit. pub fn mastered_keys(&self) -> Vec { self.stats .iter() .filter(|(_, s)| s.mastered) .map(|(&k, _)| k) .collect() } /// Re-apply a previously earned sticky mastery bit, creating a bare entry if /// the key has no stats left. Used when rebuilding from a history window that /// no longer contains the strokes that originally earned mastery — mastery is /// one-way, so a rebuild must never demote a key. pub fn seed_mastered(&mut self, key: char) { self.stats.entry(key).or_default().mastered = true; } /// Returns the sticky persisted mastery bit for a key. pub fn is_mastered(&self, key: char) -> bool { self.stats.get(&key).map(|s| s.mastered).unwrap_or(false) } /// Returns true if the key's current stats would satisfy the promotion gate. /// Useful for tests/diagnostics; live updates promote automatically. #[allow(dead_code)] pub fn qualifies_for_mastery(&self, key: char) -> bool { self.stats .get(&key) .map(|s| s.qualifies_for_mastery()) .unwrap_or(false) } /// Count of keys with sticky mastery set. #[allow(dead_code)] pub fn mastered_count(&self) -> usize { self.stats.values().filter(|s| s.mastered).count() } /// EMA-based error rate for a key. pub fn smoothed_error_rate(&self, key: char) -> f64 { match self.stats.get(&key) { Some(s) => s.error_rate_ema, None => 0.5, } } } #[cfg(test)] mod tests { use super::*; #[test] fn test_initial_confidence_is_zero() { let store = KeyStatsStore::default(); assert_eq!(store.get_confidence('a'), 0.0); } #[test] fn test_update_key_creates_stat() { let mut store = KeyStatsStore::default(); store.update_key('e', 300.0); assert!(store.get_confidence('e') > 0.0); assert_eq!(store.stats.get(&'e').unwrap().sample_count, 1); } #[test] fn test_ema_converges() { let mut store = KeyStatsStore::default(); // Type key fast many times - confidence should increase for _ in 0..50 { store.update_key('t', 200.0); } let conf = store.get_confidence('t'); // At 175 CPM target, target_time = 60000/175 = 342.8ms // With 200ms typing time, confidence = 342.8/200 = 1.71 assert!( conf > 1.0, "confidence should be > 1.0 for fast typing, got {conf}" ); } #[test] fn test_slow_typing_low_confidence() { let mut store = KeyStatsStore::default(); for _ in 0..50 { store.update_key('a', 1000.0); } let conf = store.get_confidence('a'); // target_time = 342.8ms, typing at 1000ms -> conf = 342.8/1000 = 0.34 assert!( conf < 1.0, "confidence should be < 1.0 for slow typing, got {conf}" ); } #[test] fn test_ema_error_rate_correct_strokes() { let mut store = KeyStatsStore::default(); // All correct strokes → EMA should be 0.0 for first, stay near 0 store.update_key('a', 200.0); assert!((store.smoothed_error_rate('a') - 0.0).abs() < f64::EPSILON); for _ in 0..10 { store.update_key('a', 200.0); } assert!( store.smoothed_error_rate('a') < 0.01, "All correct → EMA near 0" ); } #[test] fn test_ema_error_rate_error_strokes() { let mut store = KeyStatsStore::default(); // First stroke is error store.update_key_error('b'); assert!((store.smoothed_error_rate('b') - 1.0).abs() < f64::EPSILON); // Follow with correct strokes → EMA decays for _ in 0..20 { store.update_key('b', 200.0); } let rate = store.smoothed_error_rate('b'); assert!( rate < 0.15, "After 20 correct, EMA should be < 0.15, got {rate}" ); } #[test] fn test_ema_error_rate_default_for_missing_key() { let store = KeyStatsStore::default(); assert!((store.smoothed_error_rate('z') - 0.5).abs() < f64::EPSILON); } #[test] fn test_ema_error_rate_serde_default() { // Verify backward compat: deserializing old data without error_rate_ema gets 0.5 let json = r#"{"filtered_time_ms":200.0,"best_time_ms":200.0,"confidence":1.0,"sample_count":10,"recent_times":[],"error_count":2,"total_count":10}"#; let stat: KeyStat = serde_json::from_str(json).unwrap(); assert!((stat.error_rate_ema - 0.5).abs() < f64::EPSILON); } #[test] fn test_mastered_defaults_false_on_deserialize() { let json = r#"{"filtered_time_ms":200.0,"best_time_ms":200.0,"confidence":1.2,"sample_count":50,"recent_times":[],"error_count":0,"total_count":50,"error_rate_ema":0.0}"#; let stat: KeyStat = serde_json::from_str(json).unwrap(); assert!( !stat.mastered, "mastered should default to false when absent from JSON" ); } #[test] fn test_no_mastery_from_speed_alone_with_high_error_rate() { let mut store = KeyStatsStore::default(); // Inject many errors first to drive error_rate_ema high. for _ in 0..30 { store.update_key_error('a'); } // Then just enough fast correct strokes to satisfy speed + samples, // but not so many that the error-rate EMA decays below the threshold. for _ in 0..20 { store.update_key_ranked('a', 200.0); } let stat = store.get_stat('a').unwrap(); assert!( stat.confidence >= MASTERY_MIN_SPEED_CONFIDENCE, "precondition: fast enough" ); assert!( stat.sample_count >= MASTERY_MIN_SAMPLES, "precondition: enough samples" ); // error_rate_ema decays with correct strokes but should still be above 0.05 here. assert!( stat.error_rate_ema > MASTERY_MAX_ERROR_RATE_EMA, "error_rate_ema should still exceed 0.05, got {}", stat.error_rate_ema ); assert!( !store.is_mastered('a'), "key must not be mastered while error rate too high" ); } #[test] fn test_no_mastery_with_too_few_samples() { let mut store = KeyStatsStore::default(); // Few fast strokes (below MASTERY_MIN_SAMPLES) for _ in 0..(MASTERY_MIN_SAMPLES - 1) { store.update_key_ranked('b', 150.0); } let stat = store.get_stat('b').unwrap(); assert!(stat.confidence >= MASTERY_MIN_SPEED_CONFIDENCE); assert!(stat.error_rate_ema <= MASTERY_MAX_ERROR_RATE_EMA); assert!(stat.sample_count < MASTERY_MIN_SAMPLES); assert!( !store.is_mastered('b'), "key must not be mastered without enough samples" ); } #[test] fn test_mastery_when_all_thresholds_met() { let mut store = KeyStatsStore::default(); for _ in 0..30 { store.update_key_ranked('c', 150.0); } let stat = store.get_stat('c').unwrap(); assert!(stat.confidence >= MASTERY_MIN_SPEED_CONFIDENCE); assert!(stat.sample_count >= MASTERY_MIN_SAMPLES); assert!(stat.error_rate_ema <= MASTERY_MAX_ERROR_RATE_EMA); assert!(store.is_mastered('c')); } #[test] fn test_mastery_sticks_after_slow_strokes() { let mut store = KeyStatsStore::default(); for _ in 0..30 { store.update_key_ranked('d', 150.0); } assert!(store.is_mastered('d')); // Many slow strokes — confidence will drop below 1.0 eventually. for _ in 0..50 { store.update_key_ranked('d', 2000.0); } let conf = store.get_confidence('d'); assert!(conf < 1.0, "confidence should have dropped, got {conf}"); assert!( store.is_mastered('d'), "sticky mastery must persist after slow strokes" ); } #[test] fn test_mastery_sticks_after_errors() { let mut store = KeyStatsStore::default(); for _ in 0..30 { store.update_key_ranked('e', 150.0); } assert!(store.is_mastered('e')); for _ in 0..10 { store.update_key_error_ranked('e'); } assert!( store.is_mastered('e'), "sticky mastery must persist after later errors" ); } #[test] fn test_mastered_count_tracks_promotions() { let mut store = KeyStatsStore::default(); assert_eq!(store.mastered_count(), 0); for _ in 0..30 { store.update_key_ranked('x', 150.0); } assert_eq!(store.mastered_count(), 1); for _ in 0..30 { store.update_key_ranked('y', 150.0); } assert_eq!(store.mastered_count(), 2); } #[test] fn test_unranked_updates_never_promote_mastery() { let mut unranked = KeyStatsStore::default(); let mut ranked = KeyStatsStore::default(); // Identical, comfortably qualifying activity applied to both stores. for _ in 0..30 { unranked.update_key('m', 150.0); ranked.update_key_ranked('m', 150.0); } assert!( unranked.qualifies_for_mastery('m'), "precondition: unranked metrics do satisfy the gate" ); assert!( !unranked.is_mastered('m'), "unranked activity must never set the sticky bit — only ranked stats are authoritative" ); assert!( ranked.is_mastered('m'), "the same activity on ranked stats must promote" ); assert_eq!(unranked.mastered_count(), 0); assert_eq!(ranked.mastered_count(), 1); } #[test] fn test_unranked_error_updates_never_promote_mastery() { let mut unranked = KeyStatsStore::default(); for _ in 0..30 { unranked.update_key('n', 150.0); } // An error update is the other promotion entry point; the unranked // variant must not promote on it either. unranked.update_key_error('n'); assert!( !unranked.is_mastered('n'), "unranked error updates must not promote mastery" ); } #[test] fn test_seed_mastered_restores_bit_without_stats() { let mut store = KeyStatsStore::default(); store.seed_mastered('z'); assert!(store.is_mastered('z')); assert_eq!(store.get_stat('z').unwrap().sample_count, 0); // Seeding must not be undone by subsequent ordinary activity. for _ in 0..10 { store.update_key_ranked('z', 2000.0); } assert!(store.is_mastered('z')); } #[test] fn test_mastered_keys_lists_only_promoted_keys() { let mut store = KeyStatsStore::default(); for _ in 0..30 { store.update_key_ranked('p', 150.0); } for _ in 0..10 { store.update_key_ranked('q', 150.0); } let mut mastered = store.mastered_keys(); mastered.sort_unstable(); assert_eq!(mastered, vec!['p']); } }