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