use crate::engine::confidence;
#[derive(Default, Clone)]
pub struct KeyStats {
pub attempts: u32,
pub errors: u32,
pub reaction_times_ms: Vec<u64>,
pub filtered_time_ms: f64,
pub best_filtered_time_ms: f64,
lesson_sum_ms: f64,
lesson_samples: u32,
}
impl KeyStats {
const ALPHA: f64 = 0.1;
pub fn record_hit(&mut self, reaction_ms: u64) {
self.attempts += 1;
self.reaction_times_ms.push(reaction_ms);
if self.reaction_times_ms.len() > 20 {
self.reaction_times_ms.remove(0);
}
self.lesson_sum_ms += reaction_ms as f64;
self.lesson_samples += 1;
}
pub fn finish_lesson(&mut self) {
if self.lesson_samples == 0 {
return;
}
let mean = self.lesson_sum_ms / self.lesson_samples as f64;
self.lesson_sum_ms = 0.0;
self.lesson_samples = 0;
self.add_smoothed_sample(mean);
}
pub fn add_smoothed_sample(&mut self, sample_ms: f64) {
if self.filtered_time_ms == 0.0 {
self.filtered_time_ms = sample_ms;
} else {
self.filtered_time_ms =
Self::ALPHA * sample_ms + (1.0 - Self::ALPHA) * self.filtered_time_ms;
}
if self.best_filtered_time_ms == 0.0 || self.filtered_time_ms < self.best_filtered_time_ms {
self.best_filtered_time_ms = self.filtered_time_ms;
}
}
pub fn record_error(&mut self) {
self.errors += 1;
self.attempts += 1;
}
#[allow(dead_code)]
pub fn avg_reaction_ms(&self) -> f64 {
if self.reaction_times_ms.is_empty() {
return f64::MAX;
}
let sum: u64 = self.reaction_times_ms.iter().sum();
sum as f64 / self.reaction_times_ms.len() as f64
}
#[allow(dead_code)]
pub fn error_rate(&self) -> f64 {
if self.attempts == 0 {
return 0.0;
}
self.errors as f64 / self.attempts as f64
}
pub fn confidence(&self, target_cpm: f64) -> f64 {
if self.filtered_time_ms == 0.0 {
return 0.0;
}
confidence::confidence(target_cpm, self.filtered_time_ms)
}
pub fn best_confidence(&self, target_cpm: f64) -> f64 {
if self.best_filtered_time_ms == 0.0 {
return 0.0;
}
confidence::confidence(target_cpm, self.best_filtered_time_ms)
}
#[allow(dead_code)]
pub fn is_proficient(&self, target_cpm: f64) -> bool {
self.confidence(target_cpm) >= 1.0
}
pub fn wpm(&self) -> Option<f64> {
if self.filtered_time_ms > 0.0 {
Some(12_000.0 / self.filtered_time_ms)
} else {
None
}
}
pub fn best_wpm(&self) -> Option<f64> {
if self.best_filtered_time_ms > 0.0 {
Some(12_000.0 / self.best_filtered_time_ms)
} else {
None
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn lesson(stats: &mut KeyStats, times: &[u64]) {
for &t in times {
stats.record_hit(t);
}
stats.finish_lesson();
}
#[test]
fn new_key_stats_are_empty() {
let stats = KeyStats::default();
assert_eq!(stats.attempts, 0);
assert_eq!(stats.errors, 0);
assert!(stats.reaction_times_ms.is_empty());
assert!((stats.filtered_time_ms - 0.0).abs() < f64::EPSILON);
assert!((stats.best_filtered_time_ms - 0.0).abs() < f64::EPSILON);
}
#[test]
fn avg_reaction_ms_no_samples_returns_max() {
let stats = KeyStats::default();
assert_eq!(stats.avg_reaction_ms(), f64::MAX);
}
#[test]
fn avg_reaction_ms_calculates_correctly() {
let mut stats = KeyStats::default();
stats.record_hit(100);
stats.record_hit(200);
stats.record_hit(300);
assert!((stats.avg_reaction_ms() - 200.0).abs() < f64::EPSILON);
}
#[test]
fn error_rate_with_no_attempts() {
let stats = KeyStats::default();
assert!((stats.error_rate() - 0.0).abs() < f64::EPSILON);
}
#[test]
fn error_rate_calculates_correctly() {
let mut stats = KeyStats::default();
stats.record_hit(100);
stats.record_hit(100);
stats.record_error(); assert!((stats.error_rate() - 1.0 / 3.0).abs() < 0.001);
}
#[test]
fn reaction_times_capped_at_20() {
let mut stats = KeyStats::default();
for i in 0..30 {
stats.record_hit(i * 10);
}
assert_eq!(stats.reaction_times_ms.len(), 20);
}
#[test]
fn record_hit_alone_does_not_touch_filtered_time() {
let mut stats = KeyStats::default();
stats.record_hit(400);
assert!((stats.filtered_time_ms - 0.0).abs() < f64::EPSILON);
assert!((stats.best_filtered_time_ms - 0.0).abs() < f64::EPSILON);
}
#[test]
fn first_lesson_seeds_filtered_time() {
let mut stats = KeyStats::default();
lesson(&mut stats, &[400]);
assert!((stats.filtered_time_ms - 400.0).abs() < f64::EPSILON);
}
#[test]
fn finish_lesson_uses_lesson_mean() {
let mut stats = KeyStats::default();
lesson(&mut stats, &[200, 400]);
assert!((stats.filtered_time_ms - 300.0).abs() < f64::EPSILON);
}
#[test]
fn finish_lesson_without_samples_is_noop() {
let mut stats = KeyStats::default();
lesson(&mut stats, &[400]);
stats.finish_lesson(); assert!((stats.filtered_time_ms - 400.0).abs() < f64::EPSILON);
}
#[test]
fn exponential_smoothing_applies_across_lessons() {
let mut stats = KeyStats::default();
lesson(&mut stats, &[400]);
lesson(&mut stats, &[200]);
assert!((stats.filtered_time_ms - 380.0).abs() < 0.01);
}
#[test]
fn lesson_accumulator_resets_between_lessons() {
let mut stats = KeyStats::default();
lesson(&mut stats, &[400]);
lesson(&mut stats, &[200]);
assert!((stats.filtered_time_ms - 380.0).abs() < 0.01);
}
#[test]
fn best_filtered_time_tracks_minimum() {
let mut stats = KeyStats::default();
lesson(&mut stats, &[400]);
assert!((stats.best_filtered_time_ms - 400.0).abs() < f64::EPSILON);
for _ in 0..50 {
lesson(&mut stats, &[200]);
}
assert!(stats.best_filtered_time_ms <= stats.filtered_time_ms + 0.01);
assert!(stats.best_filtered_time_ms < 400.0);
}
#[test]
fn best_does_not_regress_when_slowing_down() {
let mut stats = KeyStats::default();
lesson(&mut stats, &[200]);
assert!((stats.best_filtered_time_ms - 200.0).abs() < f64::EPSILON);
lesson(&mut stats, &[800]);
assert!(stats.filtered_time_ms > 200.0);
assert!((stats.best_filtered_time_ms - 200.0).abs() < f64::EPSILON);
}
#[test]
fn add_smoothed_sample_matches_keybr_filter() {
let mut stats = KeyStats::default();
stats.add_smoothed_sample(500.0);
assert!((stats.filtered_time_ms - 500.0).abs() < f64::EPSILON);
stats.add_smoothed_sample(300.0);
assert!((stats.filtered_time_ms - 480.0).abs() < 0.01);
assert!((stats.best_filtered_time_ms - 480.0).abs() < 0.01);
}
#[test]
fn confidence_zero_without_data() {
let stats = KeyStats::default();
assert!((stats.confidence(175.0) - 0.0).abs() < f64::EPSILON);
}
#[test]
fn confidence_increases_with_speed() {
let mut stats = KeyStats::default();
lesson(&mut stats, &[200]);
let c = stats.confidence(175.0);
assert!(
c > 1.0,
"confidence should be > 1.0 for fast typing, got {}",
c
);
}
#[test]
fn confidence_below_one_when_slow() {
let mut stats = KeyStats::default();
lesson(&mut stats, &[600]);
let c = stats.confidence(175.0);
assert!(
c < 1.0,
"confidence should be < 1.0 for slow typing, got {}",
c
);
}
#[test]
fn best_confidence_zero_without_sample() {
let stats = KeyStats::default();
assert!((stats.best_confidence(175.0) - 0.0).abs() < f64::EPSILON);
}
#[test]
fn best_confidence_uses_best_not_current() {
let target_cpm = 175.0;
let target_time = 60_000.0 / target_cpm; let stats = KeyStats {
filtered_time_ms: 2.0 * target_time, best_filtered_time_ms: 0.9 * target_time, attempts: 50,
..KeyStats::default()
};
let cur = stats.confidence(target_cpm);
let best = stats.best_confidence(target_cpm);
assert!(cur < 1.0, "current confidence should be < 1.0, got {}", cur);
assert!(
best >= 1.0,
"best confidence should be >= 1.0, got {}",
best
);
}
#[test]
fn wpm_none_without_sample() {
let stats = KeyStats::default();
assert!(stats.wpm().is_none());
assert!(stats.best_wpm().is_none());
}
#[test]
fn wpm_converts_filtered_time_to_wpm() {
let stats = KeyStats {
filtered_time_ms: 343.0,
..KeyStats::default()
};
let wpm = stats.wpm().expect("should have a value");
assert!((wpm - 35.0).abs() < 0.1, "expected ~35 WPM, got {}", wpm);
}
#[test]
fn best_wpm_converts_best_filtered_time_to_wpm() {
let stats = KeyStats {
best_filtered_time_ms: 343.0,
..KeyStats::default()
};
let wpm = stats.best_wpm().expect("should have a value");
assert!((wpm - 35.0).abs() < 0.1, "expected ~35 WPM, got {}", wpm);
}
#[test]
fn is_proficient_uses_confidence() {
let mut stats = KeyStats::default();
lesson(&mut stats, &[200]); assert!(stats.is_proficient(175.0));
let mut slow_stats = KeyStats::default();
lesson(&mut slow_stats, &[600]); assert!(!slow_stats.is_proficient(175.0));
}
}