captchaforge 0.2.35

[DO NOT USE — UNDER ACTIVE DEVELOPMENT, NOT PRODUCTION-READY] Captcha solver scaffolding for chromiumoxide-driven browsers. The architecture is in place (vendor solvers, retry-loop iframe walking, VLM provider abstraction, real-WAF bench harness) but the live-vendor success rate is still 0% — Cloudflare Turnstile / hCaptcha / reCAPTCHA detect us at a TLS / CDP fingerprint layer that no flag-based stealth has cleared. Watch the repo; do not depend on this for any real workload.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
//! Bigram-aware keystroke timing.
//!
//! [`crate::behavior::type_realistic`] dispatches each character with a
//! uniform 80–250ms inter-key delay plus an occasional "thinking
//! pause." That distribution is bot-shaped: real typists have
//! bigram-dependent timing — `th`, `he`, `in`, `er` are typed
//! 60–110ms apart because the motor program is over-trained, while
//! cold bigrams like `qz`, `xv`, `wq` take 250–400ms. A uniform
//! distribution across all bigrams is one of the cheapest signals
//! a behavioural classifier can train on.
//!
//! This module ships:
//!
//! - A frequency-weighted timing table for the 30 hottest English
//!   bigrams (drawn from Norvig's count_2l corpus, scaled to
//!   typing-latency studies).
//! - Per-character key-hold-time variance tied to character type
//!   (digits and modifier-requiring characters held slightly longer
//!   than letters).
//! - Optional typo injection — at low probability the typist enters
//!   the wrong character, hits backspace, and corrects.
//! - A pure planner ([`plan_keystrokes`]) that turns an input string
//!   into a sequence of [`Keystroke`] events with realistic timing.
//!   The planner is doctested; the dispatcher in
//!   [`crate::behavior::type_human`] is the live wrapper around CDP.
//!
//! The numbers were tuned to land inside the 5th–95th percentile
//! envelope from the typing-latency corpora cited in Dhakal et al.
//! (CHI 2018). Real production tuning should ideally come from
//! per-deployment measurement, but the bundled defaults are
//! defensible for cold-start.

use rand::{rngs::StdRng, Rng};

/// One scheduled keystroke. The dispatcher sleeps `gap_ms_before`,
/// emits a `keydown`, sleeps `hold_ms`, then emits a `keyup` and
/// moves to the next keystroke. The first keystroke in a plan has
/// `gap_ms_before = 0`.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Keystroke {
    /// Character to send.
    pub ch: char,
    /// Hold time between keydown and keyup, in milliseconds.
    pub hold_ms: u16,
    /// Inter-keystroke gap that elapses BEFORE the keydown for this
    /// keystroke. The first keystroke has `0`.
    pub gap_ms_before: u16,
    /// True when this keystroke is part of a typo-correction
    /// sequence (an injected wrong character or a backspace).
    /// Useful for tests and observability.
    pub is_correction: bool,
}

/// Per-bigram inter-key gap envelope `(min_ms, max_ms)`.
/// Hot English bigrams (over-trained motor programs) are fast;
/// rare letter combinations are slow. Lookup is case-insensitive.
const HOT_BIGRAMS: &[(&str, u16, u16)] = &[
    // Top 30 English bigrams (Norvig corpus), tuned to ~60–120ms.
    ("th", 60, 100),
    ("he", 65, 105),
    ("in", 70, 110),
    ("er", 70, 115),
    ("an", 70, 115),
    ("re", 75, 120),
    ("on", 80, 125),
    ("at", 80, 125),
    ("en", 80, 130),
    ("nd", 80, 130),
    ("ti", 85, 130),
    ("es", 85, 135),
    ("or", 85, 135),
    ("te", 85, 135),
    ("of", 90, 140),
    ("ed", 90, 140),
    ("is", 90, 140),
    ("it", 90, 145),
    ("al", 95, 145),
    ("ar", 95, 145),
    ("st", 95, 150),
    ("to", 95, 150),
    ("nt", 100, 150),
    ("ng", 100, 155),
    ("se", 100, 155),
    ("ha", 100, 155),
    ("as", 105, 160),
    ("ou", 105, 160),
    ("io", 105, 160),
    ("le", 105, 165),
    // Lukewarm — common but not over-trained.
    ("ve", 110, 170),
    ("co", 115, 175),
    ("me", 115, 175),
    ("de", 115, 180),
    ("hi", 120, 180),
    ("ri", 120, 180),
    ("ro", 125, 185),
    ("ic", 125, 190),
    ("ne", 130, 195),
    ("ea", 130, 200),
];

