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:
+278
-77
@@ -1,10 +1,13 @@
|
||||
use std::collections::{BTreeSet, HashSet};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
use std::sync::Once;
|
||||
|
||||
use chrono::Datelike;
|
||||
use keydr::engine::key_stats::{
|
||||
KeyStatsStore, MASTERY_MAX_ERROR_RATE_EMA, MASTERY_MIN_SAMPLES, MASTERY_MIN_SPEED_CONFIDENCE,
|
||||
};
|
||||
use keydr::engine::scoring::level_from_score;
|
||||
use keydr::engine::skill_tree::{ALL_BRANCHES, BranchId, BranchStatus, DrillScope, SkillTree};
|
||||
use keydr::store::json_store::JsonStore;
|
||||
@@ -26,9 +29,6 @@ static GENERATE: Once = Once::new();
|
||||
/// Ensure test-profiles/ exists by running the generator binary (once per test run).
|
||||
fn ensure_profiles_generated() {
|
||||
GENERATE.call_once(|| {
|
||||
if Path::new("test-profiles/03-near-lowercase-complete.json").exists() {
|
||||
return;
|
||||
}
|
||||
let status = Command::new("cargo")
|
||||
.args(["run", "--bin", "generate_test_profiles"])
|
||||
.status()
|
||||
@@ -147,19 +147,40 @@ fn assert_profile_valid(name: &str) {
|
||||
);
|
||||
}
|
||||
|
||||
// Invariant #2 + #8: all keys in completed branches have confidence >= 1.0
|
||||
// and completed branches have stats for all their keys
|
||||
// Invariant #2 + #8: all keys in completed branches are sticky-mastered
|
||||
// and completed branches have stats for all their keys.
|
||||
//
|
||||
// Progression invariants are checked against `ranked_key_stats`: that is the
|
||||
// only authoritative store for mastery. Unranked `key_stats` shares the type
|
||||
// but must never carry the sticky bit.
|
||||
let completed_keys = completed_branch_keys(&data);
|
||||
for &key in &completed_keys {
|
||||
assert!(
|
||||
data.key_stats.stats.stats.contains_key(&key),
|
||||
"{name}: key '{key}' in completed branch has no stats entry"
|
||||
data.ranked_key_stats.stats.stats.contains_key(&key),
|
||||
"{name}: key '{key}' in completed branch has no ranked stats entry"
|
||||
);
|
||||
let stat = &data.key_stats.stats.stats[&key];
|
||||
let stat = &data.ranked_key_stats.stats.stats[&key];
|
||||
assert!(
|
||||
stat.confidence >= 1.0,
|
||||
"{name}: key '{key}' in completed branch has confidence {} < 1.0",
|
||||
stat.confidence
|
||||
stat.mastered,
|
||||
"{name}: key '{key}' in completed branch must have mastered == true, got stat with confidence {} sample_count {} error_rate_ema {}",
|
||||
stat.confidence, stat.sample_count, stat.error_rate_ema
|
||||
);
|
||||
// Deliberately NOT asserting qualifies_for_mastery() here: mastery is
|
||||
// sticky, so a key that earned it may currently sit below the gate (a
|
||||
// recent error spikes error_rate_ema, a slow stretch drops confidence).
|
||||
// What must hold is that the key accumulated real evidence.
|
||||
assert!(
|
||||
stat.sample_count >= MASTERY_MIN_SAMPLES,
|
||||
"{name}: key '{key}' in completed branch is mastered with only {} samples",
|
||||
stat.sample_count
|
||||
);
|
||||
}
|
||||
|
||||
// Only ranked activity may promote mastery.
|
||||
for (&key, stat) in &data.key_stats.stats.stats {
|
||||
assert!(
|
||||
!stat.mastered,
|
||||
"{name}: unranked key_stats entry '{key}' must not carry the sticky mastery bit"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -284,10 +305,10 @@ fn ranked_stats_cover_ranked_drill_keys() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Invariant #2: in-progress keys have confidence < 1.0 ────────────────
|
||||
// ── Invariant #2: in-progress keys have mastered == false ──────────────
|
||||
|
||||
#[test]
|
||||
fn in_progress_keys_have_partial_confidence() {
|
||||
fn in_progress_keys_are_not_mastered() {
|
||||
// Profiles 2, 3, 5, 6 have in-progress branches with partial keys
|
||||
for name in &[
|
||||
"02-early-lowercase.json",
|
||||
@@ -299,21 +320,22 @@ fn in_progress_keys_have_partial_confidence() {
|
||||
let data = load_profile(name);
|
||||
let ip_keys = in_progress_level_keys(&data);
|
||||
|
||||
// At least some in-progress keys should have confidence < 1.0
|
||||
// At least some in-progress keys should not yet be sticky-mastered.
|
||||
// Mastery is only authoritative in ranked stats.
|
||||
let partial_count = ip_keys
|
||||
.iter()
|
||||
.filter(|&&k| {
|
||||
data.key_stats
|
||||
data.ranked_key_stats
|
||||
.stats
|
||||
.stats
|
||||
.get(&k)
|
||||
.is_some_and(|s| s.confidence < 1.0)
|
||||
.is_some_and(|s| !s.mastered)
|
||||
})
|
||||
.count();
|
||||
assert!(
|
||||
partial_count > 0,
|
||||
"{name}: expected some in-progress keys with confidence < 1.0, \
|
||||
but all {} in-progress keys are mastered",
|
||||
"{name}: expected some in-progress keys with mastered == false, \
|
||||
but all {} in-progress keys are already mastered",
|
||||
ip_keys.len()
|
||||
);
|
||||
}
|
||||
@@ -415,109 +437,288 @@ fn streak_and_last_practice_date_consistent_with_history() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Profile-specific confidence bands ────────────────────────────────────
|
||||
/// Mastery assertions read `ranked_key_stats` — the authoritative progression
|
||||
/// store. Unranked `key_stats` never carries the sticky bit.
|
||||
fn assert_sticky_mastered(name: &str, key: char, data: &ExportData) {
|
||||
let stat = &data.ranked_key_stats.stats.stats[&key];
|
||||
assert!(
|
||||
stat.mastered,
|
||||
"{name}: key '{key}' should have mastered == true"
|
||||
);
|
||||
// Sticky mastery survives later dips, so assert durable evidence rather than
|
||||
// that the key still clears the gate right now.
|
||||
assert!(
|
||||
stat.sample_count >= MASTERY_MIN_SAMPLES,
|
||||
"{name}: key '{key}' is mastered with only {} samples",
|
||||
stat.sample_count
|
||||
);
|
||||
}
|
||||
|
||||
fn assert_not_mastered(name: &str, key: char, data: &ExportData) {
|
||||
let stat = &data.ranked_key_stats.stats.stats[&key];
|
||||
assert!(
|
||||
!stat.mastered,
|
||||
"{name}: key '{key}' should have mastered == false"
|
||||
);
|
||||
assert!(
|
||||
!stat.qualifies_for_mastery(),
|
||||
"{name}: key '{key}' should miss the mastery gate"
|
||||
);
|
||||
}
|
||||
|
||||
// ── Profile-specific mastery semantics ───────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn profile_specific_confidence_bands() {
|
||||
// Profile 02: s,h,r,d should be partial (0.3-0.7); e,t,a,o,i,n should be mastered
|
||||
fn profile_specific_mastery_semantics() {
|
||||
// Profile 02: s,h,r,d are partial; e,t,a,o,i,n are sticky-mastered.
|
||||
{
|
||||
let data = load_profile("02-early-lowercase.json");
|
||||
let stats = &data.key_stats.stats.stats;
|
||||
for &k in &['e', 't', 'a', 'o', 'i', 'n'] {
|
||||
let conf = stats[&k].confidence;
|
||||
assert!(conf >= 1.0, "02: key '{k}' should be mastered, got {conf}");
|
||||
assert_sticky_mastered("02", k, &data);
|
||||
}
|
||||
for &k in &['s', 'h', 'r', 'd'] {
|
||||
let conf = stats[&k].confidence;
|
||||
assert!(
|
||||
(0.2..1.0).contains(&conf),
|
||||
"02: key '{k}' should be partial (0.2-1.0), got {conf}"
|
||||
);
|
||||
assert_not_mastered("02", k, &data);
|
||||
}
|
||||
}
|
||||
|
||||
// Profile 03: first 14 keys mastered, w,f,g,y partial (0.4-0.8)
|
||||
// Profile 03: first 14 keys mastered; w,f,g,y remain partial.
|
||||
{
|
||||
let data = load_profile("03-mid-lowercase.json");
|
||||
let stats = &data.key_stats.stats.stats;
|
||||
let all_lc: Vec<char> = "etaoinshrdlcum".chars().collect();
|
||||
for &k in &all_lc {
|
||||
let conf = stats[&k].confidence;
|
||||
assert!(conf >= 1.0, "03: key '{k}' should be mastered, got {conf}");
|
||||
assert_sticky_mastered("03", k, &data);
|
||||
}
|
||||
for &k in &['w', 'f', 'g', 'y'] {
|
||||
let conf = stats[&k].confidence;
|
||||
assert!(
|
||||
(0.3..1.0).contains(&conf),
|
||||
"03: key '{k}' should be partial (0.3-1.0), got {conf}"
|
||||
);
|
||||
assert_not_mastered("03", k, &data);
|
||||
}
|
||||
}
|
||||
|
||||
// Profile 03-near: first 24 lowercase keys mastered, one key near mastery.
|
||||
// Profile 03-near: first 24 lowercase keys mastered; one key misses only speed.
|
||||
{
|
||||
let data = load_profile("03-near-lowercase-complete.json");
|
||||
let stats = &data.key_stats.stats.stats;
|
||||
let stats = &data.ranked_key_stats.stats.stats;
|
||||
let almost_all_lc: Vec<char> = "etaoinshrdlcumwfgypbvkjx".chars().collect();
|
||||
for &k in &almost_all_lc {
|
||||
let conf = stats[&k].confidence;
|
||||
assert!(
|
||||
conf >= 1.0,
|
||||
"03-near: key '{k}' should be mastered, got {conf}"
|
||||
);
|
||||
assert_sticky_mastered("03-near", k, &data);
|
||||
}
|
||||
let q_conf = stats[&'q'].confidence;
|
||||
let q_stat = &stats[&'q'];
|
||||
assert!(
|
||||
(0.8..1.0).contains(&q_conf),
|
||||
"03-near: key 'q' should be near mastery (0.8-1.0), got {q_conf}"
|
||||
!q_stat.mastered,
|
||||
"03-near: key 'q' should not yet be sticky-mastered"
|
||||
);
|
||||
assert!(
|
||||
q_stat.sample_count >= MASTERY_MIN_SAMPLES,
|
||||
"03-near: key 'q' should already meet the sample gate"
|
||||
);
|
||||
assert!(
|
||||
q_stat.error_rate_ema <= MASTERY_MAX_ERROR_RATE_EMA,
|
||||
"03-near: key 'q' should already meet the accuracy gate"
|
||||
);
|
||||
assert!(
|
||||
q_stat.confidence < MASTERY_MIN_SPEED_CONFIDENCE && q_stat.confidence >= 1.0,
|
||||
"03-near: key 'q' should miss only the speed gate, got confidence {}",
|
||||
q_stat.confidence
|
||||
);
|
||||
}
|
||||
|
||||
// Profile 05: capitals L2 partial (J,D,R,C,E), numbers partial (1,2,3),
|
||||
// punctuation partial (.,',')
|
||||
// Profile 05: capitals L2 partial (J,D,R,C), numbers partial (1,2,3),
|
||||
// punctuation partial (.,',').
|
||||
{
|
||||
let data = load_profile("05-multi-branch.json");
|
||||
let stats = &data.key_stats.stats.stats;
|
||||
for &k in &['J', 'D', 'R', 'C'] {
|
||||
let conf = stats[&k].confidence;
|
||||
assert!(
|
||||
(0.2..1.0).contains(&conf),
|
||||
"05: key '{k}' should be partial, got {conf}"
|
||||
);
|
||||
assert_not_mastered("05", k, &data);
|
||||
}
|
||||
for &k in &['1', '2', '3'] {
|
||||
let conf = stats[&k].confidence;
|
||||
assert!(
|
||||
(0.2..1.0).contains(&conf),
|
||||
"05: key '{k}' should be partial, got {conf}"
|
||||
);
|
||||
assert_not_mastered("05", k, &data);
|
||||
}
|
||||
for &k in &['.', ',', '\''] {
|
||||
let conf = stats[&k].confidence;
|
||||
assert!(
|
||||
(0.2..1.0).contains(&conf),
|
||||
"05: key '{k}' should be partial, got {conf}"
|
||||
);
|
||||
assert_not_mastered("05", k, &data);
|
||||
}
|
||||
}
|
||||
|
||||
// Profile 06: code symbols L3 partial (&,|,^,~)
|
||||
{
|
||||
let data = load_profile("06-advanced.json");
|
||||
let stats = &data.key_stats.stats.stats;
|
||||
for &k in &['&', '|', '^', '~'] {
|
||||
let conf = stats[&k].confidence;
|
||||
assert_not_mastered("06", k, &data);
|
||||
}
|
||||
assert_sticky_mastered("06", '!', &data);
|
||||
}
|
||||
}
|
||||
|
||||
/// A branch parked at an in-progress level claims that level is still being
|
||||
/// learned. If its keys were already mastered in the authoritative store, the
|
||||
/// saved progression and the ranked stats would contradict each other.
|
||||
#[test]
|
||||
fn current_level_keys_are_not_mastered_in_ranked_stats() {
|
||||
for name in ALL_PROFILES {
|
||||
let data = load_profile(name);
|
||||
for branch_def in ALL_BRANCHES {
|
||||
// Lowercase unlocks key-by-key rather than by level, so its frontier
|
||||
// legitimately mixes mastered and unmastered keys.
|
||||
if branch_def.id == BranchId::Lowercase {
|
||||
continue;
|
||||
}
|
||||
let Some(bp) = data.profile.skill_tree.branches.get(branch_def.id.to_key()) else {
|
||||
continue;
|
||||
};
|
||||
if bp.status != BranchStatus::InProgress || bp.current_level >= branch_def.levels.len()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// Keys shared with an already-completed branch are legitimately mastered.
|
||||
let completed = completed_branch_keys(&data);
|
||||
for &key in branch_def.levels[bp.current_level].keys {
|
||||
if completed.contains(&key) {
|
||||
continue;
|
||||
}
|
||||
if let Some(stat) = data.ranked_key_stats.stats.stats.get(&key) {
|
||||
assert!(
|
||||
!stat.mastered,
|
||||
"{name}: key '{key}' is in {:?}'s current (in-progress) level {} \
|
||||
but is already mastered in ranked stats",
|
||||
branch_def.id, bp.current_level
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The persisted stores must be reproducible from the fixture's own history.
|
||||
///
|
||||
/// Replaying `drill_history` the way the app does has to yield exactly the
|
||||
/// persisted `key_stats`/`ranked_key_stats` — including the sticky `mastered`
|
||||
/// bits — otherwise a fixture encodes a state the engine could never produce.
|
||||
#[test]
|
||||
fn fixture_stats_match_replay_of_their_drill_history() {
|
||||
for name in ALL_PROFILES {
|
||||
let data = load_profile(name);
|
||||
|
||||
let mut replayed_global = KeyStatsStore {
|
||||
target_cpm: data.key_stats.stats.target_cpm,
|
||||
..KeyStatsStore::default()
|
||||
};
|
||||
let mut replayed_ranked = KeyStatsStore {
|
||||
target_cpm: data.ranked_key_stats.stats.target_cpm,
|
||||
..KeyStatsStore::default()
|
||||
};
|
||||
|
||||
for drill in &data.drill_history.drills {
|
||||
for kt in &drill.per_key_times {
|
||||
if kt.correct {
|
||||
replayed_global.update_key(kt.key, kt.time_ms);
|
||||
} else {
|
||||
replayed_global.update_key_error(kt.key);
|
||||
}
|
||||
if drill.ranked {
|
||||
if kt.correct {
|
||||
replayed_ranked.update_key_ranked(kt.key, kt.time_ms);
|
||||
} else {
|
||||
replayed_ranked.update_key_error_ranked(kt.key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert_stores_match(
|
||||
name,
|
||||
"ranked_key_stats",
|
||||
&replayed_ranked,
|
||||
&data.ranked_key_stats.stats,
|
||||
);
|
||||
assert_stores_match(name, "key_stats", &replayed_global, &data.key_stats.stats);
|
||||
}
|
||||
}
|
||||
|
||||
/// Compare a replayed store against a persisted one.
|
||||
///
|
||||
/// Discrete state (mastery, counts) must match exactly. Float fields are
|
||||
/// compared with a tolerance: the persisted values made a round trip through
|
||||
/// JSON before being recomputed, so requiring bit-identical results would be
|
||||
/// asserting something about float formatting rather than about the engine.
|
||||
fn assert_stores_match(
|
||||
name: &str,
|
||||
label: &str,
|
||||
replayed: &KeyStatsStore,
|
||||
persisted: &KeyStatsStore,
|
||||
) {
|
||||
let replayed_keys: BTreeSet<char> = replayed.stats.keys().copied().collect();
|
||||
let persisted_keys: BTreeSet<char> = persisted.stats.keys().copied().collect();
|
||||
assert_eq!(
|
||||
replayed_keys, persisted_keys,
|
||||
"{name}: {label} covers different keys than a replay of its drill history"
|
||||
);
|
||||
|
||||
for (&key, expected) in &persisted.stats {
|
||||
let actual = &replayed.stats[&key];
|
||||
assert_eq!(
|
||||
actual.mastered, expected.mastered,
|
||||
"{name}: {label} key '{key}' mastered bit disagrees with replay"
|
||||
);
|
||||
assert_eq!(
|
||||
actual.sample_count, expected.sample_count,
|
||||
"{name}: {label} key '{key}' sample_count disagrees with replay"
|
||||
);
|
||||
assert_eq!(
|
||||
actual.error_count, expected.error_count,
|
||||
"{name}: {label} key '{key}' error_count disagrees with replay"
|
||||
);
|
||||
assert_eq!(
|
||||
actual.total_count, expected.total_count,
|
||||
"{name}: {label} key '{key}' total_count disagrees with replay"
|
||||
);
|
||||
for (field, a, e) in [
|
||||
("confidence", actual.confidence, expected.confidence),
|
||||
(
|
||||
"filtered_time_ms",
|
||||
actual.filtered_time_ms,
|
||||
expected.filtered_time_ms,
|
||||
),
|
||||
("best_time_ms", actual.best_time_ms, expected.best_time_ms),
|
||||
(
|
||||
"error_rate_ema",
|
||||
actual.error_rate_ema,
|
||||
expected.error_rate_ema,
|
||||
),
|
||||
] {
|
||||
assert!(
|
||||
(0.2..1.0).contains(&conf),
|
||||
"06: key '{k}' should be partial, got {conf}"
|
||||
(a - e).abs() <= 1e-9 * e.abs().max(1.0),
|
||||
"{name}: {label} key '{key}' {field} disagrees with replay: {a} vs {e}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Replaying a fixture's ranked history through the skill tree must reproduce
|
||||
/// the progression state the fixture ships, at least to the extent that every
|
||||
/// completed branch is backed by genuinely mastered keys.
|
||||
#[test]
|
||||
fn fixture_progression_is_backed_by_replayed_mastery() {
|
||||
for name in ALL_PROFILES {
|
||||
let data = load_profile(name);
|
||||
|
||||
let mut replayed = KeyStatsStore {
|
||||
target_cpm: data.ranked_key_stats.stats.target_cpm,
|
||||
..KeyStatsStore::default()
|
||||
};
|
||||
for drill in data.drill_history.drills.iter().filter(|d| d.ranked) {
|
||||
for kt in &drill.per_key_times {
|
||||
if kt.correct {
|
||||
replayed.update_key_ranked(kt.key, kt.time_ms);
|
||||
} else {
|
||||
replayed.update_key_error_ranked(kt.key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for &key in &completed_branch_keys(&data) {
|
||||
assert!(
|
||||
replayed.is_mastered(key),
|
||||
"{name}: key '{key}' is in a completed branch but replaying the fixture's \
|
||||
ranked history does not earn mastery for it"
|
||||
);
|
||||
}
|
||||
// '!' is shared with completed ProsePunctuation, must be mastered
|
||||
let bang_conf = stats[&'!'].confidence;
|
||||
assert!(
|
||||
bang_conf >= 1.0,
|
||||
"06: key '!' should be mastered (shared with complete branch), got {bang_conf}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user