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