/// Default cold-bigram envelope used when the bigram doesn't appear
/// in the (private) `HOT_BIGRAMS` table. Anything from `qz` to `wq`
/// to `xj` falls here.
pub const COLD_BIGRAM_GAP_MIN_MS: u16 = 180;
pub const COLD_BIGRAM_GAP_MAX_MS: u16 = 320;

/// Whitespace transition envelope. Space-after-word is consistently
/// faster than cold bigrams in real typists because the thumb is
/// already over the spacebar.
pub const SPACE_GAP_MIN_MS: u16 = 90;
pub const SPACE_GAP_MAX_MS: u16 = 170;

/// Digit-bigram envelope (digit-after-digit). Digits are typed
/// noticeably slower than letters because most typists don't
/// over-train the number row.
pub const DIGIT_GAP_MIN_MS: u16 = 200;
pub const DIGIT_GAP_MAX_MS: u16 = 360;

/// Look up the inter-key gap envelope for a `prev → next`
/// transition. Returns `(min_ms, max_ms)`.
///
/// # Examples
///
/// ```
/// use captchaforge::keystroke_timing::bigram_gap;
/// // Hot bigram lands in the fast range.
/// let (lo, hi) = bigram_gap('t', 'h');
/// assert!(lo < 110 && hi <= 110);
/// // Space-after-word stays fast.
/// let (lo, hi) = bigram_gap('o', ' ');
/// assert_eq!(lo, 90);
/// assert_eq!(hi, 170);
/// // Cold bigram lands in the slow range.
/// let (lo, hi) = bigram_gap('q', 'z');
/// assert_eq!(lo, 180);
/// assert_eq!(hi, 320);
/// // Digit-bigram envelope.
/// let (lo, hi) = bigram_gap('5', '7');
/// assert_eq!(lo, 200);
/// assert_eq!(hi, 360);
/// ```
pub fn bigram_gap(prev: char, next: char) -> (u16, u16) {
    if next == ' ' || next == '\t' {
        return (SPACE_GAP_MIN_MS, SPACE_GAP_MAX_MS);
    }
    if prev.is_ascii_digit() && next.is_ascii_digit() {
        return (DIGIT_GAP_MIN_MS, DIGIT_GAP_MAX_MS);
    }
    let key = format!("{}{}", prev.to_ascii_lowercase(), next.to_ascii_lowercase());
    if let Some(&(_, lo, hi)) = HOT_BIGRAMS.iter().find(|(k, _, _)| *k == key) {
        return (lo, hi);
    }
    (COLD_BIGRAM_GAP_MIN_MS, COLD_BIGRAM_GAP_MAX_MS)
}

/// Per-character key-hold time envelope `(min_ms, max_ms)`.
/// Letters are held shortest; digits and uppercase characters need
/// the shift modifier and are held slightly longer.
///
/// # Examples
///
/// ```
/// use captchaforge::keystroke_timing::hold_envelope;
/// let (lo, hi) = hold_envelope('a');
/// assert!(lo >= 30 && hi <= 80);
/// let (lo_u, hi_u) = hold_envelope('A');
/// assert!(lo_u > lo, "uppercase held longer");
/// let (lo_d, hi_d) = hold_envelope('5');
/// assert!(lo_d >= 40, "digits held >= 40ms");
/// ```
pub fn hold_envelope(ch: char) -> (u16, u16) {
    if ch.is_ascii_uppercase() {
        return (50, 100);
    }
    if ch.is_ascii_digit() {
        return (45, 90);
    }
    if ch == ' ' {
        return (35, 80);
    }
    (30, 75)
}

