Skip to main content

synheart_sensor_agent/core/
hsi.rs

1//! HSI (Human State Interface) 1.0 compliant snapshot builder.
2//!
3//! This module creates JSON snapshots according to the HSI 1.0 specification.
4//! Each snapshot represents a single time window of behavioral data.
5
6use crate::core::features::WindowFeatures;
7use crate::core::windowing::EventWindow;
8use chrono::Utc;
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11use uuid::Uuid;
12
13/// The current HSI format version.
14pub const HSI_VERSION: &str = "1.0";
15
16/// The name of this producer.
17pub const PRODUCER_NAME: &str = "synheart-sensor-agent";
18
19// ============================================================================
20// HSI 1.0 Compliant Types
21// ============================================================================
22
23/// HSI 1.0 axis reading direction
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
25#[serde(rename_all = "snake_case")]
26pub enum HsiDirection {
27    HigherIsMore,
28    HigherIsLess,
29    Bidirectional,
30}
31
32/// HSI 1.0 source type
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(rename_all = "snake_case")]
35pub enum HsiSourceType {
36    Sensor,
37    App,
38    SelfReport,
39    Observer,
40    Derived,
41    Other,
42}
43
44/// HSI 1.0 producer metadata
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct HsiProducer {
47    /// Name of the producing software
48    pub name: String,
49    /// Version of the producing software
50    pub version: String,
51    /// Unique instance identifier (UUID)
52    #[serde(skip_serializing_if = "Option::is_none")]
53    pub instance_id: Option<String>,
54}
55
56/// HSI 1.0 window definition
57#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct HsiWindow {
59    /// Window start time (RFC3339)
60    pub start: String,
61    /// Window end time (RFC3339)
62    pub end: String,
63    /// Optional label for the window
64    #[serde(skip_serializing_if = "Option::is_none")]
65    pub label: Option<String>,
66}
67
68/// HSI 1.0 axis reading
69#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct HsiAxisReading {
71    /// Axis name (lower_snake_case)
72    pub axis: String,
73    /// Score value (0-1) or null if unavailable
74    pub score: Option<f64>,
75    /// Confidence in the score (0-1)
76    pub confidence: f64,
77    /// Window ID this reading belongs to
78    pub window_id: String,
79    /// Direction semantics
80    #[serde(skip_serializing_if = "Option::is_none")]
81    pub direction: Option<HsiDirection>,
82    /// Unit of measurement
83    #[serde(skip_serializing_if = "Option::is_none")]
84    pub unit: Option<String>,
85    /// Source IDs that contributed to this reading
86    #[serde(skip_serializing_if = "Option::is_none")]
87    pub evidence_source_ids: Option<Vec<String>>,
88    /// Notes about this reading
89    #[serde(skip_serializing_if = "Option::is_none")]
90    pub notes: Option<String>,
91}
92
93/// HSI 1.0 axes domain (contains readings array)
94#[derive(Debug, Clone, Serialize, Deserialize)]
95pub struct HsiAxesDomain {
96    /// Axis readings
97    pub readings: Vec<HsiAxisReading>,
98}
99
100/// HSI 1.0 axes container
101#[derive(Debug, Clone, Default, Serialize, Deserialize)]
102pub struct HsiAxes {
103    /// Affect domain readings
104    #[serde(skip_serializing_if = "Option::is_none")]
105    pub affect: Option<HsiAxesDomain>,
106    /// Engagement domain readings
107    #[serde(skip_serializing_if = "Option::is_none")]
108    pub engagement: Option<HsiAxesDomain>,
109    /// Behavior domain readings
110    #[serde(skip_serializing_if = "Option::is_none")]
111    pub behavior: Option<HsiAxesDomain>,
112}
113
114/// HSI 1.0 source definition
115#[derive(Debug, Clone, Serialize, Deserialize)]
116pub struct HsiSource {
117    /// Source type
118    #[serde(rename = "type")]
119    pub source_type: HsiSourceType,
120    /// Quality of the source (0-1)
121    pub quality: f64,
122    /// Whether the source is degraded
123    pub degraded: bool,
124    /// Optional notes
125    #[serde(skip_serializing_if = "Option::is_none")]
126    pub notes: Option<String>,
127}
128
129/// HSI 1.0 privacy declaration
130#[derive(Debug, Clone, Serialize, Deserialize)]
131pub struct HsiPrivacy {
132    /// Must be false - HSI payloads must not contain PII
133    pub contains_pii: bool,
134    /// Whether raw biosignals are allowed
135    pub raw_biosignals_allowed: bool,
136    /// Whether derived metrics are allowed
137    pub derived_metrics_allowed: bool,
138    /// Notes about privacy
139    #[serde(skip_serializing_if = "Option::is_none")]
140    pub notes: Option<String>,
141}
142
143impl Default for HsiPrivacy {
144    fn default() -> Self {
145        Self {
146            contains_pii: false,
147            raw_biosignals_allowed: false,
148            derived_metrics_allowed: true,
149            notes: Some(
150                "No key content or coordinates captured - timing and magnitude only".to_string(),
151            ),
152        }
153    }
154}
155
156/// HSI 1.0 compliant snapshot
157#[derive(Debug, Clone, Serialize, Deserialize)]
158pub struct HsiSnapshot {
159    /// HSI schema version (must be "1.0")
160    pub hsi_version: String,
161    /// When the human state was observed (RFC3339)
162    pub observed_at_utc: String,
163    /// When this payload was computed (RFC3339)
164    pub computed_at_utc: String,
165    /// Producer metadata
166    pub producer: HsiProducer,
167    /// Window identifiers
168    pub window_ids: Vec<String>,
169    /// Window definitions keyed by ID
170    pub windows: HashMap<String, HsiWindow>,
171    /// Source identifiers
172    #[serde(skip_serializing_if = "Option::is_none")]
173    pub source_ids: Option<Vec<String>>,
174    /// Source definitions keyed by ID
175    #[serde(skip_serializing_if = "Option::is_none")]
176    pub sources: Option<HashMap<String, HsiSource>>,
177    /// Axis readings by domain
178    #[serde(skip_serializing_if = "Option::is_none")]
179    pub axes: Option<HsiAxes>,
180    /// Privacy declaration
181    pub privacy: HsiPrivacy,
182    /// Additional metadata
183    #[serde(skip_serializing_if = "Option::is_none")]
184    pub meta: Option<HashMap<String, serde_json::Value>>,
185}
186
187/// Builder for creating HSI 1.0 compliant snapshots.
188pub struct HsiBuilder {
189    instance_id: Uuid,
190    session_id: Option<String>,
191}
192
193impl HsiBuilder {
194    /// Create a new HSI builder with a unique instance ID.
195    pub fn new() -> Self {
196        Self {
197            instance_id: Uuid::new_v4(),
198            session_id: None,
199        }
200    }
201
202    /// Set the session ID for generated snapshots.
203    pub fn with_session_id(mut self, session_id: String) -> Self {
204        self.session_id = Some(session_id);
205        self
206    }
207
208    /// Get the instance ID.
209    pub fn instance_id(&self) -> Uuid {
210        self.instance_id
211    }
212
213    /// Build an HSI 1.0 compliant snapshot from a window and its computed features.
214    pub fn build(&self, window: &EventWindow, features: &WindowFeatures) -> HsiSnapshot {
215        let computed_at = Utc::now();
216
217        // Generate window ID
218        let window_id = format!("w_{}", computed_at.timestamp_millis());
219
220        // Build windows map
221        let mut windows = HashMap::new();
222        windows.insert(
223            window_id.clone(),
224            HsiWindow {
225                start: window.start.to_rfc3339(),
226                end: window.end.to_rfc3339(),
227                label: if window.is_session_start {
228                    Some("session_start".to_string())
229                } else {
230                    None
231                },
232            },
233        );
234
235        // Build source
236        let source_id = format!("s_keyboard_mouse_{}", self.instance_id);
237        let mut sources = HashMap::new();
238
239        // Calculate quality based on event count
240        let event_count = window.event_count();
241        let quality = if event_count == 0 {
242            0.0
243        } else if event_count < 10 {
244            0.5
245        } else if event_count < 50 {
246            0.75
247        } else {
248            0.95
249        };
250
251        sources.insert(
252            source_id.clone(),
253            HsiSource {
254                source_type: HsiSourceType::Sensor,
255                quality,
256                degraded: event_count < 10,
257                notes: if event_count < 10 {
258                    Some("Low event count in window".to_string())
259                } else {
260                    None
261                },
262            },
263        );
264
265        // Calculate confidence based on data availability
266        let confidence = quality * 0.9; // Slightly lower than quality
267
268        // Build behavioral axis readings
269        let behavior_readings = vec![
270            // Typing rate (normalized to 0-1 by clamping to max 10 keys/sec)
271            HsiAxisReading {
272                axis: "typing_rate".to_string(),
273                score: Some((features.keyboard.typing_rate / 10.0).min(1.0)),
274                confidence,
275                window_id: window_id.clone(),
276                direction: Some(HsiDirection::HigherIsMore),
277                unit: Some("keys_per_sec_normalized".to_string()),
278                evidence_source_ids: Some(vec![source_id.clone()]),
279                notes: None,
280            },
281            // Burst index (already 0-1)
282            HsiAxisReading {
283                axis: "typing_burstiness".to_string(),
284                score: Some(features.keyboard.burst_index),
285                confidence,
286                window_id: window_id.clone(),
287                direction: Some(HsiDirection::Bidirectional),
288                unit: None,
289                evidence_source_ids: Some(vec![source_id.clone()]),
290                notes: Some("Clustering of keystrokes".to_string()),
291            },
292            // Session continuity (already 0-1)
293            HsiAxisReading {
294                axis: "session_continuity".to_string(),
295                score: Some(features.keyboard.session_continuity),
296                confidence,
297                window_id: window_id.clone(),
298                direction: Some(HsiDirection::HigherIsMore),
299                unit: None,
300                evidence_source_ids: Some(vec![source_id.clone()]),
301                notes: None,
302            },
303            // Idle ratio (already 0-1)
304            HsiAxisReading {
305                axis: "idle_ratio".to_string(),
306                score: Some(features.mouse.idle_ratio),
307                confidence,
308                window_id: window_id.clone(),
309                direction: Some(HsiDirection::HigherIsLess),
310                unit: Some("ratio".to_string()),
311                evidence_source_ids: Some(vec![source_id.clone()]),
312                notes: None,
313            },
314            // Focus continuity proxy (already 0-1)
315            HsiAxisReading {
316                axis: "focus_continuity".to_string(),
317                score: Some(features.behavioral.focus_continuity_proxy),
318                confidence,
319                window_id: window_id.clone(),
320                direction: Some(HsiDirection::HigherIsMore),
321                unit: None,
322                evidence_source_ids: Some(vec![source_id.clone()]),
323                notes: Some("Derived from typing and mouse patterns".to_string()),
324            },
325            // Interaction rhythm (already 0-1)
326            HsiAxisReading {
327                axis: "interaction_rhythm".to_string(),
328                score: Some(features.behavioral.interaction_rhythm),
329                confidence,
330                window_id: window_id.clone(),
331                direction: Some(HsiDirection::HigherIsMore),
332                unit: None,
333                evidence_source_ids: Some(vec![source_id.clone()]),
334                notes: None,
335            },
336            // Motor stability (already 0-1)
337            HsiAxisReading {
338                axis: "motor_stability".to_string(),
339                score: Some(features.behavioral.motor_stability),
340                confidence,
341                window_id: window_id.clone(),
342                direction: Some(HsiDirection::HigherIsMore),
343                unit: None,
344                evidence_source_ids: Some(vec![source_id.clone()]),
345                notes: None,
346            },
347            // Friction (already 0-1)
348            HsiAxisReading {
349                axis: "friction".to_string(),
350                score: Some(features.behavioral.friction),
351                confidence,
352                window_id: window_id.clone(),
353                direction: Some(HsiDirection::HigherIsMore),
354                unit: None,
355                evidence_source_ids: Some(vec![source_id.clone()]),
356                notes: Some("Micro-adjustments and hesitation".to_string()),
357            },
358            // Typing cadence stability (already 0-1)
359            HsiAxisReading {
360                axis: "typing_cadence_stability".to_string(),
361                score: Some(features.keyboard.typing_cadence_stability),
362                confidence,
363                window_id: window_id.clone(),
364                direction: Some(HsiDirection::HigherIsMore),
365                unit: None,
366                evidence_source_ids: Some(vec![source_id.clone()]),
367                notes: Some("Rhythmic consistency of typing".to_string()),
368            },
369            // Typing gap ratio (already 0-1)
370            HsiAxisReading {
371                axis: "typing_gap_ratio".to_string(),
372                score: Some(features.keyboard.typing_gap_ratio),
373                confidence,
374                window_id: window_id.clone(),
375                direction: Some(HsiDirection::HigherIsLess),
376                unit: Some("ratio".to_string()),
377                evidence_source_ids: Some(vec![source_id.clone()]),
378                notes: Some("Proportion of inter-tap intervals classified as gaps".to_string()),
379            },
380            // Typing interaction intensity (already 0-1)
381            HsiAxisReading {
382                axis: "typing_interaction_intensity".to_string(),
383                score: Some(features.keyboard.typing_interaction_intensity),
384                confidence,
385                window_id: window_id.clone(),
386                direction: Some(HsiDirection::HigherIsMore),
387                unit: None,
388                evidence_source_ids: Some(vec![source_id.clone()]),
389                notes: Some("Composite of speed, cadence stability, and gap behavior".to_string()),
390            },
391            // Keyboard scroll rate (normalized to 0-1, capped at 5 keys/sec)
392            HsiAxisReading {
393                axis: "keyboard_scroll_rate".to_string(),
394                score: Some((features.keyboard.keyboard_scroll_rate / 5.0).min(1.0)),
395                confidence,
396                window_id: window_id.clone(),
397                direction: Some(HsiDirection::HigherIsMore),
398                unit: Some("nav_keys_per_sec_normalized".to_string()),
399                evidence_source_ids: Some(vec![source_id.clone()]),
400                notes: Some(
401                    "Navigation keys (arrows, page up/down) - separate from mouse scroll"
402                        .to_string(),
403                ),
404            },
405            // Burstiness (already 0-1)
406            HsiAxisReading {
407                axis: "burstiness".to_string(),
408                score: Some(features.behavioral.burstiness),
409                confidence,
410                window_id: window_id.clone(),
411                direction: Some(HsiDirection::Bidirectional),
412                unit: None,
413                evidence_source_ids: Some(vec![source_id.clone()]),
414                notes: Some(
415                    "Whether interactions occur in clusters (high) or evenly (low)".to_string(),
416                ),
417            },
418        ];
419
420        // Build axes
421        let axes = HsiAxes {
422            affect: None,
423            engagement: None,
424            behavior: Some(HsiAxesDomain {
425                readings: behavior_readings,
426            }),
427        };
428
429        // Build metadata
430        let mut meta = HashMap::new();
431        meta.insert(
432            "keyboard_events".to_string(),
433            serde_json::Value::Number(serde_json::Number::from(window.keyboard_events.len())),
434        );
435        meta.insert(
436            "mouse_events".to_string(),
437            serde_json::Value::Number(serde_json::Number::from(window.mouse_events.len())),
438        );
439        meta.insert(
440            "duration_secs".to_string(),
441            serde_json::Value::Number(
442                serde_json::Number::from_f64(window.duration_secs())
443                    .unwrap_or(serde_json::Number::from(0)),
444            ),
445        );
446        meta.insert(
447            "is_session_start".to_string(),
448            serde_json::Value::Bool(window.is_session_start),
449        );
450        if let Some(ref session_id) = self.session_id {
451            meta.insert(
452                "session_id".to_string(),
453                serde_json::Value::String(session_id.clone()),
454            );
455        }
456        // Include raw feature values in meta for transparency
457        meta.insert(
458            "raw_typing_rate".to_string(),
459            serde_json::Value::Number(
460                serde_json::Number::from_f64(features.keyboard.typing_rate)
461                    .unwrap_or(serde_json::Number::from(0)),
462            ),
463        );
464        meta.insert(
465            "raw_mean_velocity".to_string(),
466            serde_json::Value::Number(
467                serde_json::Number::from_f64(features.mouse.mean_velocity)
468                    .unwrap_or(serde_json::Number::from(0)),
469            ),
470        );
471        meta.insert(
472            "raw_click_rate".to_string(),
473            serde_json::Value::Number(
474                serde_json::Number::from_f64(features.mouse.click_rate)
475                    .unwrap_or(serde_json::Number::from(0)),
476            ),
477        );
478        meta.insert(
479            "typing_tap_count".to_string(),
480            serde_json::Value::Number(serde_json::Number::from(features.keyboard.typing_tap_count)),
481        );
482        meta.insert(
483            "navigation_key_count".to_string(),
484            serde_json::Value::Number(serde_json::Number::from(
485                features.keyboard.navigation_key_count,
486            )),
487        );
488        meta.insert(
489            "keyboard_scroll_rate".to_string(),
490            serde_json::Value::Number(
491                serde_json::Number::from_f64(features.keyboard.keyboard_scroll_rate)
492                    .unwrap_or(serde_json::Number::from(0)),
493            ),
494        );
495        meta.insert(
496            "idle_time_ms".to_string(),
497            serde_json::Value::Number(serde_json::Number::from(features.mouse.idle_time_ms)),
498        );
499        meta.insert(
500            "deep_focus_block".to_string(),
501            serde_json::Value::Bool(features.behavioral.deep_focus_block),
502        );
503        meta.insert(
504            "burstiness".to_string(),
505            serde_json::Value::Number(
506                serde_json::Number::from_f64(features.behavioral.burstiness)
507                    .unwrap_or(serde_json::Number::from(0)),
508            ),
509        );
510
511        HsiSnapshot {
512            hsi_version: HSI_VERSION.to_string(),
513            observed_at_utc: window.end.to_rfc3339(),
514            computed_at_utc: computed_at.to_rfc3339(),
515            producer: HsiProducer {
516                name: PRODUCER_NAME.to_string(),
517                version: env!("CARGO_PKG_VERSION").to_string(),
518                instance_id: Some(self.instance_id.to_string()),
519            },
520            window_ids: vec![window_id],
521            windows,
522            source_ids: Some(vec![source_id]),
523            sources: Some(sources),
524            axes: Some(axes),
525            privacy: HsiPrivacy::default(),
526            meta: Some(meta),
527        }
528    }
529
530    /// Build and serialize an HSI snapshot to JSON.
531    pub fn build_json(&self, window: &EventWindow, features: &WindowFeatures) -> String {
532        let snapshot = self.build(window, features);
533        serde_json::to_string_pretty(&snapshot).unwrap_or_else(|_| "{}".to_string())
534    }
535}
536
537impl Default for HsiBuilder {
538    fn default() -> Self {
539        Self::new()
540    }
541}
542
543#[cfg(test)]
544mod tests {
545    use super::*;
546    use crate::core::features::compute_features;
547    use chrono::Duration;
548
549    #[test]
550    fn test_hsi_builder_instance_id() {
551        let builder1 = HsiBuilder::new();
552        let builder2 = HsiBuilder::new();
553        assert_ne!(builder1.instance_id(), builder2.instance_id());
554    }
555
556    #[test]
557    fn test_hsi_snapshot_creation() {
558        let builder = HsiBuilder::new();
559        let window = EventWindow::new(Utc::now(), Duration::seconds(10));
560        let features = compute_features(&window);
561
562        let snapshot = builder.build(&window, &features);
563
564        assert_eq!(snapshot.hsi_version, HSI_VERSION);
565        assert_eq!(snapshot.producer.name, PRODUCER_NAME);
566        assert!(!snapshot.privacy.contains_pii);
567        assert!(snapshot.privacy.derived_metrics_allowed);
568    }
569
570    #[test]
571    fn test_hsi_1_0_compliance() {
572        let builder = HsiBuilder::new();
573        let window = EventWindow::new(Utc::now(), Duration::seconds(10));
574        let features = compute_features(&window);
575
576        let snapshot = builder.build(&window, &features);
577
578        // Check required top-level fields
579        assert_eq!(snapshot.hsi_version, "1.0");
580        assert!(!snapshot.observed_at_utc.is_empty());
581        assert!(!snapshot.computed_at_utc.is_empty());
582        assert!(!snapshot.window_ids.is_empty());
583        assert!(!snapshot.windows.is_empty());
584
585        // Check window_ids match windows keys
586        for id in &snapshot.window_ids {
587            assert!(snapshot.windows.contains_key(id));
588        }
589
590        // Check privacy constraints
591        assert!(!snapshot.privacy.contains_pii);
592
593        // Check axes structure
594        let axes = snapshot.axes.as_ref().unwrap();
595        let behavior = axes.behavior.as_ref().unwrap();
596        assert!(!behavior.readings.is_empty());
597
598        // Check each reading has required fields
599        for reading in &behavior.readings {
600            assert!(!reading.axis.is_empty());
601            assert!(reading.confidence >= 0.0 && reading.confidence <= 1.0);
602            assert!(!reading.window_id.is_empty());
603            if let Some(score) = reading.score {
604                assert!((0.0..=1.0).contains(&score), "score out of range: {score}");
605            }
606        }
607    }
608
609    #[test]
610    fn test_hsi_json_serialization() {
611        let builder = HsiBuilder::new();
612        let window = EventWindow::new(Utc::now(), Duration::seconds(10));
613        let features = compute_features(&window);
614
615        let json = builder.build_json(&window, &features);
616
617        // Verify JSON contains required fields
618        assert!(json.contains("hsi_version"));
619        assert!(json.contains("observed_at_utc"));
620        assert!(json.contains("computed_at_utc"));
621        assert!(json.contains("producer"));
622        assert!(json.contains("window_ids"));
623        assert!(json.contains("windows"));
624        assert!(json.contains("privacy"));
625        assert!(json.contains("contains_pii"));
626    }
627
628    #[test]
629    fn test_source_quality_calculation() {
630        let builder = HsiBuilder::new();
631        let window = EventWindow::new(Utc::now(), Duration::seconds(10));
632        let features = compute_features(&window);
633
634        let snapshot = builder.build(&window, &features);
635
636        let sources = snapshot.sources.as_ref().unwrap();
637        let source = sources.values().next().unwrap();
638
639        // Empty window should have low quality and be degraded
640        assert!(source.quality < 0.5);
641        assert!(source.degraded);
642    }
643}