use rand::{Rng, RngExt};
use rand_chacha::rand_core::SeedableRng;
use rand_chacha::ChaCha20Rng;
use rand_distr::{Distribution, LogNormal, Normal};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Handedness {
Right,
Left,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ScrollStyle {
Trackpad,
Wheel,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BehaviorProfile {
#[serde(default = "default_seed")]
pub seed: u64,
#[serde(default = "default_handedness")]
pub handedness: Handedness,
#[serde(default = "default_dpi")]
pub mouse_dpi: u16,
#[serde(default = "default_wpm_mean")]
pub typing_wpm_mean: f32,
#[serde(default = "default_wpm_sigma")]
pub typing_wpm_sigma: f32,
#[serde(default = "default_scroll")]
pub scroll_style: ScrollStyle,
#[serde(default = "default_fitts_b")]
pub fitts_b: f32,
}
fn default_seed() -> u64 {
rand::random::<u64>()
}
fn default_handedness() -> Handedness {
Handedness::Right
}
fn default_dpi() -> u16 {
1600
}
fn default_wpm_mean() -> f32 {
50.0
}
fn default_wpm_sigma() -> f32 {
15.0
}
fn default_scroll() -> ScrollStyle {
ScrollStyle::Trackpad
}
fn default_fitts_b() -> f32 {
166.0
}
impl Default for BehaviorProfile {
fn default() -> Self {
Self {
seed: default_seed(),
handedness: default_handedness(),
mouse_dpi: default_dpi(),
typing_wpm_mean: default_wpm_mean(),
typing_wpm_sigma: default_wpm_sigma(),
scroll_style: default_scroll(),
fitts_b: default_fitts_b(),
}
}
}
impl BehaviorProfile {
pub fn rng_for(&self, salt: u64) -> ChaCha20Rng {
let combined = self
.seed
.wrapping_mul(0x9E3779B97F4A7C15)
.wrapping_add(salt);
ChaCha20Rng::seed_from_u64(combined)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct MousePoint {
pub t_ms: f32,
pub x: f32,
pub y: f32,
}
pub fn mouse_trajectory(
from: (f32, f32),
to: (f32, f32),
target_w: f32,
profile: &BehaviorProfile,
) -> Vec<MousePoint> {
let mut rng = profile
.rng_for(((from.0 as u64) << 32) | (from.1 as u64) ^ ((to.0 as u64) << 16) ^ (to.1 as u64));
mouse_trajectory_with_rng(from, to, target_w, profile, &mut rng)
}
pub fn mouse_trajectory_with_rng<R: Rng>(
from: (f32, f32),
to: (f32, f32),
target_w: f32,
profile: &BehaviorProfile,
rng: &mut R,
) -> Vec<MousePoint> {
let dx = to.0 - from.0;
let dy = to.1 - from.1;
let distance = (dx * dx + dy * dy).sqrt().max(1.0);
let target_w = target_w.max(1.0);
let id_bits = ((distance / target_w) + 1.0).log2();
let n_strokes = ((1.3 * id_bits).round() as usize).clamp(2, 7);
let total_ms = 230.0 + profile.fitts_b * id_bits;
let mut amplitudes: Vec<f32> = Vec::with_capacity(n_strokes);
let primary = 0.85 * distance;
amplitudes.push(primary);
let remaining = distance - primary;
let per_corrective = remaining / (n_strokes - 1).max(1) as f32;
for _ in 1..n_strokes {
let jitter: f32 = Normal::new(0.0_f32, per_corrective * 0.15)
.unwrap()
.sample(rng);
amplitudes.push((per_corrective + jitter).max(1.0));
}
let sigma_dist = Normal::new(0.25_f32, 0.05).unwrap();
let mu_dist = Normal::new(-1.6_f32, 0.2).unwrap();
let onset_dist = LogNormal::new(90.0_f32.ln(), 0.3).unwrap();
let theta_dist = Normal::new(0.0_f32, 8.0_f32.to_radians()).unwrap();
let target_angle = dy.atan2(dx);
let mut strokes: Vec<Stroke> = Vec::with_capacity(n_strokes);
let mut t0 = 0.0_f32;
for (i, amp) in amplitudes.iter().enumerate() {
let sigma = sigma_dist.sample(rng).clamp(0.15, 0.40);
let mu = mu_dist.sample(rng);
let theta = if i == 0 {
target_angle + theta_dist.sample(rng)
} else {
target_angle + theta_dist.sample(rng) * 1.5
};
strokes.push(Stroke {
amplitude: *amp,
sigma,
mu,
t0,
theta,
});
t0 += onset_dist.sample(rng);
}
let dt_ms = 8.0_f32;
let n_samples = (total_ms / dt_ms).ceil() as usize + 1;
let mut points: Vec<MousePoint> = Vec::with_capacity(n_samples);
let tremor_dist = Normal::new(0.0_f32, 1.5).unwrap();
let mut tremor_x = 0.0_f32;
let mut tremor_y = 0.0_f32;
let tremor_alpha = 0.3_f32;
let (cum_x, cum_y) = (from.0, from.1);
for i in 0..n_samples {
let t = (i as f32) * dt_ms;
tremor_x = tremor_alpha * tremor_x + (1.0 - tremor_alpha) * tremor_dist.sample(rng);
tremor_y = tremor_alpha * tremor_y + (1.0 - tremor_alpha) * tremor_dist.sample(rng);
let x = cum_x + integrate_x(&strokes, t) + tremor_x;
let y = cum_y + integrate_y(&strokes, t) + tremor_y;
points.push(MousePoint { t_ms: t, x, y });
}
if points.len() >= 2 {
let n = points.len();
let last = &points[n - 1];
let res_x = to.0 - last.x;
let res_y = to.1 - last.y;
let tail = 15.min(n - 1);
let start = n - tail - 1;
for (k, p) in points.iter_mut().enumerate().skip(start) {
let u = (k - start) as f32 / tail as f32;
let s = u * u * (3.0 - 2.0 * u); p.x += res_x * s;
p.y += res_y * s;
}
if let Some(last) = points.last_mut() {
last.x = to.0;
last.y = to.1;
}
} else if let Some(last) = points.last_mut() {
last.x = to.0;
last.y = to.1;
}
points
}
struct Stroke {
amplitude: f32,
sigma: f32,
mu: f32,
t0: f32,
theta: f32,
}
fn integrate_x(strokes: &[Stroke], t: f32) -> f32 {
strokes
.iter()
.map(|s| {
let dt = t - s.t0;
if dt <= 0.0 {
return 0.0;
}
let z = (dt.ln() - s.mu) / (s.sigma * std::f32::consts::SQRT_2);
let cdf = 0.5 * (1.0 + erf(z));
s.amplitude * cdf * s.theta.cos()
})
.sum()
}
fn integrate_y(strokes: &[Stroke], t: f32) -> f32 {
strokes
.iter()
.map(|s| {
let dt = t - s.t0;
if dt <= 0.0 {
return 0.0;
}
let z = (dt.ln() - s.mu) / (s.sigma * std::f32::consts::SQRT_2);
let cdf = 0.5 * (1.0 + erf(z));
s.amplitude * cdf * s.theta.sin()
})
.sum()
}
fn erf(x: f32) -> f32 {
let sign = x.signum();
let x = x.abs();
let a1 = 0.254_829_6;
let a2 = -0.284_496_72;
let a3 = 1.421_413_8;
let a4 = -1.453_152_1;
let a5 = 1.061_405_4;
let p = 0.3275911;
let t = 1.0 / (1.0 + p * x);
let y = 1.0 - (((((a5 * t + a4) * t) + a3) * t + a2) * t + a1) * t * (-x * x).exp();
sign * y
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct KeystrokeTiming {
pub ch: char,
pub dwell_ms: f32,
pub flight_ms: f32,
}
fn bigram_ratio(prev: char, cur: char) -> f32 {
let key = (
prev.to_ascii_lowercase() as u8,
cur.to_ascii_lowercase() as u8,
);
match key {
(b't', b'h')
| (b'h', b'e')
| (b'i', b'n')
| (b'a', b'n')
| (b'o', b'n')
| (b'a', b't')
| (b'i', b's')
| (b'i', b't')
| (b'o', b'r')
| (b'o', b'f') => 0.7,
(b'e', b'd')
| (b'u', b'n')
| (b'r', b'e')
| (b'e', b'r')
| (b'e', b'n')
| (b'n', b'd')
| (b'e', b's')
| (b't', b'e')
| (b'a', b'l')
| (b'a', b'r') => 1.4,
(a, b) if a == b => 2.0,
_ => 1.0,
}
}
pub fn keystroke_timings(text: &str, profile: &BehaviorProfile) -> Vec<KeystrokeTiming> {
let mut rng = profile.rng_for(0xCAFEBABE ^ text.len() as u64);
keystroke_timings_with_rng(text, profile, &mut rng)
}
pub fn keystroke_timings_with_rng<R: Rng>(
text: &str,
profile: &BehaviorProfile,
rng: &mut R,
) -> Vec<KeystrokeTiming> {
let ms_per_char = 60_000.0 / (profile.typing_wpm_mean * 5.0);
let flight_median = (ms_per_char - 95.0).max(40.0);
let flight_dist = LogNormal::new(flight_median.ln(), 0.55).unwrap();
let dwell_dist = LogNormal::new(95.0_f32.ln(), 0.30).unwrap();
let mut out = Vec::with_capacity(text.len());
let mut prev_ch: Option<char> = None;
for ch in text.chars() {
let dwell = dwell_dist.sample(rng).clamp(40.0, 400.0);
let flight = if let Some(p) = prev_ch {
let ratio = bigram_ratio(p, ch);
(flight_dist.sample(rng) * ratio).clamp(20.0, 1000.0)
} else {
0.0
};
out.push(KeystrokeTiming {
ch,
dwell_ms: dwell,
flight_ms: flight,
});
prev_ch = Some(ch);
}
out
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct WheelTick {
pub t_ms: f32,
pub delta_y: f32,
pub mode: u32,
}
pub fn wheel_burst(target_dy: f32, profile: &BehaviorProfile) -> Vec<WheelTick> {
let mut rng = profile.rng_for(0xDEAD_BEEF ^ target_dy.to_bits() as u64);
wheel_burst_with_rng(target_dy, profile, &mut rng)
}
pub fn wheel_burst_with_rng<R: Rng>(
target_dy: f32,
profile: &BehaviorProfile,
rng: &mut R,
) -> Vec<WheelTick> {
let dir = if target_dy >= 0.0 { 1.0 } else { -1.0 };
let abs_dy = target_dy.abs().max(1.0);
match profile.scroll_style {
ScrollStyle::Trackpad => {
let v0 = LogNormal::new((abs_dy / 8.0).ln(), 0.3)
.unwrap()
.sample(rng);
let decay = 0.94 + rng.random_range(0.0_f32..0.04); let mut t = 0.0_f32;
let mut v = v0;
let mut ticks = Vec::new();
let mut accumulated = 0.0_f32;
while v > 0.5 && accumulated < abs_dy * 1.1 {
let step = (v.min(abs_dy - accumulated)).max(0.5);
ticks.push(WheelTick {
t_ms: t,
delta_y: step * dir,
mode: 0,
});
accumulated += step;
t += 16.0;
v *= decay;
}
ticks
}
ScrollStyle::Wheel => {
let notches = ((abs_dy / 100.0).round() as u32).max(1);
let interval_dist = LogNormal::new(180.0_f32.ln(), 0.4).unwrap();
let mut t = 0.0_f32;
let mut ticks = Vec::with_capacity(notches as usize);
for _ in 0..notches {
ticks.push(WheelTick {
t_ms: t,
delta_y: 100.0 * dir,
mode: 0,
});
t += interval_dist.sample(rng);
}
ticks
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use rand_chacha::rand_core::SeedableRng;
fn fixed_rng() -> ChaCha20Rng {
ChaCha20Rng::seed_from_u64(42)
}
#[test]
fn profile_defaults_are_sensible() {
let p = BehaviorProfile::default();
assert!((30.0..=80.0).contains(&p.typing_wpm_mean));
assert!((130.0..=220.0).contains(&p.fitts_b));
assert_eq!(p.handedness, Handedness::Right);
}
#[test]
fn rng_for_is_deterministic_per_seed() {
let p = BehaviorProfile::default();
let mut a = p.rng_for(123);
let mut b = p.rng_for(123);
assert_eq!(a.random::<u64>(), b.random::<u64>());
}
#[test]
fn rng_for_differs_across_salts() {
let p = BehaviorProfile::default();
let mut a = p.rng_for(1);
let mut b = p.rng_for(2);
assert_ne!(a.random::<u64>(), b.random::<u64>());
}
#[test]
fn mouse_trajectory_starts_at_from_and_ends_at_to() {
let p = BehaviorProfile::default();
let pts = mouse_trajectory((100.0, 100.0), (500.0, 400.0), 50.0, &p);
assert!(pts.len() > 5);
let first = pts[0];
let last = pts[pts.len() - 1];
assert!((first.x - 100.0).abs() < 10.0, "first x={}", first.x);
assert!((first.y - 100.0).abs() < 10.0, "first y={}", first.y);
assert_eq!(last.x, 500.0);
assert_eq!(last.y, 400.0);
}
#[test]
fn mouse_trajectory_obeys_fitts_law_total_time() {
let p = BehaviorProfile::default();
let pts = mouse_trajectory((0.0, 0.0), (500.0, 0.0), 50.0, &p);
let last_t = pts[pts.len() - 1].t_ms;
assert!(
(700.0..=950.0).contains(&last_t),
"expected ~805 ms, got {last_t}"
);
}
#[test]
fn mouse_trajectory_uses_8ms_sample_rate() {
let p = BehaviorProfile::default();
let pts = mouse_trajectory((0.0, 0.0), (200.0, 0.0), 30.0, &p);
for w in pts.windows(2) {
let dt = w[1].t_ms - w[0].t_ms;
assert!((dt - 8.0).abs() < 1e-3, "gap {} not 8 ms", dt);
}
}
#[test]
fn mouse_trajectory_has_velocity_diversity_not_uniform() {
let p = BehaviorProfile::default();
let mut rng = fixed_rng();
let pts = mouse_trajectory_with_rng((0.0, 0.0), (600.0, 400.0), 40.0, &p, &mut rng);
let speeds: Vec<f32> = pts
.windows(2)
.map(|w| ((w[1].x - w[0].x).powi(2) + (w[1].y - w[0].y).powi(2)).sqrt())
.collect();
let mean = speeds.iter().sum::<f32>() / speeds.len() as f32;
let var = speeds.iter().map(|s| (s - mean).powi(2)).sum::<f32>() / speeds.len() as f32;
let std = var.sqrt();
let cv = std / mean.max(1e-3);
assert!(cv > 0.4, "speed coefficient of variation too low: {cv}");
}
#[test]
fn mouse_trajectory_deterministic_per_seed() {
let p = BehaviorProfile {
seed: 123,
..BehaviorProfile::default()
};
let mut r1 = p.rng_for(1);
let mut r2 = p.rng_for(1);
let a = mouse_trajectory_with_rng((0.0, 0.0), (300.0, 200.0), 25.0, &p, &mut r1);
let b = mouse_trajectory_with_rng((0.0, 0.0), (300.0, 200.0), 25.0, &p, &mut r2);
assert_eq!(a.len(), b.len());
for (pa, pb) in a.iter().zip(b.iter()) {
assert_eq!(pa, pb);
}
}
#[test]
fn mouse_trajectory_no_endpoint_jerk_spike() {
for seed in 0..40u64 {
let p = BehaviorProfile {
seed,
..BehaviorProfile::default()
};
let mut r = p.rng_for(2);
let tr = mouse_trajectory_with_rng((12.0, 30.0), (840.0, 510.0), 28.0, &p, &mut r);
assert!(tr.len() >= 8, "trajectory too short");
let step =
|a: &MousePoint, b: &MousePoint| ((b.x - a.x).powi(2) + (b.y - a.y).powi(2)).sqrt();
let steps: Vec<f32> = tr.windows(2).map(|w| step(&w[0], &w[1])).collect();
let n = steps.len();
let final_step = steps[n - 1];
let mut sorted = steps.clone();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
let median = sorted[n / 2];
let max_step = sorted[n - 1];
assert!(
final_step <= max_step + 1e-3,
"seed {seed}: final step {final_step} exceeds max interior step {max_step} — endpoint snap/jerk spike"
);
assert!(
final_step <= median * 6.0 + 5.0,
"seed {seed}: final step {final_step} is a jerk outlier vs median {median}"
);
let last = tr.last().unwrap();
assert!((last.x - 840.0).abs() < 1e-2 && (last.y - 510.0).abs() < 1e-2);
}
}
#[test]
fn keystroke_first_has_no_flight() {
let p = BehaviorProfile::default();
let ks = keystroke_timings("hi", &p);
assert_eq!(ks[0].flight_ms, 0.0);
assert!(ks[1].flight_ms > 0.0);
}
#[test]
fn keystroke_dwell_in_realistic_range() {
let p = BehaviorProfile::default();
let ks = keystroke_timings("the quick brown fox jumps over the lazy dog", &p);
let mean_dwell: f32 = ks.iter().map(|k| k.dwell_ms).sum::<f32>() / ks.len() as f32;
assert!(
(70.0..=150.0).contains(&mean_dwell),
"mean dwell {mean_dwell} outside CMU plausible range"
);
}
#[test]
fn keystroke_flight_scales_with_wpm() {
let slow = BehaviorProfile {
typing_wpm_mean: 30.0,
..BehaviorProfile::default()
};
let fast = BehaviorProfile {
typing_wpm_mean: 70.0,
..BehaviorProfile::default()
};
let s = keystroke_timings("the quick brown fox jumps over", &slow);
let f = keystroke_timings("the quick brown fox jumps over", &fast);
let mean = |ks: &[KeystrokeTiming]| -> f32 {
ks.iter().skip(1).map(|k| k.flight_ms).sum::<f32>() / (ks.len() - 1) as f32
};
assert!(
mean(&s) > mean(&f),
"30 WPM flight {} should exceed 70 WPM flight {}",
mean(&s),
mean(&f)
);
}
#[test]
fn keystroke_bigram_th_faster_than_dd() {
let _p = BehaviorProfile::default();
let mut th_total = 0.0_f32;
let mut dd_total = 0.0_f32;
for seed in 0..50 {
let prof = BehaviorProfile {
seed: seed as u64,
..BehaviorProfile::default()
};
let th = keystroke_timings("th", &prof);
let dd = keystroke_timings("dd", &prof);
th_total += th[1].flight_ms;
dd_total += dd[1].flight_ms;
}
let th_mean = th_total / 50.0;
let dd_mean = dd_total / 50.0;
assert!(
dd_mean > th_mean * 1.5,
"dd flight {dd_mean} should be > 1.5× th flight {th_mean}"
);
}
#[test]
fn keystroke_deterministic_per_seed() {
let mut rng_a = ChaCha20Rng::seed_from_u64(7);
let mut rng_b = ChaCha20Rng::seed_from_u64(7);
let p = BehaviorProfile::default();
let a = keystroke_timings_with_rng("hello world", &p, &mut rng_a);
let b = keystroke_timings_with_rng("hello world", &p, &mut rng_b);
assert_eq!(a, b);
}
#[test]
fn trackpad_burst_decays_to_zero() {
let p = BehaviorProfile {
scroll_style: ScrollStyle::Trackpad,
..BehaviorProfile::default()
};
let ticks = wheel_burst(-1000.0, &p);
assert!(ticks.len() > 5);
for t in &ticks {
assert_eq!(t.mode, 0);
assert!(t.delta_y < 0.0);
}
let cum: f32 = ticks.iter().map(|t| t.delta_y).sum();
assert!(
(cum + 1000.0).abs() < 200.0,
"cumulative {cum} not close to -1000"
);
for w in ticks.windows(2) {
let dt = w[1].t_ms - w[0].t_ms;
assert!((dt - 16.0).abs() < 1e-3);
}
}
#[test]
fn wheel_burst_uses_100px_notches() {
let p = BehaviorProfile {
scroll_style: ScrollStyle::Wheel,
..BehaviorProfile::default()
};
let ticks = wheel_burst(500.0, &p);
assert_eq!(ticks.len(), 5);
for t in &ticks {
assert_eq!(t.delta_y, 100.0);
assert_eq!(t.mode, 0);
}
}
#[test]
fn wheel_burst_intervals_are_lognormal_distributed() {
let p = BehaviorProfile {
scroll_style: ScrollStyle::Wheel,
..BehaviorProfile::default()
};
let ticks = wheel_burst(2000.0, &p); let intervals: Vec<f32> = ticks.windows(2).map(|w| w[1].t_ms - w[0].t_ms).collect();
let mean = intervals.iter().sum::<f32>() / intervals.len() as f32;
assert!(
(mean - 180.0).abs() < 200.0,
"mean interval {mean} too far from 180 ms"
);
let mut sorted = intervals.clone();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
sorted.dedup_by(|a, b| (*a - *b).abs() < 1e-3);
assert!(sorted.len() > 5, "only {} distinct intervals", sorted.len());
}
#[test]
fn default_seeds_differ_across_instances() {
let a = BehaviorProfile::default();
let b = BehaviorProfile::default();
assert_ne!(
a.seed, b.seed,
"default seeds must be random, got {:#x} for both",
a.seed
);
}
#[test]
fn default_seed_is_not_the_placeholder() {
let p = BehaviorProfile::default();
assert_ne!(
p.seed, 0xCAFEF00DDEADBEEF,
"default seed is the hardcoded placeholder — must be randomized"
);
}
}