Skip to main content

very_normal/
drill.rs

1//! The drills, and the typing engine that scores them.
2
3use std::collections::BTreeMap;
4use std::time::{Duration, Instant};
5
6use crate::layout;
7use crate::store::{LetterStat, Progress};
8
9/// How long a pause before the next key lights up on the map.
10pub const HINT_AFTER: Duration = Duration::from_millis(1200);
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum Mode {
14    Recall,
15    Words,
16    Review,
17    Real,
18    Links,
19    Mirror,
20}
21
22impl Mode {
23    pub const ALL: [Mode; 6] = [
24        Mode::Recall,
25        Mode::Words,
26        Mode::Review,
27        Mode::Real,
28        Mode::Links,
29        Mode::Mirror,
30    ];
31
32    /// The name stored with each run, shared with the web tutor.
33    pub fn id(self) -> &'static str {
34        match self {
35            Mode::Recall => "recall",
36            Mode::Words => "words",
37            Mode::Review => "review",
38            Mode::Real => "real",
39            Mode::Links => "links",
40            Mode::Mirror => "mirror",
41        }
42    }
43
44    pub fn label(self) -> &'static str {
45        match self {
46            Mode::Recall => "key recall",
47            Mode::Words => "new keys",
48            Mode::Review => "review",
49            Mode::Real => "real text 60s",
50            Mode::Links => "markdown links",
51            Mode::Mirror => "mirror (bonus)",
52        }
53    }
54
55    pub fn label_for(id: &str) -> &'static str {
56        Mode::ALL
57            .iter()
58            .find(|m| m.id() == id)
59            .map(|m| m.label())
60            .unwrap_or("other")
61    }
62
63    pub fn time_limit(self) -> Option<Duration> {
64        (self == Mode::Real).then(|| Duration::from_secs(60))
65    }
66
67    pub fn describe(self, stage: usize) -> String {
68        match self {
69            Mode::Recall if stage == 0 => "Single home-row keys. Pick a stage to drill its moved keys.".into(),
70            Mode::Recall => format!(
71                "One key at a time from this stage's moves: {}. Keys you miss or hesitate on come back more often.",
72                layout::focus(stage).iter().map(char::to_string).collect::<Vec<_>>().join(" ")
73            ),
74            Mode::Words if stage == 0 => "Common words from your own typing.".into(),
75            Mode::Words => "30 words you actually type, heavy in this stage's moved keys. The gate counts this one.".into(),
76            Mode::Review if stage < 2 => "Same as new keys until stage 2.".into(),
77            Mode::Review => format!("Words from every stage so far (1 to {stage}), so earlier swaps stay sharp."),
78            Mode::Real if stage == 0 => "60 seconds of your most common words. Three of these set your baseline.".into(),
79            Mode::Real => "60 seconds of your most common words. The gate compares this with your baseline.".into(),
80            Mode::Links => "[text](link) shapes for your symbol layers. The gate ignores it.".into(),
81            Mode::Mirror => "Left hand only. Hold the mirror key for right-hand letters; the second letter on each left key is what it types then. The gate ignores it.".into(),
82        }
83    }
84}
85
86/// A small xorshift generator: plenty for shuffling drill words.
87pub struct Rng(u64);
88
89impl Rng {
90    pub fn new() -> Rng {
91        let seed = std::time::SystemTime::now()
92            .duration_since(std::time::UNIX_EPOCH)
93            .map(|d| d.as_nanos() as u64)
94            .unwrap_or(0x9e37_79b9_7f4a_7c15);
95        Rng::seeded(seed)
96    }
97
98    pub fn seeded(seed: u64) -> Rng {
99        Rng(seed | 1)
100    }
101
102    pub fn next_u64(&mut self) -> u64 {
103        let mut x = self.0;
104        x ^= x << 13;
105        x ^= x >> 7;
106        x ^= x << 17;
107        self.0 = x;
108        x
109    }
110
111    /// A float in [0, 1).
112    pub fn unit(&mut self) -> f64 {
113        (self.next_u64() >> 11) as f64 / (1u64 << 53) as f64
114    }
115
116    pub fn below(&mut self, n: usize) -> usize {
117        (self.unit() * n as f64) as usize
118    }
119
120    pub fn pick<'a, T>(&mut self, items: &'a [T]) -> &'a T {
121        &items[self.below(items.len())]
122    }
123}
124
125impl Default for Rng {
126    fn default() -> Self {
127        Rng::new()
128    }
129}
130
131/// The stage's own word list, or common words that use its moved keys.
132fn stage_words(stage: usize) -> Vec<String> {
133    let own = layout::words(stage);
134    if stage > 0 && !own.is_empty() {
135        return own.to_vec();
136    }
137    let focus = layout::focus(stage);
138    let common = layout::common_words();
139    let picked: Vec<String> = common
140        .iter()
141        .filter(|w| w.chars().any(|c| focus.contains(&c)))
142        .take(80)
143        .cloned()
144        .collect();
145    if picked.len() >= 10 {
146        picked
147    } else {
148        common.iter().take(200).cloned().collect()
149    }
150}
151
152/// How much more often a word with your worst key comes up than one without.
153pub const LEAN: f64 = 3.0;
154
155/// Keys that need more work than the stage's middle key: slower, or missed
156/// more, scored from just above the middle (near 0) to the worst (1). Keys
157/// pressed fewer than five times aren't judged yet.
158pub fn trouble(stage: usize, progress: &Progress) -> BTreeMap<char, f64> {
159    let scores: Vec<(char, f64)> = progress
160        .letters(stage)
161        .iter()
162        .filter_map(|(c, s)| {
163            let ch = c.chars().next().filter(|ch| *ch != ' ')?;
164            let tries = s.n + s.miss;
165            (s.n > 0 && tries >= 5).then(|| {
166                let miss_rate = s.miss as f64 / tries as f64;
167                (ch, s.ms / s.n as f64 * (1.0 + 10.0 * miss_rate))
168            })
169        })
170        .collect();
171    if scores.len() < 3 {
172        return BTreeMap::new();
173    }
174    let mut sorted: Vec<f64> = scores.iter().map(|(_, v)| *v).collect();
175    sorted.sort_by(f64::total_cmp);
176    let (mid, worst) = (sorted[sorted.len() / 2], sorted[sorted.len() - 1]);
177    if worst <= mid {
178        return BTreeMap::new();
179    }
180    scores
181        .into_iter()
182        .filter(|(_, v)| *v > mid)
183        .map(|(c, v)| (c, (v - mid) / (worst - mid)))
184        .collect()
185}
186
187/// The keys a words drill leans on, worst first: at most three, and only
188/// ones clearly behind the rest.
189pub fn leaning(mode: Mode, stage: usize, progress: &Progress) -> Vec<char> {
190    if !matches!(mode, Mode::Words | Mode::Review) {
191        return Vec::new();
192    }
193    let mut keys: Vec<(char, f64)> = trouble(stage, progress)
194        .into_iter()
195        .filter(|(_, t)| *t >= 0.3)
196        .collect();
197    keys.sort_by(|a, b| b.1.total_cmp(&a.1));
198    keys.into_iter().take(3).map(|(c, _)| c).collect()
199}
200
201/// Words with troublesome keys come up more often, up to `LEAN` times, so
202/// they get extra reps without the whole drill turning into them.
203fn words_from(pool: &[String], n: usize, trouble: &BTreeMap<char, f64>, rng: &mut Rng) -> String {
204    let weights: Vec<f64> = pool
205        .iter()
206        .map(|w| {
207            let worst = w
208                .chars()
209                .filter_map(|c| trouble.get(&c))
210                .fold(0.0, |a: f64, t| a.max(*t));
211            1.0 + (LEAN - 1.0) * worst
212        })
213        .collect();
214    let total: f64 = weights.iter().sum();
215    (0..n)
216        .map(|_| {
217            let mut t = total * rng.unit();
218            for (w, weight) in pool.iter().zip(&weights) {
219                t -= weight;
220                if t <= 0.0 {
221                    return w.as_str();
222                }
223            }
224            pool[pool.len() - 1].as_str()
225        })
226        .collect::<Vec<_>>()
227        .join(" ")
228}
229
230/// Keys you miss or are slow on get picked more often.
231fn weighted_char(stage: usize, progress: &Progress, rng: &mut Rng) -> char {
232    let mut focus = layout::focus(stage);
233    if focus.is_empty() {
234        focus = "asdfjkl;".chars().collect();
235    }
236    let stats = progress.letters(stage);
237    let weights: Vec<f64> = focus
238        .iter()
239        .map(|c| match stats.get(&c.to_string()) {
240            Some(s) if s.n > 0 => {
241                1.0 + 12.0 * (s.miss as f64 / s.n as f64) + s.ms / s.n as f64 / 350.0
242            }
243            _ => 3.0,
244        })
245        .collect();
246    let mut t = weights.iter().sum::<f64>() * rng.unit();
247    for (c, w) in focus.iter().zip(&weights) {
248        t -= w;
249        if t <= 0.0 {
250            return *c;
251        }
252    }
253    focus[0]
254}
255
256pub fn build(mode: Mode, stage: usize, progress: &Progress, rng: &mut Rng) -> String {
257    let common = layout::common_words();
258    match mode {
259        Mode::Recall => {
260            let several = layout::focus(stage).len() > 1;
261            let mut out = String::new();
262            let mut prev = None;
263            while out.chars().count() < 40 {
264                let c = weighted_char(stage, progress, rng);
265                if several && Some(c) == prev {
266                    continue;
267                }
268                out.push(c);
269                prev = Some(c);
270            }
271            out
272        }
273        Mode::Words => words_from(&stage_words(stage), 30, &trouble(stage, progress), rng),
274        Mode::Review => {
275            let pool: Vec<String> = if stage < 2 {
276                stage_words(stage)
277            } else {
278                (1..=stage).flat_map(stage_words).collect()
279            };
280            words_from(&pool, 30, &trouble(stage, progress), rng)
281        }
282        Mode::Real => (0..260)
283            .map(|_| common[(rng.unit().powf(2.2) * common.len() as f64) as usize].as_str())
284            .collect::<Vec<_>>()
285            .join(" "),
286        Mode::Links => {
287            let top = &common[..300.min(common.len())];
288            (0..10)
289                .map(|_| {
290                    let (a, b, c) = (rng.pick(top), rng.pick(top), rng.pick(top));
291                    if rng.unit() < 0.5 {
292                        format!("see [{a} {b}]({c}.md)")
293                    } else {
294                        format!("[{a}](https://{b}.dev/{c})")
295                    }
296                })
297                .collect::<Vec<_>>()
298                .join(" ")
299        }
300        Mode::Mirror => {
301            let pool: Vec<String> = common
302                .iter()
303                .take(150)
304                .filter(|w| w.len() >= 2 && w.chars().all(|c| c.is_ascii_lowercase()))
305                .cloned()
306                .collect();
307            words_from(&pool, 20, &BTreeMap::new(), rng)
308        }
309    }
310}
311
312pub struct Drill {
313    pub mode: Mode,
314    pub stage: usize,
315    pub text: Vec<char>,
316    pub pos: usize,
317    pub keys: u64,
318    pub misses: u64,
319    pub error: bool,
320    pub done: bool,
321    started: Option<Instant>,
322    last_key: Option<Instant>,
323    last_progress: Instant,
324    finished_at: Option<Instant>,
325    log: BTreeMap<String, LetterStat>,
326    pairs: BTreeMap<String, LetterStat>,
327    typos: BTreeMap<String, u64>,
328}
329
330impl Drill {
331    pub fn new(mode: Mode, stage: usize, text: String, now: Instant) -> Drill {
332        Drill {
333            mode,
334            stage,
335            text: text.chars().collect(),
336            pos: 0,
337            keys: 0,
338            misses: 0,
339            error: false,
340            done: false,
341            started: None,
342            last_key: None,
343            last_progress: now,
344            finished_at: None,
345            log: BTreeMap::new(),
346            pairs: BTreeMap::new(),
347            typos: BTreeMap::new(),
348        }
349    }
350
351    pub fn expected(&self) -> Option<char> {
352        (!self.done)
353            .then(|| self.text.get(self.pos).copied())
354            .flatten()
355    }
356
357    pub fn started(&self) -> bool {
358        self.started.is_some()
359    }
360
361    /// Restarts the hint clock, for when the drill gets focus.
362    pub fn touch(&mut self, now: Instant) {
363        self.last_progress = now;
364    }
365
366    /// Wrong keys don't advance; they count as a miss on the key you wanted.
367    pub fn press(&mut self, c: char, now: Instant) {
368        if self.done {
369            return;
370        }
371        let Some(want) = self.expected() else { return };
372        if self.started.is_none() {
373            self.started = Some(now);
374            self.last_key = Some(now);
375        }
376        self.keys += 1;
377        // the key before, when both are letters or symbols: a pair to time
378        let pair = self
379            .pos
380            .checked_sub(1)
381            .map(|i| self.text[i])
382            .filter(|p| *p != ' ' && want != ' ')
383            .map(|p| format!("{p}{want}"));
384        let stat = self.log.entry(want.to_string()).or_default();
385        if c == want {
386            if want != ' ' && self.pos > 0 {
387                let gap = now - self.last_key.unwrap_or(now);
388                let ms = gap.min(Duration::from_secs(3)).as_secs_f64() * 1000.0;
389                stat.n += 1;
390                stat.ms += ms;
391                if let Some(pair) = pair {
392                    let p = self.pairs.entry(pair).or_default();
393                    p.n += 1;
394                    p.ms += ms;
395                }
396            }
397            self.pos += 1;
398            self.last_key = Some(now);
399            self.last_progress = now;
400            self.error = false;
401            if self.pos >= self.text.len() {
402                self.finish(now);
403            }
404        } else {
405            self.misses += 1;
406            stat.miss += 1;
407            self.error = true;
408            *self.typos.entry(format!("{want}>{c}")).or_default() += 1;
409            if let Some(pair) = pair {
410                self.pairs.entry(pair).or_default().miss += 1;
411            }
412        }
413    }
414
415    /// Ends a timed drill once its time is up.
416    pub fn tick(&mut self, now: Instant) {
417        if let (Some(limit), false) = (self.mode.time_limit(), self.done)
418            && self.elapsed(now) >= limit
419        {
420            self.finish(now);
421        }
422    }
423
424    fn finish(&mut self, now: Instant) {
425        self.done = true;
426        self.finished_at = Some(now);
427    }
428
429    pub fn elapsed(&self, now: Instant) -> Duration {
430        match self.started {
431            Some(s) => self.finished_at.unwrap_or(now) - s,
432            None => Duration::ZERO,
433        }
434    }
435
436    pub fn remaining(&self, now: Instant) -> Option<Duration> {
437        self.mode
438            .time_limit()
439            .map(|l| l.saturating_sub(self.elapsed(now)))
440    }
441
442    pub fn wpm(&self, now: Instant) -> f64 {
443        let minutes = self.elapsed(now).as_secs_f64().max(1.0) / 60.0;
444        self.pos as f64 / 5.0 / minutes
445    }
446
447    pub fn accuracy(&self) -> f64 {
448        if self.keys == 0 {
449            100.0
450        } else {
451            100.0 * (self.keys - self.misses) as f64 / self.keys as f64
452        }
453    }
454
455    pub fn hesitating(&self, now: Instant) -> bool {
456        !self.done && now - self.last_progress >= HINT_AFTER
457    }
458
459    pub fn letters(&self) -> &BTreeMap<String, LetterStat> {
460        &self.log
461    }
462
463    /// Timings for each pair of keys typed one after the other.
464    pub fn pairs(&self) -> &BTreeMap<String, LetterStat> {
465        &self.pairs
466    }
467
468    /// Wrong keys, as "wanted>typed".
469    pub fn typos(&self) -> &BTreeMap<String, u64> {
470        &self.typos
471    }
472
473    /// The slowest keys of this drill, for the recall summary.
474    pub fn slowest(&self, n: usize) -> Vec<(char, f64, u64)> {
475        let mut v: Vec<(char, f64, u64)> = self
476            .log
477            .iter()
478            .filter_map(|(c, s)| {
479                let ch = c.chars().next()?;
480                Some((ch, s.average_ms()?, s.miss))
481            })
482            .collect();
483        v.sort_by(|a, b| b.1.total_cmp(&a.1));
484        v.truncate(n);
485        v
486    }
487}
488
489#[cfg(test)]
490mod tests {
491    use super::*;
492
493    fn at(start: Instant, ms: u64) -> Instant {
494        start + Duration::from_millis(ms)
495    }
496
497    #[test]
498    fn wrong_keys_count_as_misses_and_do_not_advance() {
499        let t0 = Instant::now();
500        let mut d = Drill::new(Mode::Words, 1, "ab".into(), t0);
501        d.press('x', at(t0, 0));
502        assert_eq!((d.pos, d.misses, d.error), (0, 1, true));
503        d.press('a', at(t0, 100));
504        d.press('b', at(t0, 400));
505        assert!(d.done);
506        assert_eq!(d.keys, 3);
507        assert!((d.accuracy() - 66.666).abs() < 0.01);
508        assert_eq!(d.letters()["b"].ms, 300.0);
509        assert_eq!(d.letters()["a"].miss, 1);
510        assert_eq!(d.typos()["a>x"], 1);
511    }
512
513    #[test]
514    fn pairs_time_the_second_key_and_skip_spaces() {
515        let t0 = Instant::now();
516        let mut d = Drill::new(Mode::Words, 1, "th e".into(), t0);
517        d.press('t', at(t0, 0));
518        d.press('y', at(t0, 50));
519        d.press('h', at(t0, 200));
520        d.press(' ', at(t0, 300));
521        d.press('e', at(t0, 450));
522        assert_eq!(d.pairs().len(), 1);
523        let th = d.pairs()["th"];
524        assert_eq!((th.n, th.miss, th.ms), (1, 1, 200.0));
525    }
526
527    #[test]
528    fn timed_drills_stop_at_the_limit() {
529        let t0 = Instant::now();
530        let mut d = Drill::new(Mode::Real, 0, "a".repeat(500), t0);
531        d.press('a', t0);
532        d.tick(at(t0, 59_000));
533        assert!(!d.done);
534        d.tick(at(t0, 60_000));
535        assert!(d.done);
536        assert_eq!(d.elapsed(at(t0, 90_000)), Duration::from_secs(60));
537    }
538
539    #[test]
540    fn wpm_is_characters_over_five_per_minute() {
541        let t0 = Instant::now();
542        let mut d = Drill::new(Mode::Words, 1, "hello world".into(), t0);
543        for (i, c) in "hello world".chars().enumerate() {
544            d.press(c, at(t0, i as u64 * 1200));
545        }
546        // 11 characters over 12 seconds
547        assert!((d.wpm(at(t0, 99_000)) - 11.0).abs() < 0.01);
548    }
549
550    #[test]
551    fn the_hint_shows_after_a_pause() {
552        let t0 = Instant::now();
553        let d = Drill::new(Mode::Recall, 1, "e".into(), t0);
554        assert!(!d.hesitating(at(t0, 1000)));
555        assert!(d.hesitating(at(t0, 1300)));
556    }
557
558    #[test]
559    fn recall_only_uses_the_stage_keys() {
560        let mut rng = Rng::seeded(42);
561        let text = build(Mode::Recall, 3, &Progress::default(), &mut rng);
562        let focus = layout::focus(3);
563        assert_eq!(text.chars().count(), 40);
564        assert!(text.chars().all(|c| focus.contains(&c)));
565        assert!(text.chars().zip(text.chars().skip(1)).all(|(a, b)| a != b));
566    }
567
568    fn with_letters(stats: &[(char, u64, u64, f64)]) -> Progress {
569        let mut p = Progress::default();
570        let stage = p.letters.entry("1".into()).or_default();
571        for (c, n, miss, avg) in stats {
572            stage.insert(
573                c.to_string(),
574                LetterStat {
575                    n: *n,
576                    miss: *miss,
577                    ms: avg * *n as f64,
578                },
579            );
580        }
581        p
582    }
583
584    #[test]
585    fn trouble_scores_slow_and_missed_keys_above_the_middle() {
586        let p = with_letters(&[
587            ('a', 20, 0, 250.0),
588            ('b', 20, 0, 260.0),
589            ('c', 20, 0, 270.0),
590            ('k', 20, 0, 2000.0),
591            ('i', 20, 2, 600.0),
592            ('z', 2, 2, 5000.0), // too few to judge
593        ]);
594        let t = trouble(1, &p);
595        assert_eq!(t[&'k'], 1.0);
596        assert!(t[&'i'] > 0.4 && t[&'i'] < 1.0, "{t:?}");
597        assert!(!t.contains_key(&'a') && !t.contains_key(&'z'));
598        assert_eq!(leaning(Mode::Words, 1, &p), vec!['k', 'i']);
599        assert!(leaning(Mode::Real, 1, &p).is_empty());
600        assert!(trouble(2, &p).is_empty());
601    }
602
603    #[test]
604    fn words_with_the_worst_key_come_up_about_three_times_as_often() {
605        let pool = vec!["ok".to_string(), "no".to_string()];
606        let t = BTreeMap::from([('k', 1.0)]);
607        let text = words_from(&pool, 4000, &t, &mut Rng::seeded(3));
608        let ok = text.split(' ').filter(|w| *w == "ok").count() as f64;
609        let share = ok / 4000.0;
610        assert!((share - 0.75).abs() < 0.03, "{share}");
611    }
612
613    #[test]
614    fn every_mode_builds_text() {
615        let mut rng = Rng::seeded(7);
616        for stage in 0..=layout::last() {
617            for mode in Mode::ALL {
618                assert!(!build(mode, stage, &Progress::default(), &mut rng).is_empty());
619            }
620        }
621    }
622}