First one-shot pass

This commit is contained in:
2026-02-10 14:29:23 -05:00
parent 739d79d6a2
commit f65e3d8413
48 changed files with 5409 additions and 2 deletions

20
src/engine/filter.rs Normal file
View File

@@ -0,0 +1,20 @@
pub struct CharFilter {
pub allowed: Vec<char>,
}
impl CharFilter {
pub fn new(allowed: Vec<char>) -> Self {
Self { allowed }
}
pub fn is_allowed(&self, ch: char) -> bool {
self.allowed.contains(&ch) || ch == ' '
}
#[allow(dead_code)]
pub fn filter_text(&self, text: &str) -> String {
text.chars()
.filter(|&ch| self.is_allowed(ch))
.collect()
}
}

120
src/engine/key_stats.rs Normal file
View File

@@ -0,0 +1,120 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
const EMA_ALPHA: f64 = 0.1;
const DEFAULT_TARGET_CPM: f64 = 175.0;
#[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<f64>,
}
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(),
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct KeyStatsStore {
pub stats: HashMap<char, KeyStat>,
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;
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);
}
}
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)
}
}
#[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}");
}
}

View File

@@ -0,0 +1,59 @@
#[allow(dead_code)]
pub fn polynomial_regression(times: &[f64]) -> Option<f64> {
if times.len() < 3 {
return None;
}
let n = times.len();
let xs: Vec<f64> = (0..n).map(|i| i as f64).collect();
let x_mean: f64 = xs.iter().sum::<f64>() / n as f64;
let y_mean: f64 = times.iter().sum::<f64>() / n as f64;
let mut ss_xy = 0.0;
let mut ss_xx = 0.0;
let mut ss_yy = 0.0;
for i in 0..n {
let dx = xs[i] - x_mean;
let dy = times[i] - y_mean;
ss_xy += dx * dy;
ss_xx += dx * dx;
ss_yy += dy * dy;
}
if ss_xx < 1e-10 || ss_yy < 1e-10 {
return None;
}
let slope = ss_xy / ss_xx;
let r_squared = (ss_xy * ss_xy) / (ss_xx * ss_yy);
if r_squared < 0.5 {
return None;
}
let predicted_next = y_mean + slope * (n as f64 - x_mean);
Some(predicted_next.max(0.0))
}
#[allow(dead_code)]
pub fn learning_rate_description(times: &[f64]) -> &'static str {
match polynomial_regression(times) {
Some(predicted) => {
if times.is_empty() {
return "No data";
}
let current = times.last().unwrap();
let improvement = (current - predicted) / current * 100.0;
if improvement > 5.0 {
"Improving"
} else if improvement < -5.0 {
"Slowing down"
} else {
"Steady"
}
}
None => "Not enough data",
}
}

151
src/engine/letter_unlock.rs Normal file
View File

