use rand::{Rng, RngExt}; use std::collections::{HashMap, HashSet}; const PROB_ERROR: f64 = 0.04; const PROB_SWAP_ERROR: f64 = 0.015; const PROB_NOTICE_ERROR: f64 = 0.85; const SPEED_BOOST_COMMON_WORD: f64 = 0.6; const SPEED_PENALTY_COMPLEX_WORD: f64 = 1.3; const SPEED_BOOST_CLOSE_KEYS: f64 = 0.5; const SPEED_BOOST_BIGRAM: f64 = 0.4; const TIME_KEYSTROKE_STD: f64 = 0.03; const TIME_BACKSPACE_MEAN: f64 = 0.12; const TIME_BACKSPACE_STD: f64 = 0.02; const TIME_REACTION_MEAN: f64 = 0.35; const TIME_REACTION_STD: f64 = 0.1; const TIME_UPPERCASE_PENALTY: f64 = 0.2; const TIME_SPACE_PAUSE_MEAN: f64 = 0.25; const TIME_SPACE_PAUSE_STD: f64 = 0.05; const FATIGUE_FACTOR: f64 = 1.0005; const AVG_WORD_LENGTH: f64 = 5.0; const WPM_STD: f64 = 10.0; const DEFAULT_WPM: f64 = 80.0; #[derive(Debug, Clone)] pub enum TypingAction { Char(char), Backspace, } #[derive(Debug, Clone)] pub struct TypingEvent { pub time: f64, pub action: TypingAction, } struct KeyboardLayout { pos_map: HashMap, grid: Vec>, } impl KeyboardLayout { fn new() -> Self { let grid: Vec> = vec![ "`1234567890-=".chars().collect(), "qwertyuiop[]\\".chars().collect(), "asdfghjkl;'".chars().collect(), "zxcvbnm,./".chars().collect(), ]; let mut pos_map = HashMap::new(); for (r, row) in grid.iter().enumerate() { for (c, &ch) in row.iter().enumerate() { pos_map.insert(ch, (r, c)); } } KeyboardLayout { pos_map, grid } } fn has_key(&self, ch: char) -> bool { self.pos_map.contains_key(&ch.to_ascii_lowercase()) } fn get_neighbor_keys(&self, ch: char) -> Vec { let ch = ch.to_ascii_lowercase(); let (r, c) = match self.pos_map.get(&ch) { Some(&pos) => pos, None => return vec![], }; let deltas: [(i32, i32); 8] = [ (-1, -1), (-1, 0), (-1, 1), (0, -1), (0, 1), (1, -1), (1, 0), (1, 1), ]; let mut neighbors = Vec::new(); for (dr, dc) in &deltas { let nr = r as i32 + dr; let nc = c as i32 + dc; if nr >= 0 && (nr as usize) < self.grid.len() { let row = &self.grid[nr as usize]; if nc >= 0 && (nc as usize) < row.len() { neighbors.push(row[nc as usize]); } } } neighbors } fn get_distance(&self, c1: char, c2: char) -> f64 { let c1 = c1.to_ascii_lowercase(); let c2 = c2.to_ascii_lowercase(); match (self.pos_map.get(&c1), self.pos_map.get(&c2)) { (Some(&(r1, c1p)), Some(&(r2, c2p))) => { let dr = r1 as f64 - r2 as f64; let dc = c1p as f64 - c2p as f64; (dr * dr + dc * dc).sqrt() } _ => 4.0, } } fn get_random_neighbor(&self, ch: char, rng: &mut impl Rng) -> char { let neighbors = self.get_neighbor_keys(ch); if neighbors.is_empty() { let flat: Vec = self.grid.iter().flat_map(|r| r.iter().copied()).collect(); flat[rng.random_range(0..flat.len())] } else { neighbors[rng.random_range(0..neighbors.len())] } } } fn normal_sample(rng: &mut impl Rng, mean: f64, std_dev: f64) -> f64 { // Box-Muller transform let u1: f64 = rng.random::().max(1e-10); let u2: f64 = rng.random::(); let z = (-2.0_f64 * u1.ln()).sqrt() * (2.0_f64 * std::f64::consts::PI * u2).cos(); mean + std_dev * z } static COMMON_WORDS: &[&str] = &[ "the", "be", "to", "of", "and", "a", "in", "that", "have", "it", "for", "not", "on", "with", "he", "as", "you", "do", "at", "this", "but", "his", "by", "from", "they", "we", "say", "her", "she", "or", "an", "will", "my", "one", "all", "would", "there", "their", "what", "so", "up", "out", "if", "about", "who", "get", "which", "go", "me", "when", "make", "can", "like", "time", "no", "just", "him", "know", "take", "people", "into", "year", "your", "good", "some", "could", "them", "see", "other", "than", "then", "now", "look", "only", "come", "its", "over", "think", "also", "back", "after", "use", "two", "how", "our", "work", "first", "well", "way", "even", "new", "want", "because", ]; static COMMON_BIGRAMS: &[&str] = &[ "th", "he", "in", "er", "an", "re", "on", "at", "en", "nd", "ti", "es", "or", "te", "of", "ed", "is", "it", "al", "ar", "st", "to", "nt", "ng", "se", "ha", "as", "ou", "io", "le", "ve", "co", "me", "de", "hi", "ri", "ro", "ic", "ne", "ea", "ra", "ce", ]; fn get_word_difficulty(word: &str) -> &'static str { let lower = word.to_lowercase(); let trimmed = lower.trim_matches(|c: char| matches!(c, '.' | ',' | '!' | '?' | ';' | ':')); let common_set: HashSet<&str> = COMMON_WORDS.iter().copied().collect(); if common_set.contains(trimmed) { return "common"; } let is_long = trimmed.len() > 8; let has_complex = trimmed.chars().any(|c| matches!(c, 'z' | 'x' | 'q' | 'j')); if is_long || has_complex { return "complex"; } "normal" } fn is_common_bigram(c1: char, c2: char) -> bool { let bigram = format!("{}{}", c1.to_ascii_lowercase(), c2.to_ascii_lowercase()); let bigram_set: HashSet<&str> = COMMON_BIGRAMS.iter().copied().collect(); bigram_set.contains(bigram.as_str()) } pub struct MarkovTyper { target: Vec, current: Vec, keyboard: KeyboardLayout, base_keystroke_time: f64, fatigue_multiplier: f64, mental_cursor_pos: usize, last_char_typed: Option, total_time: f64, last_was_backspace: bool, rng: rand::rngs::ThreadRng, } impl MarkovTyper { pub fn new(text: &str, wpm: Option) -> Self { let mut rng = rand::rng(); let target_wpm = wpm.unwrap_or(DEFAULT_WPM); let session_wpm = normal_sample(&mut rng, target_wpm, WPM_STD).max(10.0); let base_keystroke_time = 60.0 / (session_wpm * AVG_WORD_LENGTH); MarkovTyper { target: text.chars().collect(), current: Vec::new(), keyboard: KeyboardLayout::new(), base_keystroke_time, fatigue_multiplier: 1.0, mental_cursor_pos: 0, last_char_typed: None, total_time: 0.0, last_was_backspace: false, rng, } } fn get_current_word(&self) -> Option { if self.mental_cursor_pos >= self.target.len() { return None; } let mut start = self.mental_cursor_pos; while start > 0 && self.target[start - 1] != ' ' { start -= 1; } let mut end = self.mental_cursor_pos; while end < self.target.len() && self.target[end] != ' ' { end += 1; } Some(self.target[start..end].iter().collect()) } fn calculate_keystroke_time(&mut self, ch: char) -> f64 { let mut time = self.base_keystroke_time * self.fatigue_multiplier; if let Some(word) = self.get_current_word() { match get_word_difficulty(&word) { "common" => time *= SPEED_BOOST_COMMON_WORD, "complex" => time *= SPEED_PENALTY_COMPLEX_WORD, _ => {} } } if let Some(last) = self.last_char_typed { if is_common_bigram(last, ch) { time *= SPEED_BOOST_BIGRAM; } else { let dist = self.keyboard.get_distance(last, ch); if dist > 0.0 && dist < 2.0 { time *= SPEED_BOOST_CLOSE_KEYS; } else if dist > 4.0 { time *= 1.2; } } } if ch == ' ' { time += normal_sample(&mut self.rng, TIME_SPACE_PAUSE_MEAN, TIME_SPACE_PAUSE_STD); } else if ch.is_uppercase() { time += TIME_UPPERCASE_PENALTY; } let dt = normal_sample(&mut self.rng, time, TIME_KEYSTROKE_STD); dt.max(0.02) } fn step(&mut self) -> Option { if self.current == self.target { return None; } // Find first error position let mut first_error_pos = self.target.len(); let min_len = self.current.len().min(self.target.len()); for i in 0..min_len { if self.current[i] != self.target[i] { first_error_pos = i; break; } } if self.current.len() > self.target.len() && first_error_pos == self.target.len() { first_error_pos = self.target.len(); } // Error correction if first_error_pos < self.current.len() { let mut should_correct = false; if self.last_was_backspace || self.mental_cursor_pos >= self.target.len() { should_correct = true; } else if !self.current.is_empty() { let last_char = *self.current.last().unwrap(); let distance = self.current.len() - first_error_pos; if " \n\t.,;!?:()[]{}\"'<>".contains(last_char) { should_correct = true; } else if distance >= 2 { if self.rng.random::() < 0.8 { should_correct = true; } } else if distance == 1 && self.rng.random::() < PROB_NOTICE_ERROR { should_correct = true; } } if should_correct { if !self.last_was_backspace { let dt = normal_sample(&mut self.rng, TIME_REACTION_MEAN, TIME_REACTION_STD).max(0.1); self.total_time += dt; } let dt = normal_sample(&mut self.rng, TIME_BACKSPACE_MEAN, TIME_BACKSPACE_STD); self.total_time += dt; self.current.pop(); self.mental_cursor_pos = self.current.len(); self.last_was_backspace = true; return Some(TypingEvent { time: self.total_time, action: TypingAction::Backspace, }); } } self.last_was_backspace = false; if self.mental_cursor_pos > self.current.len() { self.mental_cursor_pos = self.current.len(); } if self.mental_cursor_pos >= self.target.len() { return None; } let char_intended = self.target[self.mental_cursor_pos]; self.fatigue_multiplier *= FATIGUE_FACTOR; // Non-QWERTY characters (CJK, Cyrillic, etc.) are composed via IME — // skip error simulation entirely, just apply realistic timing. let on_keyboard = self.keyboard.has_key(char_intended); // Swap error (only for characters on the physical keyboard) if on_keyboard && self.mental_cursor_pos + 1 < self.target.len() { let char_after = self.target[self.mental_cursor_pos + 1]; if char_after != ' ' && char_after != char_intended && self.keyboard.has_key(char_after) && self.rng.random::() < PROB_SWAP_ERROR { let dt = self.calculate_keystroke_time(char_after); self.total_time += dt; self.current.push(char_after); self.last_char_typed = Some(char_after); self.mental_cursor_pos += 1; return Some(TypingEvent { time: self.total_time, action: TypingAction::Char(char_after), }); } } // Normal typing with possible error (errors only for QWERTY characters) let typed_char = if on_keyboard { let mut current_prob_error = PROB_ERROR; if let Some(word) = self.get_current_word() { match get_word_difficulty(&word) { "complex" => current_prob_error *= 1.5, "common" => current_prob_error *= 0.5, _ => {} } } if self.rng.random::() < current_prob_error { self .keyboard .get_random_neighbor(char_intended, &mut self.rng) } else { char_intended } } else { char_intended }; let dt = self.calculate_keystroke_time(typed_char); self.total_time += dt; self.current.push(typed_char); self.last_char_typed = Some(typed_char); self.mental_cursor_pos += 1; Some(TypingEvent { time: self.total_time, action: TypingAction::Char(typed_char), }) } pub fn run(mut self) -> Vec { let max_steps = self.target.len() * 10; let mut events = Vec::new(); let mut steps = 0; while let Some(event) = self.step() { events.push(event); steps += 1; if steps > max_steps { break; } } events } } #[cfg(test)] mod tests { use super::*; #[test] fn test_generates_events() { let typer = MarkovTyper::new("hello", Some(60.0)); let events = typer.run(); assert!(!events.is_empty()); // Final text should be "hello" — verify by replaying let mut text = String::new(); for event in &events { match &event.action { TypingAction::Char(c) => text.push(*c), TypingAction::Backspace => { text.pop(); } } } assert_eq!(text, "hello"); } #[test] fn test_timing_increases() { let typer = MarkovTyper::new("test", Some(60.0)); let events = typer.run(); for window in events.windows(2) { assert!(window[1].time >= window[0].time); } } #[test] fn test_empty_text() { let typer = MarkovTyper::new("", Some(60.0)); let events = typer.run(); assert!(events.is_empty()); } #[test] fn test_chinese_text() { let input = "你好世界"; let typer = MarkovTyper::new(input, Some(60.0)); let events = typer.run(); let mut text = String::new(); for event in &events { match &event.action { TypingAction::Char(c) => text.push(*c), TypingAction::Backspace => { text.pop(); } } } assert_eq!(text, input); } #[test] fn test_russian_text() { let input = "Привет мир"; let typer = MarkovTyper::new(input, Some(60.0)); let events = typer.run(); let mut text = String::new(); for event in &events { match &event.action { TypingAction::Char(c) => text.push(*c), TypingAction::Backspace => { text.pop(); } } } assert_eq!(text, input); } #[test] fn test_japanese_text() { let input = "東京タワー"; let typer = MarkovTyper::new(input, Some(60.0)); let events = typer.run(); let mut text = String::new(); for event in &events { match &event.action { TypingAction::Char(c) => text.push(*c), TypingAction::Backspace => { text.pop(); } } } assert_eq!(text, input); } #[test] fn test_mixed_latin_and_cjk() { let input = "Hello 你好 world"; let typer = MarkovTyper::new(input, Some(60.0)); let events = typer.run(); let mut text = String::new(); for event in &events { match &event.action { TypingAction::Char(c) => text.push(*c), TypingAction::Backspace => { text.pop(); } } } assert_eq!(text, input); } }