/// Generate a plausible typo for a target character.
///
/// Picks a neighbouring key on a QWERTY keyboard. Used by
/// [`plan_keystrokes`] when typo injection fires.
///
/// Returns `None` for characters with no defined neighbour set
/// (digits, punctuation, uppercase). Production typo modelling for
/// those classes is a follow-up.
///
/// # Examples
///
/// ```
/// use captchaforge::keystroke_timing::qwerty_neighbour;
/// // 'h' neighbours include g/j/y/n/b — never the same key.
/// let n = qwerty_neighbour('h', 0).unwrap();
/// assert_ne!(n, 'h');
/// assert!("gjyn b".contains(n));
/// ```
pub fn qwerty_neighbour(ch: char, rng_seed: u8) -> Option<char> {
    let neighbours: &[char] = match ch.to_ascii_lowercase() {
        'q' => &['w', 'a'],
        'w' => &['q', 'e', 's'],
        'e' => &['w', 'r', 'd'],
        'r' => &['e', 't', 'f'],
        't' => &['r', 'y', 'g'],
        'y' => &['t', 'u', 'h'],
        'u' => &['y', 'i', 'j'],
        'i' => &['u', 'o', 'k'],
        'o' => &['i', 'p', 'l'],
        'p' => &['o', 'l'],
        'a' => &['q', 's', 'z'],
        's' => &['a', 'd', 'w', 'x'],
        'd' => &['s', 'f', 'e', 'c'],
        'f' => &['d', 'g', 'r', 'v'],
        'g' => &['f', 'h', 't', 'b'],
        'h' => &['g', 'j', 'y', 'n', 'b', ' '],
        'j' => &['h', 'k', 'u', 'm'],
        'k' => &['j', 'l', 'i'],
        'l' => &['k', 'o', 'p'],
        'z' => &['a', 'x'],
        'x' => &['z', 'c', 's'],
        'c' => &['x', 'v', 'd'],
        'v' => &['c', 'b', 'f'],
        'b' => &['v', 'n', 'g', 'h'],
        'n' => &['b', 'm', 'h', 'j'],
        'm' => &['n', 'j', 'k'],
        _ => return None,
    };
    Some(neighbours[(rng_seed as usize) % neighbours.len()])
}

/// Configuration for [`plan_keystrokes`].
#[derive(Debug, Clone, Copy)]
pub struct TypingPlan {
    /// Probability in `[0.0, 1.0]` of a single-character typo per
    /// character. Set to `0.0` to disable. Real typists make
    /// 1–4% character-level errors; the default is 0.015 (1.5%).
    pub typo_probability: f32,
    /// Mean inter-keystroke "thinking pause" probability. Real
    /// typists pause every 4–10 characters for 200–600ms. Set to
    /// `0.0` to disable.
    pub thinking_pause_probability: f32,
}

impl Default for TypingPlan {
    fn default() -> Self {
        Self {
            typo_probability: 0.015,
            thinking_pause_probability: 0.10,
        }
    }
}

