use rand::{rngs::StdRng, Rng};
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Keystroke {
pub ch: char,
pub hold_ms: u16,
pub gap_ms_before: u16,
pub is_correction: bool,
}
const HOT_BIGRAMS: &[(&str, u16, u16)] = &[
("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),
("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),
];
pub const COLD_BIGRAM_GAP_MIN_MS: u16 = 180;
pub const COLD_BIGRAM_GAP_MAX_MS: u16 = 320;
pub const SPACE_GAP_MIN_MS: u16 = 90;
pub const SPACE_GAP_MAX_MS: u16 = 170;
pub const DIGIT_GAP_MIN_MS: u16 = 200;
pub const DIGIT_GAP_MAX_MS: u16 = 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)
}
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)
}
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()])
}
#[derive(Debug, Clone, Copy)]
pub struct TypingPlan {
pub typo_probability: f32,
pub thinking_pause_probability: f32,
}
impl Default for TypingPlan {
fn default() -> Self {
Self {
typo_probability: 0.015,
thinking_pause_probability: 0.10,
}
}
}
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() {
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}', hold_ms: rng.gen_range(35..=70),
gap_ms_before: rng.gen_range(80..=250),
is_correction: true,
});
}
}
let (h_lo, h_hi) = hold_envelope(ch);
let gap = if out.is_empty() {
0
} else if let Some(last) = out.last() {
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() {
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, thinking_pause_probability: 0.0,
};
let keys = plan_keystrokes("ab", plan, &mut rng);
assert_eq!(keys.len(), 6);
assert!(keys[0].is_correction); assert_eq!(keys[1].ch, '\u{0008}'); assert!(keys[1].is_correction);
assert!(!keys[2].is_correction); assert_eq!(keys[2].ch, 'a');
assert!(keys[3].is_correction); 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);
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);
assert_eq!(keys[0].gap_ms_before, 0);
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);
assert_eq!(keys[0].gap_ms_before, 0);
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,
);
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);
assert_eq!(keys.len(), 3);
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,
);
}
}