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