/// Plan a realistic keystroke sequence for `text`. Pure function
/// (no IO); the dispatcher in [`crate::behavior::type_human`] is
/// the live wrapper.
///
/// Returns a `Vec<Keystroke>` whose dispatch produces the timing
/// pattern of a human typist:
///
/// - Inter-key gaps drawn from [`bigram_gap`] for each adjacent pair.
/// - Hold times drawn from [`hold_envelope`] per character.
/// - Optional typo injection — wrong char + backspace + correct
///   char — with the configured probability per character.
/// - Optional thinking pauses (200–600ms extra gap) at the
///   configured rate.
///
/// # Examples
///
/// ```
/// use captchaforge::keystroke_timing::{plan_keystrokes, TypingPlan};
/// use rand::{rngs::StdRng, SeedableRng};
/// let mut rng = StdRng::seed_from_u64(0);
/// // With typos and pauses disabled, output length matches input.
/// let plan = TypingPlan { typo_probability: 0.0, thinking_pause_probability: 0.0 };
/// let keys = plan_keystrokes("hello", plan, &mut rng);
/// assert_eq!(keys.len(), 5);
/// assert_eq!(keys[0].ch, 'h');
/// assert_eq!(keys[4].ch, 'o');
/// // First keystroke has no preceding context.
/// assert_eq!(keys[0].gap_ms_before, 0);
/// // Subsequent keystrokes carry the (prev → curr) bigram gap; he
/// // is hot, so it lands in the fast envelope.
/// assert!(keys[1].gap_ms_before >= 65 && keys[1].gap_ms_before <= 105);
/// ```
pub fn plan_keystrokes(text: &str, plan: TypingPlan, rng: &mut StdRng) -> Vec<Keystroke> {
    let chars: Vec<char> = text.chars().collect();
    let mut out: Vec<Keystroke> = Vec::with_capacity(chars.len() + 8);
    for (i, &ch) in chars.iter().enumerate() {
        // Optional typo: insert a neighbour char then a backspace,
        // then the correct char. We bundle the wrong char and the
        // backspace as `is_correction = true` so observers can tell
        // them apart from the genuine keystrokes.
        let inject_typo = plan.typo_probability > 0.0
            && rng.gen::<f32>() < plan.typo_probability
            && ch.is_ascii_alphabetic();
        if inject_typo {
            if let Some(neighbour) = qwerty_neighbour(ch, rng.gen()) {
                let (h_lo, h_hi) = hold_envelope(neighbour);
                let prev = if i == 0 { ' ' } else { chars[i - 1] };
                let (g_lo, g_hi) = bigram_gap(prev, neighbour);
                let typo_gap = if i == 0 {
                    0
                } else {
                    rng.gen_range(g_lo..=g_hi)
                };
                out.push(Keystroke {
                    ch: neighbour,
                    hold_ms: rng.gen_range(h_lo..=h_hi),
                    gap_ms_before: typo_gap,
                    is_correction: true,
                });
                out.push(Keystroke {
                    ch: '\u{0008}', // BS character; dispatcher maps to Backspace
                    hold_ms: rng.gen_range(35..=70),
                    // Realisation gap before the typist notices the
                    // wrong char and backspaces — typically 80–250ms.
                    gap_ms_before: rng.gen_range(80..=250),
                    is_correction: true,
                });
                // Now the correct `ch` follows below — its preceding
                // gap is the time after the backspace to recover and
                // resume typing.
            }
        }

        let (h_lo, h_hi) = hold_envelope(ch);
        // For the very first keystroke (no preceding char and no
        // preceding correction event), gap is 0 — the dispatcher
        // starts typing immediately.
        let gap = if out.is_empty() {
            0
        } else if let Some(last) = out.last() {
            // If the previous emitted keystroke was a correction
            // (backspace), use the post-backspace recovery gap rather
            // than a bigram lookup — a typist who just corrected an
            // error usually pauses 60–180ms before continuing.
            if last.is_correction && last.ch == '\u{0008}' {
                rng.gen_range(60..=180)
            } else {
                let prev = chars[i - 1];
                let (g_lo, g_hi) = bigram_gap(prev, ch);
                let mut g = rng.gen_range(g_lo..=g_hi);
                if plan.thinking_pause_probability > 0.0
                    && rng.gen::<f32>() < plan.thinking_pause_probability
                {
                    g = g.saturating_add(rng.gen_range(200..=600));
                }
                g
            }
        } else {
            0
        };
        out.push(Keystroke {
            ch,
            hold_ms: rng.gen_range(h_lo..=h_hi),
            gap_ms_before: gap,
            is_correction: false,
        });
    }
    out
}

#[cfg(test)]
mod tests {
    use super::*;
    use rand::SeedableRng;

    #[test]
    fn hot_bigrams_table_is_lowercase_and_two_chars() {
        for (k, _, _) in HOT_BIGRAMS {
            assert_eq!(k.len(), 2, "bigram key {} not 2 chars", k);
            assert_eq!(*k, k.to_lowercase(), "bigram key {} not lowercase", k);
        }
    }