@@ -0,0 +1,151 @@
use crate::engine::key_stats::KeyStatsStore;
pub const FREQUENCY_ORDER: &[char] = &[
'e', 't', 'a', 'o', 'i', 'n', 's', 'h', 'r', 'd', 'l', 'c', 'u', 'm', 'w', 'f', 'g', 'y',
'p', 'b', 'v', 'k', 'j', 'x', 'q', 'z',
];
const MIN_LETTERS: usize = 6;
#[derive(Clone, Debug)]
pub struct LetterUnlock {
pub included: Vec<char>,
pub focused: Option<char>,
}
impl LetterUnlock {
pub fn new() -> Self {
let included = FREQUENCY_ORDER[..MIN_LETTERS].to_vec();
Self {
included,
focused: None,
}
}
pub fn from_included(included: Vec<char>) -> Self {
let mut lu = Self {
included,
focused: None,
};
lu.focused = None;
lu
}
pub fn update(&mut self, stats: &KeyStatsStore) {
let all_confident = self
.included
.iter()
.all(|&ch| stats.get_confidence(ch) >= 1.0);
if all_confident {
for &letter in FREQUENCY_ORDER {
if !self.included.contains(&letter) {
self.included.push(letter);
break;
}
}
}
while self.included.len() < MIN_LETTERS {
for &letter in FREQUENCY_ORDER {
if !self.included.contains(&letter) {
self.included.push(letter);
break;
}
}
}
self.focused = self
.included
.iter()
.filter(|&&ch| stats.get_confidence(ch) < 1.0)
.min_by(|&&a, &&b| {
stats
.get_confidence(a)
.partial_cmp(&stats.get_confidence(b))
.unwrap_or(std::cmp::Ordering::Equal)
})
.copied();
}
#[allow(dead_code)]
pub fn is_unlocked(&self, ch: char) -> bool {
self.included.contains(&ch)
}
pub fn unlocked_count(&self) -> usize {
self.included.len()
}
pub fn total_letters(&self) -> usize {
FREQUENCY_ORDER.len()
}
pub fn progress(&self) -> f64 {
self.unlocked_count() as f64 / self.total_letters() as f64
}
}
impl Default for LetterUnlock {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::engine::key_stats::KeyStatsStore;
#[test]
fn test_initial_unlock_has_min_letters() {
let lu = LetterUnlock::new();
assert_eq!(lu.unlocked_count(), 6);
assert_eq!(&lu.included, &['e', 't', 'a', 'o', 'i', 'n']);
}
#[test]
fn test_no_unlock_without_confidence() {
let mut lu = LetterUnlock::new();
let stats = KeyStatsStore::default();
lu.update(&stats);
assert_eq!(lu.unlocked_count(), 6);
}
#[test]
fn test_unlock_when_all_confident() {
let mut lu = LetterUnlock::new();
let mut stats = KeyStatsStore::default();
// Make all included keys confident by typing fast
for &ch in &['e', 't', 'a', 'o', 'i', 'n'] {
for _ in 0..50 {
stats.update_key(ch, 200.0);
}
}
lu.update(&stats);
assert_eq!(lu.unlocked_count(), 7);
assert!(lu.included.contains(&'s'));
}
#[test]
fn test_focused_key_is_weakest() {
let mut lu = LetterUnlock::new();
let mut stats = KeyStatsStore::default();
// Make most keys confident except 'o'
for &ch in &['e', 't', 'a', 'i', 'n'] {
for _ in 0..50 {
stats.update_key(ch, 200.0);
}
}
stats.update_key('o', 1000.0); // slow on 'o'
lu.update(&stats);
assert_eq!(lu.focused, Some('o'));
}
#[test]
fn test_progress_ratio() {
let lu = LetterUnlock::new();
let expected = 6.0 / 26.0;
assert!((lu.progress() - expected).abs() < 0.001);
}
}

5
src/engine/mod.rs Normal file
View File

@@ -0,0 +1,5 @@
pub mod filter;
pub mod key_stats;
pub mod learning_rate;
pub mod letter_unlock;
pub mod scoring;

45
src/engine/scoring.rs Normal file
View File

@@ -0,0 +1,45 @@
use crate::session::result::LessonResult;
pub fn compute_score(result: &LessonResult, complexity: f64) -> f64 {
let speed = result.cpm;
let errors = result.incorrect as f64;
let length = result.total_chars as f64;
(speed * complexity) / (errors + 1.0) * (length / 50.0)
}
pub fn compute_complexity(unlocked_count: usize) -> f64 {
(unlocked_count as f64 / 26.0).max(0.1)
}
pub fn level_from_score(total_score: f64) -> u32 {
let level = (total_score / 100.0).sqrt() as u32;
level.max(1)
}
#[allow(dead_code)]
pub fn score_to_next_level(total_score: f64) -> f64 {
let current_level = level_from_score(total_score);
let next_level_score = ((current_level + 1) as f64).powi(2) * 100.0;
next_level_score - total_score
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_level_starts_at_one() {
assert_eq!(level_from_score(0.0), 1);
}
#[test]
fn test_level_increases_with_score() {
assert!(level_from_score(1000.0) > level_from_score(100.0));
}
#[test]
fn test_complexity_scales_with_letters() {
assert!(compute_complexity(26) > compute_complexity(6));
assert!((compute_complexity(26) - 1.0).abs() < 0.001);
}
}