Skip to main content

synheart_sensor_agent/core/
features.rs

1//! Feature computation from event windows.
2//!
3//! This module extracts behavioral features from time windows of events.
4//! All features are computed from timing and magnitude data only - never content.
5
6use crate::collector::types::{
7    KeyboardEvent, KeyboardEventType, MouseEvent, MouseEventType, ShortcutEvent,
8};
9use crate::core::windowing::EventWindow;
10use serde::{Deserialize, Serialize};
11
12/// Keyboard-derived behavioral features.
13///
14/// Note: Typing metrics (typing_rate, typing_tap_count, etc.) are computed from
15/// typing keys ONLY. Navigation keys (arrows, page up/down, home/end) are tracked
16/// separately via keyboard_scroll_rate to avoid inflating typing metrics during
17/// navigation-heavy text editing sessions.
18#[derive(Debug, Clone, Default, Serialize, Deserialize)]
19pub struct KeyboardFeatures {
20    /// Typing keys per second (excludes navigation keys)
21    pub typing_rate: f64,
22    /// Number of idle gaps (pauses) per window
23    pub pause_count: u32,
24    /// Average pause duration in milliseconds
25    pub mean_pause_ms: f64,
26    /// Standard deviation of inter-key intervals
27    pub latency_variability: f64,
28    /// Average key hold duration in milliseconds
29    pub hold_time_mean: f64,
30    /// Burstiness index (0-1, higher = more bursty)
31    pub burst_index: f64,
32    /// Ratio of active typing time to total window time
33    pub session_continuity: f64,
34    /// Total number of discrete typing tap events (excludes navigation keys)
35    pub typing_tap_count: u32,
36    /// Normalized rhythmic consistency score (0-1, higher = more regular timing)
37    pub typing_cadence_stability: f64,
38    /// Proportion of inter-tap intervals classified as gaps
39    pub typing_gap_ratio: f64,
40    /// Composite metric combining speed, cadence stability, and gap behavior (0-1)
41    pub typing_interaction_intensity: f64,
42    /// Navigation key events per second (arrow keys, page up/down, home/end)
43    /// Tracked separately from typing to distinguish keyboard scrolling from mouse scrolling
44    pub keyboard_scroll_rate: f64,
45    /// Total navigation key events in the window
46    pub navigation_key_count: u32,
47
48    // Correction metrics
49    /// Backspace key presses
50    pub backspace_count: u32,
51    /// Forward delete key presses
52    pub delete_count: u32,
53    /// (backspace + delete) / typing_tap_count (0-1+)
54    pub correction_rate: f64,
55    /// 1.0 - correction_rate, clamped to 0-1
56    pub typing_efficiency: f64,
57
58    // Special key counts
59    /// Enter/Return key presses
60    pub enter_count: u32,
61    /// Tab key presses
62    pub tab_count: u32,
63    /// Escape key presses
64    pub escape_count: u32,
65    /// Modifier key events (Shift, Control, Option, Command)
66    pub modifier_key_count: u32,
67    /// Function key presses (F1-F12)
68    pub function_key_count: u32,
69
70    // Shortcut metrics
71    /// Total shortcuts detected in the window
72    pub shortcut_count: u32,
73    /// Shortcuts per second
74    pub shortcut_rate: f64,
75}
76
77/// Mouse-derived behavioral features.
78#[derive(Debug, Clone, Default, Serialize, Deserialize)]
79pub struct MouseFeatures {
80    /// Movement events per second
81    pub mouse_activity_rate: f64,
82    /// Average cursor speed (relative units)
83    pub mean_velocity: f64,
84    /// Standard deviation of velocity
85    pub velocity_variability: f64,
86    /// Count of sudden acceleration changes
87    pub acceleration_spikes: u32,
88    /// Clicks per window
89    pub click_rate: f64,
90    /// Scroll events per window
91    pub scroll_rate: f64,
92    /// Ratio of idle time to active time
93    pub idle_ratio: f64,
94    /// Ratio of small movements to total movements
95    pub micro_adjustment_ratio: f64,
96    /// Total idle time in milliseconds (periods with no mouse activity > 1 second)
97    pub idle_time_ms: u64,
98}
99
100/// Derived behavioral signals combining keyboard and mouse data.
101///
102/// Metric Provenance:
103/// - These signals are computed locally in the sensor agent
104/// - Additional enriched signals (distraction_score, focus_hint) are computed in Flux
105/// - Task switch metrics are NOT captured (requires app context, violates privacy policy)
106#[derive(Debug, Clone, Default, Serialize, Deserialize)]
107pub struct BehavioralSignals {
108    /// Overall interaction rhythm (regularity of input)
109    pub interaction_rhythm: f64,
110    /// Friction indicator (hesitation, corrections)
111    pub friction: f64,
112    /// Motor stability (consistency of movements)
113    pub motor_stability: f64,
114    /// Proxy for focus/attention continuity
115    pub focus_continuity_proxy: f64,
116    /// General burstiness of interactions (0-1, higher = more clustered activity)
117    /// Combines keyboard burst_index and mouse activity patterns
118    pub burstiness: f64,
119    /// True if this window represents a deep focus block:
120    /// - Continuous interaction with no idle gaps > 2 seconds
121    /// - High session continuity (> 0.7)
122    /// - Consistent activity throughout the window
123    pub deep_focus_block: bool,
124}
125
126/// All computed features for a window.
127#[derive(Debug, Clone, Default, Serialize, Deserialize)]
128pub struct WindowFeatures {
129    /// Keyboard-derived features (typing speed, pauses, etc.).
130    pub keyboard: KeyboardFeatures,
131    /// Mouse-derived features (click rate, movement speed, etc.).
132    pub mouse: MouseFeatures,
133    /// Cross-modal behavioural signals (burstiness, task switching, etc.).
134    pub behavioral: BehavioralSignals,
135}
136
137/// Threshold for considering a gap as a "pause" (in milliseconds).
138const PAUSE_THRESHOLD_MS: i64 = 500;
139
140/// Threshold for micro-adjustments (in movement magnitude units).
141const MICRO_ADJUSTMENT_THRESHOLD: f64 = 5.0;
142
143/// Threshold for acceleration spikes (change in velocity).
144const ACCELERATION_SPIKE_THRESHOLD: f64 = 50.0;
145
146/// Compute all features from an event window.
147pub fn compute_features(window: &EventWindow) -> WindowFeatures {
148    let keyboard = compute_keyboard_features(
149        &window.keyboard_events,
150        &window.shortcut_events,
151        window.duration_secs(),
152    );
153    let mouse = compute_mouse_features(&window.mouse_events, window.duration_secs());
154    let behavioral = compute_behavioral_signals(&keyboard, &mouse);
155
156    WindowFeatures {
157        keyboard,
158        mouse,
159        behavioral,
160    }
161}
162
163/// Compute keyboard features from a list of keyboard events and shortcut events.
164///
165/// Typing metrics are computed from typing key events ONLY (excludes navigation keys
166/// and other special keys). Navigation keys, correction keys, and other special keys
167/// are tracked separately.
168fn compute_keyboard_features(
169    events: &[KeyboardEvent],
170    shortcuts: &[ShortcutEvent],
171    window_duration: f64,
172) -> KeyboardFeatures {
173    if (events.is_empty() && shortcuts.is_empty()) || window_duration <= 0.0 {
174        return KeyboardFeatures::default();
175    }
176
177    // Separate typing events from navigation events
178    let typing_events: Vec<&KeyboardEvent> = events
179        .iter()
180        .filter(|e| e.event_type == KeyboardEventType::TypingTap)
181        .collect();
182
183    let navigation_events: Vec<&KeyboardEvent> = events
184        .iter()
185        .filter(|e| e.event_type == KeyboardEventType::NavigationKey)
186        .collect();
187
188    // Count navigation key presses (key down events only)
189    let navigation_key_presses: Vec<&KeyboardEvent> = navigation_events
190        .iter()
191        .filter(|e| e.is_key_down)
192        .copied()
193        .collect();
194    let navigation_key_count = navigation_key_presses.len() as u32;
195    let keyboard_scroll_rate = navigation_key_count as f64 / window_duration;
196
197    // Count special key presses (key down events only)
198    let backspace_count = events
199        .iter()
200        .filter(|e| e.is_key_down && e.event_type == KeyboardEventType::Backspace)
201        .count() as u32;
202    let delete_count = events
203        .iter()
204        .filter(|e| e.is_key_down && e.event_type == KeyboardEventType::Delete)
205        .count() as u32;
206    let enter_count = events
207        .iter()
208        .filter(|e| e.is_key_down && e.event_type == KeyboardEventType::Enter)
209        .count() as u32;
210    let tab_count = events
211        .iter()
212        .filter(|e| e.is_key_down && e.event_type == KeyboardEventType::Tab)
213        .count() as u32;
214    let escape_count = events
215        .iter()
216        .filter(|e| e.is_key_down && e.event_type == KeyboardEventType::Escape)
217        .count() as u32;
218    let modifier_key_count = events
219        .iter()
220        .filter(|e| e.is_key_down && e.event_type == KeyboardEventType::ModifierKey)
221        .count() as u32;
222    let function_key_count = events
223        .iter()
224        .filter(|e| e.is_key_down && e.event_type == KeyboardEventType::FunctionKey)
225        .count() as u32;
226
227    // Count typing key presses (key down events only) - EXCLUDES navigation keys and special keys
228    let typing_key_presses: Vec<&KeyboardEvent> = typing_events
229        .iter()
230        .filter(|e| e.is_key_down)
231        .copied()
232        .collect();
233    let typing_tap_count = typing_key_presses.len() as u32;
234
235    // Correction metrics
236    let correction_rate = (backspace_count + delete_count) as f64 / typing_tap_count.max(1) as f64;
237    let typing_efficiency = (1.0 - correction_rate).clamp(0.0, 1.0);
238
239    // Shortcut metrics
240    let shortcut_count = shortcuts.len() as u32;
241    let shortcut_rate = shortcut_count as f64 / window_duration;
242
243    // Typing rate (typing keys only)
244    let typing_rate = typing_tap_count as f64 / window_duration;
245
246    // Compute inter-key intervals for typing key presses only
247    let intervals: Vec<i64> = typing_key_presses
248        .windows(2)
249        .map(|pair| (pair[1].timestamp - pair[0].timestamp).num_milliseconds())
250        .collect();
251
252    // Pause count and mean pause duration
253    let pauses: Vec<i64> = intervals
254        .iter()
255        .filter(|&&i| i > PAUSE_THRESHOLD_MS)
256        .copied()
257        .collect();
258    let pause_count = pauses.len() as u32;
259    let mean_pause_ms = if pauses.is_empty() {
260        0.0
261    } else {
262        pauses.iter().sum::<i64>() as f64 / pauses.len() as f64
263    };
264
265    // Latency variability (std dev of intervals)
266    let latency_variability = std_dev(&intervals.iter().map(|&i| i as f64).collect::<Vec<_>>());
267
268    // Hold time computation (requires matching key down/up pairs)
269    // Only compute from typing events to avoid navigation key hold times
270    let hold_times = compute_hold_times(&typing_events);
271    let hold_time_mean = if hold_times.is_empty() {
272        0.0
273    } else {
274        hold_times.iter().sum::<f64>() / hold_times.len() as f64
275    };
276
277    // Burst index: ratio of short intervals to all intervals
278    // Short interval = less than 100ms (fast typing burst)
279    let short_interval_count = intervals.iter().filter(|&&i| i < 100).count();
280    let burst_index = if intervals.is_empty() {
281        0.0
282    } else {
283        short_interval_count as f64 / intervals.len() as f64
284    };
285
286    // Session continuity: ratio of active time to total window time
287    // Active time is sum of intervals (excluding long pauses)
288    let active_intervals: Vec<i64> = intervals
289        .iter()
290        .filter(|&&i| i <= PAUSE_THRESHOLD_MS * 2) // Allow some breathing room
291        .copied()
292        .collect();
293    let active_time_ms: i64 = active_intervals.iter().sum();
294    let session_continuity = (active_time_ms as f64 / 1000.0) / window_duration;
295
296    // Typing cadence stability: normalized rhythmic consistency (0-1, higher = more regular)
297    // Inverse relationship with latency variability
298    let typing_cadence_stability = 1.0 / (1.0 + latency_variability / 100.0);
299
300    // Typing gap ratio: proportion of inter-tap intervals classified as gaps
301    let typing_gap_ratio = if intervals.is_empty() {
302        0.0
303    } else {
304        pause_count as f64 / intervals.len() as f64
305    };
306
307    // Typing interaction intensity: composite metric (0-1)
308    // Combines normalized speed, cadence stability, and inverse gap ratio
309    let normalized_speed = (typing_rate / 10.0).min(1.0); // Normalize to ~10 keys/sec max
310    let typing_interaction_intensity =
311        (normalized_speed * 0.4 + typing_cadence_stability * 0.3 + (1.0 - typing_gap_ratio) * 0.3)
312            .clamp(0.0, 1.0);
313
314    KeyboardFeatures {
315        typing_rate,
316        pause_count,
317        mean_pause_ms,
318        latency_variability,
319        hold_time_mean,
320        burst_index,
321        session_continuity: session_continuity.min(1.0), // Cap at 1.0
322        typing_tap_count,
323        typing_cadence_stability,
324        typing_gap_ratio,
325        typing_interaction_intensity,
326        keyboard_scroll_rate,
327        navigation_key_count,
328        backspace_count,
329        delete_count,
330        correction_rate,
331        typing_efficiency,
332        enter_count,
333        tab_count,
334        escape_count,
335        modifier_key_count,
336        function_key_count,
337        shortcut_count,
338        shortcut_rate,
339    }
340}
341
342/// Estimate hold times from event sequence.
343fn compute_hold_times(events: &[&KeyboardEvent]) -> Vec<f64> {
344    let mut hold_times = Vec::new();
345    let mut last_down: Option<&KeyboardEvent> = None;
346
347    for event in events {
348        if event.is_key_down {
349            last_down = Some(event);
350        } else if let Some(down) = last_down {
351            let hold_ms = (event.timestamp - down.timestamp).num_milliseconds() as f64;
352            // Filter out unreasonable hold times (< 20ms or > 2000ms)
353            if (20.0..=2000.0).contains(&hold_ms) {
354                hold_times.push(hold_ms);
355            }
356            last_down = None;
357        }
358    }
359
360    hold_times
361}
362
363/// Compute mouse features from a list of mouse events.
364fn compute_mouse_features(events: &[MouseEvent], window_duration: f64) -> MouseFeatures {
365    if events.is_empty() || window_duration <= 0.0 {
366        return MouseFeatures::default();
367    }
368
369    // Categorize events
370    let move_events: Vec<&MouseEvent> = events
371        .iter()
372        .filter(|e| e.event_type == MouseEventType::Move)
373        .collect();
374
375    let click_events: Vec<&MouseEvent> = events
376        .iter()
377        .filter(|e| {
378            e.event_type == MouseEventType::LeftClick || e.event_type == MouseEventType::RightClick
379        })
380        .collect();
381
382    let scroll_events: Vec<&MouseEvent> = events
383        .iter()
384        .filter(|e| e.event_type == MouseEventType::Scroll)
385        .collect();
386
387    // Mouse activity rate (movements per second)
388    let mouse_activity_rate = move_events.len() as f64 / window_duration;
389
390    // Velocity statistics
391    let velocities: Vec<f64> = move_events
392        .iter()
393        .filter_map(|e| e.delta_magnitude)
394        .collect();
395
396    let mean_velocity = if velocities.is_empty() {
397        0.0
398    } else {
399        velocities.iter().sum::<f64>() / velocities.len() as f64
400    };
401
402    let velocity_variability = std_dev(&velocities);
403
404    // Acceleration spikes (large changes in velocity)
405    let acceleration_spikes = velocities
406        .windows(2)
407        .filter(|pair| (pair[1] - pair[0]).abs() > ACCELERATION_SPIKE_THRESHOLD)
408        .count() as u32;
409
410    // Click and scroll rates
411    let click_rate = click_events.len() as f64 / window_duration;
412    let scroll_rate = scroll_events.len() as f64 / window_duration;
413
414    // Idle metrics: estimate based on gaps in movement events
415    let (idle_ratio, idle_time_ms, _has_long_gap) =
416        estimate_idle_metrics(&move_events, window_duration);
417
418    // Micro-adjustment ratio: small movements vs all movements
419    let micro_count = velocities
420        .iter()
421        .filter(|&&v| v < MICRO_ADJUSTMENT_THRESHOLD)
422        .count();
423    let micro_adjustment_ratio = if velocities.is_empty() {
424        0.0
425    } else {
426        micro_count as f64 / velocities.len() as f64
427    };
428
429    MouseFeatures {
430        mouse_activity_rate,
431        mean_velocity,
432        velocity_variability,
433        acceleration_spikes,
434        click_rate,
435        scroll_rate,
436        idle_ratio,
437        micro_adjustment_ratio,
438        idle_time_ms,
439    }
440}
441
442/// Estimate idle metrics from movement event gaps.
443/// Returns (idle_ratio, idle_time_ms, has_long_gap).
444/// has_long_gap is true if any gap exceeds 2 seconds (used for deep focus detection).
445fn estimate_idle_metrics(move_events: &[&MouseEvent], window_duration: f64) -> (f64, u64, bool) {
446    if move_events.len() < 2 {
447        // No movement = all idle
448        let total_idle = (window_duration * 1000.0) as u64;
449        return (1.0, total_idle, true);
450    }
451
452    // Consider gaps > 1 second as "idle"
453    const IDLE_THRESHOLD_MS: i64 = 1000;
454    // Consider gaps > 2 seconds as "long gaps" (breaks deep focus)
455    const LONG_GAP_THRESHOLD_MS: i64 = 2000;
456
457    let mut idle_time_ms: i64 = 0;
458    let mut has_long_gap = false;
459
460    for pair in move_events.windows(2) {
461        let gap = (pair[1].timestamp - pair[0].timestamp).num_milliseconds();
462        if gap > IDLE_THRESHOLD_MS {
463            idle_time_ms += gap - IDLE_THRESHOLD_MS; // Count only the excess as idle
464        }
465        if gap > LONG_GAP_THRESHOLD_MS {
466            has_long_gap = true;
467        }
468    }
469
470    let idle_secs = idle_time_ms as f64 / 1000.0;
471    let idle_ratio = (idle_secs / window_duration).min(1.0);
472
473    (idle_ratio, idle_time_ms.max(0) as u64, has_long_gap)
474}
475
476/// Compute derived behavioral signals from keyboard and mouse features.
477fn compute_behavioral_signals(
478    keyboard: &KeyboardFeatures,
479    mouse: &MouseFeatures,
480) -> BehavioralSignals {
481    // Interaction rhythm: combines typing regularity and mouse consistency
482    // Lower variability = more rhythmic
483    let typing_rhythm = 1.0 / (1.0 + keyboard.latency_variability / 100.0);
484    let mouse_rhythm = 1.0 / (1.0 + mouse.velocity_variability / 50.0);
485    let interaction_rhythm = (typing_rhythm + mouse_rhythm) / 2.0;
486
487    // Friction: indicates hesitation, uncertainty
488    // High pause rate, low burst index, many micro-adjustments, high correction rate
489    let friction = (keyboard.pause_count as f64 * 0.1)
490        + (1.0 - keyboard.burst_index) * 0.2
491        + mouse.micro_adjustment_ratio * 0.2
492        + keyboard.correction_rate.min(1.0) * 0.3;
493
494    // Motor stability: consistency of physical movements
495    // Low variability in both keyboard and mouse
496    let motor_stability = 1.0
497        - (keyboard.latency_variability / 200.0).min(0.5)
498        - (mouse.velocity_variability / 100.0).min(0.5);
499
500    // Focus continuity proxy: sustained activity patterns
501    // High session continuity, low idle ratio
502    let focus_continuity_proxy = keyboard.session_continuity * 0.5 + (1.0 - mouse.idle_ratio) * 0.5;
503
504    // Burstiness: general measure of whether interactions occur in clusters or evenly
505    // Combines keyboard burst_index with mouse activity patterns
506    // High burstiness = interactions come in bursts with gaps between
507    let keyboard_burstiness = keyboard.burst_index;
508    // Mouse burstiness: high activity rate with high idle ratio indicates bursty behavior
509    let mouse_burstiness = if mouse.mouse_activity_rate > 0.0 {
510        // If there's activity but also significant idle time, it's bursty
511        mouse.idle_ratio * (1.0 - mouse.micro_adjustment_ratio)
512    } else {
513        0.0
514    };
515    let burstiness = (keyboard_burstiness * 0.6 + mouse_burstiness * 0.4).clamp(0.0, 1.0);
516
517    // Deep focus block detection:
518    // - High session continuity (> 0.7) - sustained typing activity
519    // - Low idle ratio (< 0.3) - minimal gaps in mouse activity
520    // - Some minimum activity (typing or mouse) to confirm engagement
521    let has_activity = keyboard.typing_tap_count > 0 || mouse.mouse_activity_rate > 0.5;
522    let sustained_typing = keyboard.session_continuity > 0.7;
523    let minimal_idle = mouse.idle_ratio < 0.3;
524    let deep_focus_block = has_activity && sustained_typing && minimal_idle;
525
526    BehavioralSignals {
527        interaction_rhythm: interaction_rhythm.clamp(0.0, 1.0),
528        friction: friction.clamp(0.0, 1.0),
529        motor_stability: motor_stability.clamp(0.0, 1.0),
530        focus_continuity_proxy: focus_continuity_proxy.clamp(0.0, 1.0),
531        burstiness,
532        deep_focus_block,
533    }
534}
535
536/// Compute standard deviation of a slice of values.
537fn std_dev(values: &[f64]) -> f64 {
538    if values.len() < 2 {
539        return 0.0;
540    }
541
542    let mean = values.iter().sum::<f64>() / values.len() as f64;
543    let variance = values.iter().map(|&v| (v - mean).powi(2)).sum::<f64>() / values.len() as f64;
544    variance.sqrt()
545}
546
547#[cfg(test)]
548mod tests {
549    use super::*;
550    use chrono::{Duration, Utc};
551
552    fn make_keyboard_event(is_down: bool, offset_ms: i64) -> KeyboardEvent {
553        KeyboardEvent {
554            timestamp: Utc::now() + Duration::milliseconds(offset_ms),
555            is_key_down: is_down,
556            event_type: KeyboardEventType::TypingTap,
557        }
558    }
559
560    fn make_navigation_event(is_down: bool, offset_ms: i64) -> KeyboardEvent {
561        KeyboardEvent {
562            timestamp: Utc::now() + Duration::milliseconds(offset_ms),
563            is_key_down: is_down,
564            event_type: KeyboardEventType::NavigationKey,
565        }
566    }
567
568    #[test]
569    fn test_keyboard_features_empty() {
570        let features = compute_keyboard_features(&[], &[], 10.0);
571        assert_eq!(features.typing_rate, 0.0);
572    }
573
574    #[test]
575    fn test_keyboard_features_basic() {
576        let events = vec![
577            make_keyboard_event(true, 0),
578            make_keyboard_event(false, 50),
579            make_keyboard_event(true, 100),
580            make_keyboard_event(false, 150),
581            make_keyboard_event(true, 200),
582            make_keyboard_event(false, 250),
583        ];
584
585        let features = compute_keyboard_features(&events, &[], 1.0);
586        assert_eq!(features.typing_rate, 3.0); // 3 key presses in 1 second
587    }
588
589    #[test]
590    fn test_std_dev() {
591        let values = vec![2.0, 4.0, 4.0, 4.0, 5.0, 5.0, 7.0, 9.0];
592        let sd = std_dev(&values);
593        assert!((sd - 2.0).abs() < 0.1);
594    }
595
596    #[test]
597    fn test_behavioral_signals_bounds() {
598        let keyboard = KeyboardFeatures::default();
599        let mouse = MouseFeatures::default();
600        let signals = compute_behavioral_signals(&keyboard, &mouse);
601
602        // All signals should be between 0 and 1
603        assert!(signals.interaction_rhythm >= 0.0 && signals.interaction_rhythm <= 1.0);
604        assert!(signals.friction >= 0.0 && signals.friction <= 1.0);
605        assert!(signals.motor_stability >= 0.0 && signals.motor_stability <= 1.0);
606        assert!(signals.focus_continuity_proxy >= 0.0 && signals.focus_continuity_proxy <= 1.0);
607    }
608
609    #[test]
610    fn test_typing_tap_count() {
611        let events = vec![
612            make_keyboard_event(true, 0),
613            make_keyboard_event(false, 50),
614            make_keyboard_event(true, 100),
615            make_keyboard_event(false, 150),
616            make_keyboard_event(true, 200),
617            make_keyboard_event(false, 250),
618        ];
619
620        let features = compute_keyboard_features(&events, &[], 1.0);
621        assert_eq!(features.typing_tap_count, 3); // 3 key presses
622    }
623
624    #[test]
625    fn test_typing_cadence_stability_bounds() {
626        // Empty events should give default (which uses 0 variability)
627        let features_empty = compute_keyboard_features(&[], &[], 10.0);
628        assert!(
629            features_empty.typing_cadence_stability >= 0.0
630                && features_empty.typing_cadence_stability <= 1.0
631        );
632
633        // Regular typing should have high cadence stability
634        let events = vec![
635            make_keyboard_event(true, 0),
636            make_keyboard_event(false, 50),
637            make_keyboard_event(true, 100),
638            make_keyboard_event(false, 150),
639            make_keyboard_event(true, 200),
640            make_keyboard_event(false, 250),
641        ];
642        let features = compute_keyboard_features(&events, &[], 1.0);
643        assert!(
644            features.typing_cadence_stability >= 0.0 && features.typing_cadence_stability <= 1.0
645        );
646        // Regular intervals should yield high stability
647        assert!(features.typing_cadence_stability > 0.5);
648    }
649
650    #[test]
651    fn test_typing_gap_ratio_bounds() {
652        let features_empty = compute_keyboard_features(&[], &[], 10.0);
653        assert_eq!(features_empty.typing_gap_ratio, 0.0);
654
655        // Fast typing with no pauses
656        let events = vec![
657            make_keyboard_event(true, 0),
658            make_keyboard_event(false, 50),
659            make_keyboard_event(true, 100),
660            make_keyboard_event(false, 150),
661        ];
662        let features = compute_keyboard_features(&events, &[], 1.0);
663        assert!(features.typing_gap_ratio >= 0.0 && features.typing_gap_ratio <= 1.0);
664        assert_eq!(features.typing_gap_ratio, 0.0); // No gaps in fast typing
665
666        // Typing with pauses (>500ms gaps)
667        let events_with_gaps = vec![
668            make_keyboard_event(true, 0),
669            make_keyboard_event(false, 50),
670            make_keyboard_event(true, 600), // 600ms gap = pause
671            make_keyboard_event(false, 650),
672        ];
673        let features_gaps = compute_keyboard_features(&events_with_gaps, &[], 1.0);
674        assert!(features_gaps.typing_gap_ratio > 0.0); // Should have gaps
675    }
676
677    #[test]
678    fn test_typing_interaction_intensity_bounds() {
679        let features_empty = compute_keyboard_features(&[], &[], 10.0);
680        assert!(
681            features_empty.typing_interaction_intensity >= 0.0
682                && features_empty.typing_interaction_intensity <= 1.0
683        );
684
685        // High intensity: fast, regular, no gaps
686        let fast_events = vec![
687            make_keyboard_event(true, 0),
688            make_keyboard_event(false, 30),
689            make_keyboard_event(true, 60),
690            make_keyboard_event(false, 90),
691            make_keyboard_event(true, 120),
692            make_keyboard_event(false, 150),
693            make_keyboard_event(true, 180),
694            make_keyboard_event(false, 210),
695            make_keyboard_event(true, 240),
696            make_keyboard_event(false, 270),
697        ];
698        let features = compute_keyboard_features(&fast_events, &[], 1.0);
699        assert!(
700            features.typing_interaction_intensity >= 0.0
701                && features.typing_interaction_intensity <= 1.0
702        );
703        // Fast regular typing should have moderate to high intensity
704        assert!(features.typing_interaction_intensity > 0.3);
705    }
706
707    #[test]
708    fn test_navigation_key_separation() {
709        // Mix of typing and navigation events
710        let events = vec![
711            make_keyboard_event(true, 0),      // typing
712            make_keyboard_event(false, 50),    // typing
713            make_navigation_event(true, 100),  // navigation (arrow key)
714            make_navigation_event(false, 150), // navigation
715            make_keyboard_event(true, 200),    // typing
716            make_keyboard_event(false, 250),   // typing
717            make_navigation_event(true, 300),  // navigation
718            make_navigation_event(false, 350), // navigation
719        ];
720
721        let features = compute_keyboard_features(&events, &[], 1.0);
722
723        // Should only count typing key presses (2 typing events)
724        assert_eq!(features.typing_tap_count, 2);
725        assert_eq!(features.typing_rate, 2.0);
726
727        // Should count navigation key presses separately (2 navigation events)
728        assert_eq!(features.navigation_key_count, 2);
729        assert_eq!(features.keyboard_scroll_rate, 2.0);
730    }
731
732    #[test]
733    fn test_navigation_keys_dont_inflate_typing_metrics() {
734        // Only navigation events - typing metrics should be zero/default
735        let nav_only_events = vec![
736            make_navigation_event(true, 0),
737            make_navigation_event(false, 50),
738            make_navigation_event(true, 100),
739            make_navigation_event(false, 150),
740            make_navigation_event(true, 200),
741            make_navigation_event(false, 250),
742        ];
743
744        let features = compute_keyboard_features(&nav_only_events, &[], 1.0);
745
746        // Typing metrics should be zero
747        assert_eq!(features.typing_tap_count, 0);
748        assert_eq!(features.typing_rate, 0.0);
749
750        // Navigation metrics should be counted
751        assert_eq!(features.navigation_key_count, 3);
752        assert_eq!(features.keyboard_scroll_rate, 3.0);
753    }
754
755    #[test]
756    fn test_keyboard_scroll_rate_bounds() {
757        let features_empty = compute_keyboard_features(&[], &[], 10.0);
758        assert_eq!(features_empty.keyboard_scroll_rate, 0.0);
759        assert_eq!(features_empty.navigation_key_count, 0);
760
761        // Navigation-heavy session
762        let nav_events = vec![
763            make_navigation_event(true, 0),
764            make_navigation_event(false, 30),
765            make_navigation_event(true, 60),
766            make_navigation_event(false, 90),
767            make_navigation_event(true, 120),
768            make_navigation_event(false, 150),
769        ];
770        let features = compute_keyboard_features(&nav_events, &[], 1.0);
771        assert_eq!(features.navigation_key_count, 3);
772        assert!(features.keyboard_scroll_rate > 0.0);
773    }
774
775    #[test]
776    fn test_burstiness_bounds() {
777        let keyboard = KeyboardFeatures::default();
778        let mouse = MouseFeatures::default();
779        let signals = compute_behavioral_signals(&keyboard, &mouse);
780
781        // Burstiness should be between 0 and 1
782        assert!(signals.burstiness >= 0.0 && signals.burstiness <= 1.0);
783    }
784
785    #[test]
786    fn test_burstiness_high_burst_index() {
787        // High keyboard burst_index should increase burstiness
788        let keyboard = KeyboardFeatures {
789            burst_index: 0.9, // Very bursty typing
790            ..Default::default()
791        };
792
793        let mouse = MouseFeatures::default();
794        let signals = compute_behavioral_signals(&keyboard, &mouse);
795
796        // Should have elevated burstiness
797        assert!(signals.burstiness > 0.4);
798        assert!(signals.burstiness <= 1.0);
799    }
800
801    #[test]
802    fn test_deep_focus_block_detection() {
803        // Default (empty) features should NOT be deep focus
804        let keyboard = KeyboardFeatures::default();
805        let mouse = MouseFeatures::default();
806        let signals = compute_behavioral_signals(&keyboard, &mouse);
807        assert!(!signals.deep_focus_block);
808
809        // High continuity, low idle, some activity = deep focus
810        let keyboard_active = KeyboardFeatures {
811            session_continuity: 0.9, // High continuity
812            typing_tap_count: 50,    // Some activity
813            ..Default::default()
814        };
815
816        let mouse_active = MouseFeatures {
817            idle_ratio: 0.1, // Low idle ratio
818            mouse_activity_rate: 2.0,
819            ..Default::default()
820        };
821
822        let signals_active = compute_behavioral_signals(&keyboard_active, &mouse_active);
823        assert!(signals_active.deep_focus_block);
824    }
825
826    #[test]
827    fn test_deep_focus_block_requires_low_idle() {
828        // High continuity but high idle = NOT deep focus
829        let keyboard = KeyboardFeatures {
830            session_continuity: 0.9,
831            typing_tap_count: 50,
832            ..Default::default()
833        };
834
835        let mouse = MouseFeatures {
836            idle_ratio: 0.5, // Too much idle time
837            ..Default::default()
838        };
839
840        let signals = compute_behavioral_signals(&keyboard, &mouse);
841        assert!(!signals.deep_focus_block);
842    }
843
844    #[test]
845    fn test_idle_time_ms_computation() {
846        use crate::collector::types::MouseEvent;
847
848        // Test with mouse events that have gaps
849        let base_time = chrono::Utc::now();
850        let events = vec![
851            MouseEvent {
852                timestamp: base_time,
853                event_type: MouseEventType::Move,
854                delta_magnitude: Some(10.0),
855                scroll_direction: None,
856                scroll_magnitude: None,
857            },
858            MouseEvent {
859                timestamp: base_time + chrono::Duration::milliseconds(500),
860                event_type: MouseEventType::Move,
861                delta_magnitude: Some(10.0),
862                scroll_direction: None,
863                scroll_magnitude: None,
864            },
865            MouseEvent {
866                timestamp: base_time + chrono::Duration::milliseconds(2000), // 1500ms gap
867                event_type: MouseEventType::Move,
868                delta_magnitude: Some(10.0),
869                scroll_direction: None,
870                scroll_magnitude: None,
871            },
872        ];
873
874        let features = compute_mouse_features(&events, 2.0);
875
876        // Should have some idle time (gap of 1500ms, 500ms over threshold)
877        assert!(features.idle_time_ms > 0);
878        assert!(features.idle_ratio > 0.0);
879    }
880
881    #[test]
882    fn test_behavioral_signals_new_fields_bounds() {
883        // Test that all new behavioral signals are properly bounded
884        let keyboard = KeyboardFeatures {
885            burst_index: 0.5,
886            session_continuity: 0.5,
887            typing_tap_count: 10,
888            ..Default::default()
889        };
890
891        let mouse = MouseFeatures {
892            idle_ratio: 0.5,
893            mouse_activity_rate: 1.0,
894            ..Default::default()
895        };
896
897        let signals = compute_behavioral_signals(&keyboard, &mouse);
898
899        // All signals should be bounded 0-1
900        assert!(signals.interaction_rhythm >= 0.0 && signals.interaction_rhythm <= 1.0);
901        assert!(signals.friction >= 0.0 && signals.friction <= 1.0);
902        assert!(signals.motor_stability >= 0.0 && signals.motor_stability <= 1.0);
903        assert!(signals.focus_continuity_proxy >= 0.0 && signals.focus_continuity_proxy <= 1.0);
904        assert!(signals.burstiness >= 0.0 && signals.burstiness <= 1.0);
905        // deep_focus_block is a boolean, no bounds check needed
906    }
907
908    fn make_special_key_event(
909        event_type: KeyboardEventType,
910        is_down: bool,
911        offset_ms: i64,
912    ) -> KeyboardEvent {
913        KeyboardEvent {
914            timestamp: Utc::now() + Duration::milliseconds(offset_ms),
915            is_key_down: is_down,
916            event_type,
917        }
918    }
919
920    #[test]
921    fn test_correction_rate_computation() {
922        let events = vec![
923            make_keyboard_event(true, 0),
924            make_keyboard_event(false, 50),
925            make_keyboard_event(true, 100),
926            make_keyboard_event(false, 150),
927            make_keyboard_event(true, 200),
928            make_keyboard_event(false, 250),
929            make_special_key_event(KeyboardEventType::Backspace, true, 300),
930            make_special_key_event(KeyboardEventType::Backspace, false, 350),
931        ];
932
933        let features = compute_keyboard_features(&events, &[], 1.0);
934        assert_eq!(features.typing_tap_count, 3);
935        assert_eq!(features.backspace_count, 1);
936        assert_eq!(features.delete_count, 0);
937        // correction_rate = 1/3 ≈ 0.333
938        assert!((features.correction_rate - 1.0 / 3.0).abs() < 0.01);
939        assert!((features.typing_efficiency - (1.0 - 1.0 / 3.0)).abs() < 0.01);
940    }
941
942    #[test]
943    fn test_correction_rate_zero_typing() {
944        // Only backspace events, no typing taps
945        let events = vec![
946            make_special_key_event(KeyboardEventType::Backspace, true, 0),
947            make_special_key_event(KeyboardEventType::Backspace, false, 50),
948        ];
949
950        let features = compute_keyboard_features(&events, &[], 1.0);
951        assert_eq!(features.typing_tap_count, 0);
952        assert_eq!(features.backspace_count, 1);
953        // correction_rate = 1/1 (max(1) prevents division by zero) = 1.0
954        assert!((features.correction_rate - 1.0).abs() < 0.01);
955        assert_eq!(features.typing_efficiency, 0.0);
956    }
957
958    #[test]
959    fn test_special_key_counts() {
960        let events = vec![
961            make_special_key_event(KeyboardEventType::Enter, true, 0),
962            make_special_key_event(KeyboardEventType::Enter, false, 50),
963            make_special_key_event(KeyboardEventType::Tab, true, 100),
964            make_special_key_event(KeyboardEventType::Tab, false, 150),
965            make_special_key_event(KeyboardEventType::Escape, true, 200),
966            make_special_key_event(KeyboardEventType::Escape, false, 250),
967            make_special_key_event(KeyboardEventType::ModifierKey, true, 300),
968            make_special_key_event(KeyboardEventType::FunctionKey, true, 400),
969            make_special_key_event(KeyboardEventType::FunctionKey, false, 450),
970        ];
971
972        let features = compute_keyboard_features(&events, &[], 1.0);
973        assert_eq!(features.enter_count, 1);
974        assert_eq!(features.tab_count, 1);
975        assert_eq!(features.escape_count, 1);
976        assert_eq!(features.modifier_key_count, 1);
977        assert_eq!(features.function_key_count, 1);
978        assert_eq!(features.typing_tap_count, 0); // None of these are typing taps
979    }
980
981    #[test]
982    fn test_shortcut_metrics() {
983        use crate::collector::types::{ShortcutEvent, ShortcutType};
984
985        let shortcuts = vec![
986            ShortcutEvent {
987                timestamp: Utc::now(),
988                shortcut_type: ShortcutType::Copy,
989            },
990            ShortcutEvent {
991                timestamp: Utc::now() + Duration::milliseconds(500),
992                shortcut_type: ShortcutType::Paste,
993            },
994        ];
995
996        let features = compute_keyboard_features(&[], &shortcuts, 10.0);
997        assert_eq!(features.shortcut_count, 2);
998        assert!((features.shortcut_rate - 0.2).abs() < 0.01); // 2 shortcuts / 10s
999    }
1000
1001    #[test]
1002    fn test_friction_includes_correction_rate() {
1003        // With corrections, friction should be higher
1004        let keyboard_with_corrections = KeyboardFeatures {
1005            correction_rate: 0.5,
1006            burst_index: 0.5,
1007            ..Default::default()
1008        };
1009
1010        let keyboard_without = KeyboardFeatures {
1011            correction_rate: 0.0,
1012            burst_index: 0.5,
1013            ..Default::default()
1014        };
1015
1016        let mouse = MouseFeatures::default();
1017
1018        let signals_with = compute_behavioral_signals(&keyboard_with_corrections, &mouse);
1019        let signals_without = compute_behavioral_signals(&keyboard_without, &mouse);
1020
1021        assert!(signals_with.friction > signals_without.friction);
1022    }
1023}