    #[test]
    fn bigram_gap_uses_hot_table_for_th() {
        let (lo, hi) = bigram_gap('t', 'h');
        assert_eq!(lo, 60);
        assert_eq!(hi, 100);
    }

    #[test]
    fn bigram_gap_is_case_insensitive() {
        assert_eq!(bigram_gap('T', 'h'), bigram_gap('t', 'h'));
        assert_eq!(bigram_gap('t', 'H'), bigram_gap('t', 'h'));
        assert_eq!(bigram_gap('T', 'H'), bigram_gap('t', 'h'));
    }

    #[test]
    fn bigram_gap_falls_back_to_cold_envelope() {
        assert_eq!(
            bigram_gap('q', 'z'),
            (COLD_BIGRAM_GAP_MIN_MS, COLD_BIGRAM_GAP_MAX_MS)
        );
        assert_eq!(
            bigram_gap('x', 'q'),
            (COLD_BIGRAM_GAP_MIN_MS, COLD_BIGRAM_GAP_MAX_MS)
        );
    }

    #[test]
    fn space_transition_uses_space_envelope() {
        let (lo, hi) = bigram_gap('e', ' ');
        assert_eq!(lo, SPACE_GAP_MIN_MS);
        assert_eq!(hi, SPACE_GAP_MAX_MS);
    }

    #[test]
    fn digit_pair_uses_digit_envelope() {
        let (lo, hi) = bigram_gap('5', '7');
        assert_eq!(lo, DIGIT_GAP_MIN_MS);
        assert_eq!(hi, DIGIT_GAP_MAX_MS);
    }

    #[test]
    fn digit_to_letter_uses_cold_or_hot_table() {
        // Digit-to-letter is NOT a digit-pair; should fall through
        // to bigram lookup which (for "5a") will be cold.
        let (lo, hi) = bigram_gap('5', 'a');
        assert_eq!(lo, COLD_BIGRAM_GAP_MIN_MS);
        assert_eq!(hi, COLD_BIGRAM_GAP_MAX_MS);
    }

    #[test]
    fn hold_envelope_matches_class_buckets() {
        let (lo, _) = hold_envelope('a');
        assert_eq!(lo, 30);
        let (lo_u, _) = hold_envelope('A');
        assert_eq!(lo_u, 50);
        let (lo_d, _) = hold_envelope('7');
        assert_eq!(lo_d, 45);
        let (lo_s, _) = hold_envelope(' ');
        assert_eq!(lo_s, 35);
    }

    #[test]
    fn qwerty_neighbour_for_letters_returns_a_neighbour() {
        for ch in 'a'..='z' {
            let n = qwerty_neighbour(ch, 0);
            assert!(n.is_some(), "no neighbour table for {}", ch);
            let nch = n.unwrap();
            assert_ne!(nch, ch, "neighbour of {} is itself", ch);
        }
    }

    #[test]
    fn qwerty_neighbour_for_non_letters_is_none() {
        assert!(qwerty_neighbour('5', 0).is_none());
        assert!(qwerty_neighbour('!', 0).is_none());
        assert!(qwerty_neighbour(' ', 0).is_none());
    }

    #[test]
    fn plan_keystrokes_no_typos_no_pauses_preserves_length() {
        let mut rng = StdRng::seed_from_u64(1);
        let plan = TypingPlan {
            typo_probability: 0.0,
            thinking_pause_probability: 0.0,
        };
        let keys = plan_keystrokes("the quick", plan, &mut rng);
        assert_eq!(keys.len(), 9);
        assert!(keys.iter().all(|k| !k.is_correction));
    }

