1use std::collections::BTreeMap;
4use std::time::{Duration, Instant};
5
6use crate::layout;
7use crate::store::{LetterStat, Progress};
8
9pub 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 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
86pub 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 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
131fn 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
152pub const LEAN: f64 = 3.0;
154
155pub 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
187pub 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
201fn 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
230fn 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 pub fn touch(&mut self, now: Instant) {
363 self.last_progress = now;
364 }
365
366 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 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 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 pub fn pairs(&self) -> &BTreeMap<String, LetterStat> {
465 &self.pairs
466 }
467
468 pub fn typos(&self) -> &BTreeMap<String, u64> {
470 &self.typos
471 }
472
473 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 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), ]);
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}