First one-shot pass
This commit is contained in:
20
src/engine/filter.rs
Normal file
20
src/engine/filter.rs
Normal 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
120
src/engine/key_stats.rs
Normal 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}");
|
||||
}
|
||||
}
|
||||
59
src/engine/learning_rate.rs
Normal file
59
src/engine/learning_rate.rs
Normal 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
151
src/engine/letter_unlock.rs
Normal 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
5
src/engine/mod.rs
Normal 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
45
src/engine/scoring.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user