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,
28 HigherIsLess,
29 Bidirectional,
30}
31
32#[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#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct HsiProducer {
47 pub name: String,
49 pub version: String,
51 #[serde(skip_serializing_if = "Option::is_none")]
53 pub instance_id: Option<String>,
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct HsiWindow {
59 pub start: String,
61 pub end: String,
63 #[serde(skip_serializing_if = "Option::is_none")]
65 pub label: Option<String>,
66}
67
68#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct HsiAxisReading {
71 pub axis: String,
73 pub score: Option<f64>,
75 pub confidence: f64,
77 pub window_id: String,
79 #[serde(skip_serializing_if = "Option::is_none")]
81 pub direction: Option<HsiDirection>,
82 #[serde(skip_serializing_if = "Option::is_none")]
84 pub unit: Option<String>,
85 #[serde(skip_serializing_if = "Option::is_none")]
87 pub evidence_source_ids: Option<Vec<String>>,
88 #[serde(skip_serializing_if = "Option::is_none")]
90 pub notes: Option<String>,
91}
92
93#[derive(Debug, Clone, Serialize, Deserialize)]
95pub struct HsiAxesDomain {
96 pub readings: Vec<HsiAxisReading>,
98}
99
100#[derive(Debug, Clone, Default, Serialize, Deserialize)]
102pub struct HsiAxes {
103 #[serde(skip_serializing_if = "Option::is_none")]
105 pub affect: Option<HsiAxesDomain>,
106 #[serde(skip_serializing_if = "Option::is_none")]
108 pub engagement: Option<HsiAxesDomain>,
109 #[serde(skip_serializing_if = "Option::is_none")]
111 pub behavior: Option<HsiAxesDomain>,
112}
113
114#[derive(Debug, Clone, Serialize, Deserialize)]
116pub struct HsiSource {
117 #[serde(rename = "type")]
119 pub source_type: HsiSourceType,
120 pub quality: f64,
122 pub degraded: bool,
124 #[serde(skip_serializing_if = "Option::is_none")]
126 pub notes: Option<String>,
127}
128
129#[derive(Debug, Clone, Serialize, Deserialize)]
131pub struct HsiPrivacy {
132 pub contains_pii: bool,
134 pub raw_biosignals_allowed: bool,
136 pub derived_metrics_allowed: bool,
138 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
158pub struct HsiSnapshot {
159 pub hsi_version: String,
161 pub observed_at_utc: String,
163 pub computed_at_utc: String,
165 pub producer: HsiProducer,
167 pub window_ids: Vec<String>,
169 pub windows: HashMap<String, HsiWindow>,
171 #[serde(skip_serializing_if = "Option::is_none")]
173 pub source_ids: Option<Vec<String>>,
174 #[serde(skip_serializing_if = "Option::is_none")]
176 pub sources: Option<HashMap<String, HsiSource>>,
177 #[serde(skip_serializing_if = "Option::is_none")]
179 pub axes: Option<HsiAxes>,
180 pub privacy: HsiPrivacy,
182 #[serde(skip_serializing_if = "Option::is_none")]
184 pub meta: Option<HashMap<String, serde_json::Value>>,
185}
186
187pub struct HsiBuilder {
189 instance_id: Uuid,
190 session_id: Option<String>,
191}
192
193impl HsiBuilder {
194 pub fn new() -> Self {
196 Self {
197 instance_id: Uuid::new_v4(),
198 session_id: None,
199 }
200 }
201
202 pub fn with_session_id(mut self, session_id: String) -> Self {
204 self.session_id = Some(session_id);
205 self
206 }
207
208 pub fn instance_id(&self) -> Uuid {
210 self.instance_id
211 }
212
213 pub fn build(&self, window: &EventWindow, features: &WindowFeatures) -> HsiSnapshot {
215 let computed_at = Utc::now();
216
217 let window_id = format!("w_{}", computed_at.timestamp_millis());
219
220 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 let source_id = format!("s_keyboard_mouse_{}", self.instance_id);
237 let mut sources = HashMap::new();
238
239 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 let confidence = quality * 0.9; let behavior_readings = vec![
270 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 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 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 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 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 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 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 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 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 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 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 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 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 let axes = HsiAxes {
422 affect: None,
423 engagement: None,
424 behavior: Some(HsiAxesDomain {
425 readings: behavior_readings,
426 }),
427 };
428
429 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 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 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 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 for id in &snapshot.window_ids {
587 assert!(snapshot.windows.contains_key(id));
588 }
589
590 assert!(!snapshot.privacy.contains_pii);
592
593 let axes = snapshot.axes.as_ref().unwrap();
595 let behavior = axes.behavior.as_ref().unwrap();
596 assert!(!behavior.readings.is_empty());
597
598 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 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 assert!(source.quality < 0.5);
641 assert!(source.degraded);
642 }
643}