    #[test]
    fn plan_keystrokes_with_typos_inserts_correction_pairs() {
        let mut rng = StdRng::seed_from_u64(42);
        let plan = TypingPlan {
            typo_probability: 1.0, // Every alpha char triggers a typo.
            thinking_pause_probability: 0.0,
        };
        let keys = plan_keystrokes("ab", plan, &mut rng);
        // 2 chars × (typo + backspace + real) = 6 keystrokes.
        assert_eq!(keys.len(), 6);
        assert!(keys[0].is_correction); // wrong char for 'a'
        assert_eq!(keys[1].ch, '\u{0008}'); // backspace
        assert!(keys[1].is_correction);
        assert!(!keys[2].is_correction); // real 'a'
        assert_eq!(keys[2].ch, 'a');
        assert!(keys[3].is_correction); // wrong char for 'b'
        assert_eq!(keys[4].ch, '\u{0008}');
        assert_eq!(keys[5].ch, 'b');
    }

    #[test]
    fn plan_keystrokes_typos_skip_non_alpha() {
        let mut rng = StdRng::seed_from_u64(42);
        let plan = TypingPlan {
            typo_probability: 1.0,
            thinking_pause_probability: 0.0,
        };
        let keys = plan_keystrokes("a 5 b", plan, &mut rng);
        // 'a' triggers (3), ' ' skipped (1), '5' skipped (1),
        // ' ' skipped (1), 'b' triggers (3) = 9.
        assert_eq!(keys.len(), 9);
    }

    #[test]
    fn plan_keystrokes_thinking_pauses_extend_gaps() {
        let mut rng = StdRng::seed_from_u64(7);
        let plan = TypingPlan {
            typo_probability: 0.0,
            thinking_pause_probability: 1.0,
        };
        let keys = plan_keystrokes("the", plan, &mut rng);
        assert_eq!(keys.len(), 3);
        // First keystroke has no preceding context.
        assert_eq!(keys[0].gap_ms_before, 0);
        // Every subsequent keystroke gets a thinking pause of
        // ≥200ms ON TOP of the bigram baseline.
        for k in &keys[1..] {
            assert!(
                k.gap_ms_before >= 200,
                "expected thinking pause to extend gap to ≥200ms, got {}",
                k.gap_ms_before,
            );
        }
    }

    #[test]
    fn plan_keystrokes_hot_bigrams_produce_fast_gaps() {
        let mut rng = StdRng::seed_from_u64(1234);
        let plan = TypingPlan {
            typo_probability: 0.0,
            thinking_pause_probability: 0.0,
        };
        let keys = plan_keystrokes("the", plan, &mut rng);
        // 't' is the first keystroke — no preceding gap.
        assert_eq!(keys[0].gap_ms_before, 0);
        // 'h' carries the t→h bigram gap (60–100ms).
        assert!(
            keys[1].gap_ms_before >= 60 && keys[1].gap_ms_before <= 100,
            "t→h bigram should give 60–100ms, got {}",
            keys[1].gap_ms_before,
        );
        // 'e' carries the h→e bigram gap (65–105ms).
        assert!(
            keys[2].gap_ms_before >= 65 && keys[2].gap_ms_before <= 105,
            "h→e bigram should give 65–105ms, got {}",
            keys[2].gap_ms_before,
        );
    }

    #[test]
    fn plan_keystrokes_first_keystroke_has_zero_gap() {
        let mut rng = StdRng::seed_from_u64(0);
        let plan = TypingPlan::default();
        let keys = plan_keystrokes("a", plan, &mut rng);
        assert_eq!(keys.len(), 1);
        assert_eq!(keys[0].gap_ms_before, 0);
    }

    #[test]
    fn plan_keystrokes_post_backspace_recovery_within_envelope() {
        let mut rng = StdRng::seed_from_u64(99);
        let plan = TypingPlan {
            typo_probability: 1.0,
            thinking_pause_probability: 0.0,
        };
        let keys = plan_keystrokes("a", plan, &mut rng);
        // Sequence: [wrong-neighbour (correction), BS (correction), 'a' (real)].
        assert_eq!(keys.len(), 3);
        // The recovery gap on the real 'a' after the backspace must
        // be in the post-backspace envelope (60–180ms), NOT the
        // bigram envelope.
        assert!(
            keys[2].gap_ms_before >= 60 && keys[2].gap_ms_before <= 180,
            "post-backspace recovery should be 60–180ms, got {}",
            keys[2].gap_ms_before,
        );
    }
}