1use crate::core::features::WindowFeatures;
7use crate::core::windowing::EventWindow;
8use chrono::Utc;
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11use uuid::Uuid;
12
13pub const HSI_VERSION: &str = "1.0";
15
16pub const PRODUCER_NAME: &str = "synheart-sensor-agent";
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
25#[serde(rename_all = "snake_case")]
26pub enum HsiDirection {
27 HigherIsMore,
29 HigherIsLess,
31 Bidirectional,
33}
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
37#[serde(rename_all = "snake_case")]
38pub enum HsiSourceType {
39 Sensor,
41 App,
43 SelfReport,
45 Observer,
47 Derived,
49 Other,
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct HsiProducer {
56 pub name: String,
58 pub version: String,
60 #[serde(skip_serializing_if = "Option::is_none")]
62 pub instance_id: Option<String>,
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct HsiWindow {
68 pub start: String,
70 pub end: String,
72 #[serde(skip_serializing_if = "Option::is_none")]
74 pub label: Option<String>,
75}
76
77#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct HsiAxisReading {
80 pub axis: String,
82 pub score: Option<f64>,
84 pub confidence: f64,
86 pub window_id: String,
88 #[serde(skip_serializing_if = "Option::is_none")]
90 pub direction: Option<HsiDirection>,
91 #[serde(skip_serializing_if = "Option::is_none")]
93 pub unit: Option<String>,
94 #[serde(skip_serializing_if = "Option::is_none")]
96 pub evidence_source_ids: Option<Vec<String>>,
97 #[serde(skip_serializing_if = "Option::is_none")]
99 pub notes: Option<String>,
100}
101
102#[derive(Debug, Clone, Serialize, Deserialize)]
104pub struct HsiAxesDomain {
105 pub readings: Vec<HsiAxisReading>,
107}
108
109#[derive(Debug, Clone, Default, Serialize, Deserialize)]
111pub struct HsiAxes {
112 #[serde(skip_serializing_if = "Option::is_none")]
114 pub affect: Option<HsiAxesDomain>,
115 #[serde(skip_serializing_if = "Option::is_none")]
117 pub engagement: Option<HsiAxesDomain>,
118 #[serde(skip_serializing_if = "Option::is_none")]
120 pub behavior: Option<HsiAxesDomain>,
121}
122
123#[derive(Debug, Clone, Serialize, Deserialize)]
125pub struct HsiSource {
126 #[serde(rename = "type")]
128 pub source_type: HsiSourceType,
129 pub quality: f64,
131 pub degraded: bool,
133 #[serde(skip_serializing_if = "Option::is_none")]
135 pub notes: Option<String>,
136}
137
138#[derive(Debug, Clone, Serialize, Deserialize)]
140pub struct HsiPrivacy {
141 pub contains_pii: bool,
143 pub raw_biosignals_allowed: bool,
145 pub derived_metrics_allowed: bool,
147 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
167pub struct HsiSnapshot {
168 pub hsi_version: String,
170 pub observed_at_utc: String,
172 pub computed_at_utc: String,
174 pub producer: HsiProducer,
176 pub window_ids: Vec<String>,
178 pub windows: HashMap<String, HsiWindow>,
180 #[serde(skip_serializing_if = "Option::is_none")]
182 pub source_ids: Option<Vec<String>>,
183 #[serde(skip_serializing_if = "Option::is_none")]
185 pub sources: Option<HashMap<String, HsiSource>>,
186 #[serde(skip_serializing_if = "Option::is_none")]
188 pub axes: Option<HsiAxes>,
189 pub privacy: HsiPrivacy,
191 #[serde(skip_serializing_if = "Option::is_none")]
193 pub meta: Option<HashMap<String, serde_json::Value>>,
194}
195
196pub struct HsiBuilder {
198 instance_id: Uuid,
199 session_id: Option<String>,
200}
201
202impl HsiBuilder {
203 pub fn new() -> Self {
205 Self {
206 instance_id: Uuid::new_v4(),
207 session_id: None,
208 }
209 }
210
211 pub fn with_session_id(mut self, session_id: String) -> Self {
213 self.session_id = Some(session_id);
214 self
215 }
216
217 pub fn instance_id(&self) -> Uuid {
219 self.instance_id
220 }
221
222 pub fn build(&self, window: &EventWindow, features: &WindowFeatures) -> HsiSnapshot {
224 let computed_at = Utc::now();
225
226 let window_id = format!("w_{}", computed_at.timestamp_millis());
228
229 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 let source_id = format!("s_keyboard_mouse_{}", self.instance_id);
246 let mut sources = HashMap::new();
247
248 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 let confidence = quality * 0.9; let behavior_readings = vec![
279 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 let axes = HsiAxes {
464 affect: None,
465 engagement: None,
466 behavior: Some(HsiAxesDomain {
467 readings: behavior_readings,
468 }),
469 };
470
471 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 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 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 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 for id in &snapshot.window_ids {
673 assert!(snapshot.windows.contains_key(id));
674 }
675
676 assert!(!snapshot.privacy.contains_pii);
678
679 let axes = snapshot.axes.as_ref().unwrap();
681 let behavior = axes.behavior.as_ref().unwrap();
682 assert!(!behavior.readings.is_empty());
683
684 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 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 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}