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