Skip to main content

vs_humanize/
keys.rs

1//! Keystroke sequence synthesis.
2//!
3//! `Human`: lognormal-ish inter-key delays with a mean in the
4//! 80–180 ms band, longer pauses at word boundaries, occasional
5//! typo + backspace + retype. Each character emits a `Down` followed
6//! by an `Up` with a small dwell between them.
7//!
8//! `Careful`: every character at a fixed 50 ms cadence, no typos,
9//! Down + Up per character.
10//!
11//! `Robotic`: empty vec — the engine falls back to setting the
12//! field's `.value` and dispatching `input`/`change` JS events.
13
14use std::time::Duration;
15
16use crate::rng::Rng;
17use crate::InputMode;
18
19/// One key, identified by a USB HID-ish `code` (engines map this to
20/// their platform's keycode space) and an optional UTF-32 character.
21///
22/// `character` is `None` for non-printable keys like `Backspace`.
23/// For typed text, `code` is the printable character's value and
24/// `character` carries the same `char`; the engine can choose which
25/// to read.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub struct Key {
28    pub code: u32,
29    pub character: Option<char>,
30}
31
32/// What kind of key event this step is.
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum KeyStepKind {
35    /// Key press (`keydown`).
36    Down,
37    /// Key release (`keyup`).
38    Up,
39    /// A composite press — engines that don't have separate keydown/
40    /// keyup paths can use this. We emit explicit `Down` + `Up` for
41    /// trusted paths and never use `Press` directly, but it's part
42    /// of the public type for engines that need it.
43    Press,
44}
45
46/// One key event in a synthesized sequence.
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub struct KeyStep {
49    pub at: Duration,
50    pub kind: KeyStepKind,
51    pub key: Key,
52}
53
54// --- Tuning ---
55
56/// Per-key dwell (down→up gap) in milliseconds for Human mode.
57const HUMAN_DWELL_MIN_MS: f64 = 30.0;
58const HUMAN_DWELL_MAX_MS: f64 = 80.0;
59
60/// Lognormal parameters for inter-key gap. With `mu=4.7, sigma=0.3`
61/// the geometric mean is exp(4.7) ≈ 110ms, with a long right tail.
62/// Clipped to `[40, 260]` so a single fat-tail draw can't stall the
63/// agent.
64const HUMAN_GAP_MU: f64 = 4.7;
65const HUMAN_GAP_SIGMA: f64 = 0.3;
66const HUMAN_GAP_MIN_MS: f64 = 40.0;
67const HUMAN_GAP_MAX_MS: f64 = 260.0;
68
69/// Multiplier on the inter-key gap when the next character is a word
70/// boundary (space, newline, tab, punctuation). Real humans pause
71/// here to think.
72const WORD_BOUNDARY_MULT: f64 = 1.7;
73
74/// Probability per character of fat-fingering a typo. Real numbers
75/// in the typing-research literature are around 1.5–3%; we pick the
76/// low end so test assertions about exact character counts don't
77/// flake.
78const HUMAN_TYPO_RATE: f64 = 0.015;
79
80/// Fixed cadence for Careful mode.
81const CAREFUL_GAP_MS: f64 = 50.0;
82
83/// Synthesize a key sequence for typing `text`.
84///
85/// Returns events in chronological order. Each character produces a
86/// `Down` at the inter-key gap offset and a matching `Up` after a
87/// short dwell. In Human mode, occasional typos (≈1.5%) insert a
88/// wrong-character `Down`/`Up`, a `Backspace` `Down`/`Up`, then the
89/// intended character.
90///
91/// - `InputMode::Human`: lognormal gaps, word-boundary pauses, typos.
92/// - `InputMode::Careful`: 50 ms fixed cadence, no typos.
93/// - `InputMode::Robotic`: empty vec.
94///
95/// `seed` is the deterministic stream selector. Same seed + same
96/// text produces bit-identical output.
97#[must_use]
98pub fn key_sequence(text: &str, mode: InputMode, seed: u64) -> Vec<KeyStep> {
99    match mode {
100        InputMode::Robotic => Vec::new(),
101        InputMode::Careful => careful_sequence(text),
102        InputMode::Human => human_sequence(text, seed),
103    }
104}
105
106fn careful_sequence(text: &str) -> Vec<KeyStep> {
107    let mut out = Vec::with_capacity(text.chars().count() * 2);
108    let mut t_ms = 0.0;
109    for ch in text.chars() {
110        let key = Key {
111            code: ch as u32,
112            character: Some(ch),
113        };
114        out.push(KeyStep {
115            at: ms(t_ms),
116            kind: KeyStepKind::Down,
117            key,
118        });
119        out.push(KeyStep {
120            at: ms(t_ms + 25.0),
121            kind: KeyStepKind::Up,
122            key,
123        });
124        t_ms += CAREFUL_GAP_MS;
125    }
126    out
127}
128
129fn human_sequence(text: &str, seed: u64) -> Vec<KeyStep> {
130    let mut rng = Rng::seed_from_u64(seed);
131    let mut out: Vec<KeyStep> = Vec::with_capacity(text.chars().count() * 2 + 4);
132    let mut t_ms = 0.0;
133    let chars: Vec<char> = text.chars().collect();
134
135    for (i, ch) in chars.iter().copied().enumerate() {
136        // Inter-key gap — sample lognormal, clip.
137        let gap_raw = (HUMAN_GAP_MU + HUMAN_GAP_SIGMA * rng.next_normal()).exp();
138        let mut gap = gap_raw.clamp(HUMAN_GAP_MIN_MS, HUMAN_GAP_MAX_MS);
139        if i > 0 && is_word_boundary(chars[i - 1]) {
140            gap *= WORD_BOUNDARY_MULT;
141        }
142        if i > 0 {
143            t_ms += gap;
144        }
145
146        // Occasional typo: insert a neighboring-letter press, then
147        // Backspace, then the intended character. Only fires on
148        // alphabetic input where typos are plausible. Each press's
149        // Down→Down spacing is the same lognormal-sampled gap as
150        // normal keys, but the typo path uses tight 60–120 ms gaps
151        // between wrong→backspace and backspace→intended (real
152        // corrections are faster than baseline typing).
153        if ch.is_ascii_alphabetic() && rng.next_f64() < HUMAN_TYPO_RATE {
154            let wrong = neighbor_letter(ch, &mut rng);
155            press(&mut out, t_ms, wrong, &mut rng);
156            t_ms += rng.next_uniform(60.0, 120.0);
157            press_backspace(&mut out, t_ms, &mut rng);
158            t_ms += rng.next_uniform(60.0, 120.0);
159        }
160
161        press(&mut out, t_ms, ch, &mut rng);
162    }
163    out
164}
165
166fn press(out: &mut Vec<KeyStep>, t_ms: f64, ch: char, rng: &mut Rng) {
167    // Dwell is the *within-press* Down→Up offset. Don't advance the
168    // caller's `t_ms`; that's reserved for the *inter-press* gap so
169    // Down→Down spacing equals the lognormal-sampled gap directly.
170    let dwell = rng.next_uniform(HUMAN_DWELL_MIN_MS, HUMAN_DWELL_MAX_MS);
171    let key = Key {
172        code: ch as u32,
173        character: Some(ch),
174    };
175    out.push(KeyStep {
176        at: ms(t_ms),
177        kind: KeyStepKind::Down,
178        key,
179    });
180    out.push(KeyStep {
181        at: ms(t_ms + dwell),
182        kind: KeyStepKind::Up,
183        key,
184    });
185}
186
187fn press_backspace(out: &mut Vec<KeyStep>, t_ms: f64, rng: &mut Rng) {
188    let dwell = rng.next_uniform(HUMAN_DWELL_MIN_MS, HUMAN_DWELL_MAX_MS);
189    // USB HID keyboard usage 0x2A — engines map this to their
190    // platform's Backspace virtual key.
191    let key = Key {
192        code: 0x2A,
193        character: None,
194    };
195    out.push(KeyStep {
196        at: ms(t_ms),
197        kind: KeyStepKind::Down,
198        key,
199    });
200    out.push(KeyStep {
201        at: ms(t_ms + dwell),
202        kind: KeyStepKind::Up,
203        key,
204    });
205}
206
207fn is_word_boundary(ch: char) -> bool {
208    ch == ' ' || ch == '\n' || ch == '\t' || ch == ',' || ch == '.' || ch == ';' || ch == ':'
209}
210
211/// Pick a plausible "neighboring" typo character. Uses a tiny QWERTY
212/// adjacency for ASCII alphabetics; falls back to the original for
213/// anything we don't have a table for. Doesn't need to be exhaustive
214/// — we just need _a_ wrong character that's typed-not-random.
215fn neighbor_letter(ch: char, rng: &mut Rng) -> char {
216    let lower = ch.to_ascii_lowercase();
217    let neighbors: &[char] = match lower {
218        'a' => &['s', 'q', 'w', 'z'],
219        'b' => &['v', 'n', 'g', 'h'],
220        'c' => &['x', 'v', 'd', 'f'],
221        'd' => &['s', 'f', 'e', 'r', 'c'],
222        'e' => &['w', 'r', 's', 'd'],
223        'f' => &['d', 'g', 'r', 't', 'v'],
224        'g' => &['f', 'h', 't', 'y', 'b'],
225        'h' => &['g', 'j', 'y', 'u', 'n'],
226        'i' => &['u', 'o', 'k'],
227        'j' => &['h', 'k', 'u', 'i', 'm'],
228        'k' => &['j', 'l', 'i', 'o'],
229        'l' => &['k', 'o', 'p'],
230        'm' => &['n', 'j', 'k'],
231        'n' => &['b', 'm', 'h', 'j'],
232        'o' => &['i', 'p', 'k', 'l'],
233        'p' => &['o', 'l'],
234        'q' => &['w', 'a'],
235        'r' => &['e', 't', 'd', 'f'],
236        's' => &['a', 'd', 'w', 'e', 'z', 'x'],
237        't' => &['r', 'y', 'f', 'g'],
238        'u' => &['y', 'i', 'h', 'j'],
239        'v' => &['c', 'b', 'f', 'g'],
240        'w' => &['q', 'e', 'a', 's'],
241        'x' => &['z', 'c', 's', 'd'],
242        'y' => &['t', 'u', 'g', 'h'],
243        'z' => &['a', 's', 'x'],
244        _ => return ch,
245    };
246    // Truncation is fine — modulo against `neighbors.len()` (≤ 5)
247    // makes the upper bits irrelevant.
248    #[allow(clippy::cast_possible_truncation)]
249    let i = (rng.next_u64() as usize) % neighbors.len();
250    let n = neighbors[i];
251    if ch.is_ascii_uppercase() {
252        n.to_ascii_uppercase()
253    } else {
254        n
255    }
256}
257
258fn ms(value: f64) -> Duration {
259    let v = value.max(0.0).round();
260    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
261    let ms_int = v as u64;
262    Duration::from_millis(ms_int)
263}
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268
269    #[test]
270    fn robotic_is_empty() {
271        let steps = key_sequence("hello", InputMode::Robotic, 0);
272        assert!(steps.is_empty());
273    }
274
275    #[test]
276    fn careful_one_down_one_up_per_char() {
277        let steps = key_sequence("hi!", InputMode::Careful, 0);
278        assert_eq!(steps.len(), 6);
279        let downs = steps.iter().filter(|s| s.kind == KeyStepKind::Down).count();
280        let ups = steps.iter().filter(|s| s.kind == KeyStepKind::Up).count();
281        assert_eq!(downs, 3);
282        assert_eq!(ups, 3);
283    }
284
285    #[test]
286    fn careful_fixed_cadence() {
287        let steps = key_sequence("abc", InputMode::Careful, 0);
288        // Three characters means three Downs at 0, 50, 100 ms.
289        let down_times: Vec<u128> = steps
290            .iter()
291            .filter(|s| s.kind == KeyStepKind::Down)
292            .map(|s| s.at.as_millis())
293            .collect();
294        assert_eq!(down_times, vec![0, 50, 100]);
295    }
296
297    #[test]
298    fn human_empty_text_yields_empty_sequence() {
299        let steps = key_sequence("", InputMode::Human, 7);
300        assert!(steps.is_empty());
301    }
302
303    #[test]
304    fn human_single_char_has_down_then_up() {
305        let steps = key_sequence("x", InputMode::Human, 7);
306        assert_eq!(steps.len(), 2);
307        assert_eq!(steps[0].kind, KeyStepKind::Down);
308        assert_eq!(steps[1].kind, KeyStepKind::Up);
309        assert_eq!(steps[0].key.character, Some('x'));
310    }
311
312    #[test]
313    fn human_is_deterministic_under_seed() {
314        let a = key_sequence("hello world", InputMode::Human, 1234);
315        let b = key_sequence("hello world", InputMode::Human, 1234);
316        assert_eq!(a, b);
317    }
318
319    #[test]
320    fn human_seed_change_changes_timing() {
321        let a = key_sequence("hello world", InputMode::Human, 1);
322        let b = key_sequence("hello world", InputMode::Human, 2);
323        // Last-event timestamp differs unless we got monumentally
324        // unlucky with two equally-distributed lognormal streams.
325        assert_ne!(a.last().unwrap().at, b.last().unwrap().at);
326    }
327
328    #[test]
329    fn human_word_boundary_pauses_longer() {
330        // The word-boundary multiplier applies to the gap *after* a
331        // boundary character (real users finish a word, hit space,
332        // then pause briefly before the next word). For "ab cd"
333        // that's the gap from the space's Down to 'c's Down. Compare
334        // it across many seeds against the in-word a→b gap; median
335        // so a single fat-tail draw doesn't flake the test.
336        let trials = 30;
337        let mut letter_gaps: Vec<u128> = Vec::new();
338        let mut boundary_gaps: Vec<u128> = Vec::new();
339        for seed in 0..trials {
340            // Use only consonants to avoid the 1.5% typo path
341            // perturbing the per-position gap interpretation; typos
342            // on alphabetic chars insert extra events into the gap.
343            let s = key_sequence("rt yu", InputMode::Careful, seed);
344            // Sanity: careful mode should not affect the test logic,
345            // we only use the alphabetic-skip property of the typo
346            // path. Switch back to Human for the real measurement.
347            assert_eq!(s.iter().filter(|s| s.kind == KeyStepKind::Down).count(), 5);
348        }
349        for seed in 0..trials {
350            let s = key_sequence("ab cd", InputMode::Human, seed);
351            let downs: Vec<u128> = s
352                .iter()
353                .filter(|s| s.kind == KeyStepKind::Down)
354                .map(|s| s.at.as_millis())
355                .collect();
356            // 5 chars → 5 Down events unless a typo fired; skip seeds
357            // where the typo path inserted extra Downs so the gap
358            // index alignment is preserved.
359            if downs.len() != 5 {
360                continue;
361            }
362            letter_gaps.push(downs[1] - downs[0]); // a→b (in-word)
363            boundary_gaps.push(downs[3] - downs[2]); // space→c (boundary)
364        }
365        let median = |mut v: Vec<u128>| {
366            v.sort_unstable();
367            v[v.len() / 2]
368        };
369        let m_letter = median(letter_gaps);
370        let m_boundary = median(boundary_gaps);
371        assert!(
372            m_boundary > m_letter,
373            "boundary gap median {m_boundary}ms should exceed letter gap median {m_letter}ms"
374        );
375    }
376
377    #[test]
378    fn human_mean_gap_in_expected_band() {
379        // Mean inter-key gap across many seeds should land in the
380        // 80–180 ms band the spec calls out. Sample widely to
381        // average over the lognormal tail.
382        let mut all_gaps: Vec<u128> = Vec::new();
383        for seed in 0..50 {
384            let s = key_sequence("the quick brown fox", InputMode::Human, seed);
385            let downs: Vec<u128> = s
386                .iter()
387                .filter(|s| s.kind == KeyStepKind::Down)
388                .map(|s| s.at.as_millis())
389                .collect();
390            for w in downs.windows(2) {
391                all_gaps.push(w[1] - w[0]);
392            }
393        }
394        #[allow(clippy::cast_precision_loss)]
395        let n = all_gaps.len() as f64;
396        #[allow(clippy::cast_precision_loss)]
397        let mean: f64 = all_gaps.iter().sum::<u128>() as f64 / n;
398        assert!(
399            (80.0..=180.0).contains(&mean),
400            "mean inter-key gap {mean}ms outside 80–180 band"
401        );
402    }
403
404    #[test]
405    fn human_emits_typos_over_long_text() {
406        // Run a long-enough text that the 1.5% typo rate produces at
407        // least one backspace in nearly every realization. Sample
408        // multiple seeds; assert that at least one shows a backspace.
409        let any_backspace = (0..16).any(|seed| {
410            let s = key_sequence(
411                &"the quick brown fox jumps over the lazy dog ".repeat(8),
412                InputMode::Human,
413                seed,
414            );
415            s.iter().any(|step| step.key.code == 0x2A)
416        });
417        assert!(any_backspace, "no typos in 16 long-text realizations");
418    }
419
420    #[test]
421    fn human_steps_monotonic_in_time() {
422        let steps = key_sequence("hello world", InputMode::Human, 42);
423        for w in steps.windows(2) {
424            assert!(
425                w[0].at <= w[1].at,
426                "non-monotonic: {:?} then {:?}",
427                w[0],
428                w[1]
429            );
430        }
431    }
432}