use crate::core::features::WindowFeatures;
use crate::core::windowing::EventWindow;
use chrono::Utc;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use uuid::Uuid;
pub const HSI_VERSION: &str = "1.0";
pub const PRODUCER_NAME: &str = "synheart-sensor-agent";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum HsiDirection {
HigherIsMore,
HigherIsLess,
Bidirectional,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum HsiSourceType {
Sensor,
App,
SelfReport,
Observer,
Derived,
Other,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HsiProducer {
pub name: String,
pub version: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub instance_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HsiWindow {
pub start: String,
pub end: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HsiAxisReading {
pub axis: String,
pub score: Option<f64>,
pub confidence: f64,
pub window_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub direction: Option<HsiDirection>,
#[serde(skip_serializing_if = "Option::is_none")]
pub unit: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub evidence_source_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub notes: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HsiAxesDomain {
pub readings: Vec<HsiAxisReading>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct HsiAxes {
#[serde(skip_serializing_if = "Option::is_none")]
pub affect: Option<HsiAxesDomain>,
#[serde(skip_serializing_if = "Option::is_none")]
pub engagement: Option<HsiAxesDomain>,
#[serde(skip_serializing_if = "Option::is_none")]
pub behavior: Option<HsiAxesDomain>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HsiSource {
#[serde(rename = "type")]
pub source_type: HsiSourceType,
pub quality: f64,
pub degraded: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub notes: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HsiPrivacy {
pub contains_pii: bool,
pub raw_biosignals_allowed: bool,
pub derived_metrics_allowed: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub notes: Option<String>,
}
impl Default for HsiPrivacy {
fn default() -> Self {
Self {
contains_pii: false,
raw_biosignals_allowed: false,
derived_metrics_allowed: true,
notes: Some(
"No key content or coordinates captured - timing and magnitude only".to_string(),
),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HsiSnapshot {
pub hsi_version: String,
pub observed_at_utc: String,
pub computed_at_utc: String,
pub producer: HsiProducer,
pub window_ids: Vec<String>,
pub windows: HashMap<String, HsiWindow>,
#[serde(skip_serializing_if = "Option::is_none")]
pub source_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sources: Option<HashMap<String, HsiSource>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub axes: Option<HsiAxes>,
pub privacy: HsiPrivacy,
#[serde(skip_serializing_if = "Option::is_none")]
pub meta: Option<HashMap<String, serde_json::Value>>,
}
pub struct HsiBuilder {
instance_id: Uuid,
session_id: Option<String>,
}
impl HsiBuilder {
pub fn new() -> Self {
Self {
instance_id: Uuid::new_v4(),
session_id: None,
}
}
pub fn with_session_id(mut self, session_id: String) -> Self {
self.session_id = Some(session_id);
self
}
pub fn instance_id(&self) -> Uuid {
self.instance_id
}
pub fn build(&self, window: &EventWindow, features: &WindowFeatures) -> HsiSnapshot {
let computed_at = Utc::now();
let window_id = format!("w_{}", computed_at.timestamp_millis());
let mut windows = HashMap::new();
windows.insert(
window_id.clone(),
HsiWindow {
start: window.start.to_rfc3339(),
end: window.end.to_rfc3339(),
label: if window.is_session_start {
Some("session_start".to_string())
} else {
None
},
},
);
let source_id = format!("s_keyboard_mouse_{}", self.instance_id);
let mut sources = HashMap::new();
let event_count = window.event_count();
let quality = if event_count == 0 {
0.0
} else if event_count < 10 {
0.5
} else if event_count < 50 {
0.75
} else {
0.95
};
sources.insert(
source_id.clone(),
HsiSource {
source_type: HsiSourceType::Sensor,
quality,
degraded: event_count < 10,
notes: if event_count < 10 {
Some("Low event count in window".to_string())
} else {
None
},
},
);
let confidence = quality * 0.9;
let behavior_readings = vec![
HsiAxisReading {
axis: "typing_rate".to_string(),
score: Some((features.keyboard.typing_rate / 10.0).min(1.0)),
confidence,
window_id: window_id.clone(),
direction: Some(HsiDirection::HigherIsMore),
unit: Some("keys_per_sec_normalized".to_string()),
evidence_source_ids: Some(vec![source_id.clone()]),
notes: None,
},
HsiAxisReading {
axis: "typing_burstiness".to_string(),
score: Some(features.keyboard.burst_index),
confidence,
window_id: window_id.clone(),
direction: Some(HsiDirection::Bidirectional),
unit: None,
evidence_source_ids: Some(vec![source_id.clone()]),
notes: Some("Clustering of keystrokes".to_string()),
},
HsiAxisReading {
axis: "session_continuity".to_string(),
score: Some(features.keyboard.session_continuity),
confidence,
window_id: window_id.clone(),
direction: Some(HsiDirection::HigherIsMore),
unit: None,
evidence_source_ids: Some(vec![source_id.clone()]),
notes: None,
},
HsiAxisReading {
axis: "idle_ratio".to_string(),
score: Some(features.mouse.idle_ratio),
confidence,
window_id: window_id.clone(),
direction: Some(HsiDirection::HigherIsLess),
unit: Some("ratio".to_string()),
evidence_source_ids: Some(vec![source_id.clone()]),
notes: None,
},
HsiAxisReading {
axis: "focus_continuity".to_string(),
score: Some(features.behavioral.focus_continuity_proxy),
confidence,
window_id: window_id.clone(),
direction: Some(HsiDirection::HigherIsMore),
unit: None,
evidence_source_ids: Some(vec![source_id.clone()]),
notes: Some("Derived from typing and mouse patterns".to_string()),
},
HsiAxisReading {
axis: "interaction_rhythm".to_string(),
score: Some(features.behavioral.interaction_rhythm),
confidence,
window_id: window_id.clone(),
direction: Some(HsiDirection::HigherIsMore),
unit: None,
evidence_source_ids: Some(vec![source_id.clone()]),
notes: None,
},
HsiAxisReading {
axis: "motor_stability".to_string(),
score: Some(features.behavioral.motor_stability),
confidence,
window_id: window_id.clone(),
direction: Some(HsiDirection::HigherIsMore),
unit: None,
evidence_source_ids: Some(vec![source_id.clone()]),
notes: None,
},
HsiAxisReading {
axis: "friction".to_string(),
score: Some(features.behavioral.friction),
confidence,
window_id: window_id.clone(),
direction: Some(HsiDirection::HigherIsMore),
unit: None,
evidence_source_ids: Some(vec![source_id.clone()]),
notes: Some("Micro-adjustments and hesitation".to_string()),
},
HsiAxisReading {
axis: "typing_cadence_stability".to_string(),
score: Some(features.keyboard.typing_cadence_stability),
confidence,
window_id: window_id.clone(),
direction: Some(HsiDirection::HigherIsMore),
unit: None,
evidence_source_ids: Some(vec![source_id.clone()]),
notes: Some("Rhythmic consistency of typing".to_string()),
},
HsiAxisReading {
axis: "typing_gap_ratio".to_string(),
score: Some(features.keyboard.typing_gap_ratio),
confidence,
window_id: window_id.clone(),
direction: Some(HsiDirection::HigherIsLess),
unit: Some("ratio".to_string()),
evidence_source_ids: Some(vec![source_id.clone()]),
notes: Some("Proportion of inter-tap intervals classified as gaps".to_string()),
},
HsiAxisReading {
axis: "typing_interaction_intensity".to_string(),
score: Some(features.keyboard.typing_interaction_intensity),
confidence,
window_id: window_id.clone(),
direction: Some(HsiDirection::HigherIsMore),
unit: None,
evidence_source_ids: Some(vec![source_id.clone()]),
notes: Some("Composite of speed, cadence stability, and gap behavior".to_string()),
},
HsiAxisReading {
axis: "keyboard_scroll_rate".to_string(),
score: Some((features.keyboard.keyboard_scroll_rate / 5.0).min(1.0)),
confidence,
window_id: window_id.clone(),
direction: Some(HsiDirection::HigherIsMore),
unit: Some("nav_keys_per_sec_normalized".to_string()),
evidence_source_ids: Some(vec![source_id.clone()]),
notes: Some(
"Navigation keys (arrows, page up/down) - separate from mouse scroll"
.to_string(),
),
},
HsiAxisReading {
axis: "burstiness".to_string(),
score: Some(features.behavioral.burstiness),
confidence,
window_id: window_id.clone(),
direction: Some(HsiDirection::Bidirectional),
unit: None,
evidence_source_ids: Some(vec![source_id.clone()]),
notes: Some(
"Whether interactions occur in clusters (high) or evenly (low)".to_string(),
),
},
HsiAxisReading {
axis: "correction_rate".to_string(),
score: Some(features.keyboard.correction_rate.min(1.0)),
confidence,
window_id: window_id.clone(),
direction: Some(HsiDirection::HigherIsLess),
unit: Some("ratio".to_string()),
evidence_source_ids: Some(vec![source_id.clone()]),
notes: Some("Ratio of correction keys to typing keys".to_string()),
},
HsiAxisReading {
axis: "typing_efficiency".to_string(),
score: Some(features.keyboard.typing_efficiency),
confidence,
window_id: window_id.clone(),
direction: Some(HsiDirection::HigherIsMore),
unit: None,
evidence_source_ids: Some(vec![source_id.clone()]),
notes: Some("1.0 - correction_rate, clamped to 0-1".to_string()),
},
HsiAxisReading {
axis: "shortcut_intensity".to_string(),
score: Some((features.keyboard.shortcut_rate / 2.0).min(1.0)),
confidence,
window_id: window_id.clone(),
direction: Some(HsiDirection::HigherIsMore),
unit: Some("shortcuts_per_sec_normalized".to_string()),
evidence_source_ids: Some(vec![source_id.clone()]),
notes: Some("Rate of keyboard shortcuts (copy, paste, etc.)".to_string()),
},
];
let axes = HsiAxes {
affect: None,
engagement: None,
behavior: Some(HsiAxesDomain {
readings: behavior_readings,
}),
};
let mut meta = HashMap::new();
meta.insert(
"keyboard_events".to_string(),
serde_json::Value::Number(serde_json::Number::from(window.keyboard_events.len())),
);
meta.insert(
"mouse_events".to_string(),
serde_json::Value::Number(serde_json::Number::from(window.mouse_events.len())),
);
meta.insert(
"duration_secs".to_string(),
serde_json::Value::Number(
serde_json::Number::from_f64(window.duration_secs())
.unwrap_or(serde_json::Number::from(0)),
),
);
meta.insert(
"is_session_start".to_string(),
serde_json::Value::Bool(window.is_session_start),
);
if let Some(ref session_id) = self.session_id {
meta.insert(
"session_id".to_string(),
serde_json::Value::String(session_id.clone()),
);
}
if let Some(ref app_id) = window.app_id {
meta.insert(
"app_id".to_string(),
serde_json::Value::String(app_id.clone()),
);
}
meta.insert(
"raw_typing_rate".to_string(),
serde_json::Value::Number(
serde_json::Number::from_f64(features.keyboard.typing_rate)
.unwrap_or(serde_json::Number::from(0)),
),
);
meta.insert(
"raw_mean_velocity".to_string(),
serde_json::Value::Number(
serde_json::Number::from_f64(features.mouse.mean_velocity)
.unwrap_or(serde_json::Number::from(0)),
),
);
meta.insert(
"raw_click_rate".to_string(),
serde_json::Value::Number(
serde_json::Number::from_f64(features.mouse.click_rate)
.unwrap_or(serde_json::Number::from(0)),
),
);
meta.insert(
"typing_tap_count".to_string(),
serde_json::Value::Number(serde_json::Number::from(features.keyboard.typing_tap_count)),
);
meta.insert(
"navigation_key_count".to_string(),
serde_json::Value::Number(serde_json::Number::from(
features.keyboard.navigation_key_count,
)),
);
meta.insert(
"keyboard_scroll_rate".to_string(),
serde_json::Value::Number(
serde_json::Number::from_f64(features.keyboard.keyboard_scroll_rate)
.unwrap_or(serde_json::Number::from(0)),
),
);
meta.insert(
"idle_time_ms".to_string(),
serde_json::Value::Number(serde_json::Number::from(features.mouse.idle_time_ms)),
);
meta.insert(
"deep_focus_block".to_string(),
serde_json::Value::Bool(features.behavioral.deep_focus_block),
);
meta.insert(
"burstiness".to_string(),
serde_json::Value::Number(
serde_json::Number::from_f64(features.behavioral.burstiness)
.unwrap_or(serde_json::Number::from(0)),
),
);
meta.insert(
"backspace_count".to_string(),
serde_json::Value::Number(serde_json::Number::from(features.keyboard.backspace_count)),
);
meta.insert(
"delete_count".to_string(),
serde_json::Value::Number(serde_json::Number::from(features.keyboard.delete_count)),
);
meta.insert(
"correction_rate".to_string(),
serde_json::Value::Number(
serde_json::Number::from_f64(features.keyboard.correction_rate)
.unwrap_or(serde_json::Number::from(0)),
),
);
meta.insert(
"enter_count".to_string(),
serde_json::Value::Number(serde_json::Number::from(features.keyboard.enter_count)),
);
meta.insert(
"tab_count".to_string(),
serde_json::Value::Number(serde_json::Number::from(features.keyboard.tab_count)),
);
meta.insert(
"escape_count".to_string(),
serde_json::Value::Number(serde_json::Number::from(features.keyboard.escape_count)),
);
meta.insert(
"shortcut_count".to_string(),
serde_json::Value::Number(serde_json::Number::from(features.keyboard.shortcut_count)),
);
meta.insert(
"shortcut_rate".to_string(),
serde_json::Value::Number(
serde_json::Number::from_f64(features.keyboard.shortcut_rate)
.unwrap_or(serde_json::Number::from(0)),
),
);
HsiSnapshot {
hsi_version: HSI_VERSION.to_string(),
observed_at_utc: window.end.to_rfc3339(),
computed_at_utc: computed_at.to_rfc3339(),
producer: HsiProducer {
name: PRODUCER_NAME.to_string(),
version: env!("CARGO_PKG_VERSION").to_string(),
instance_id: Some(self.instance_id.to_string()),
},
window_ids: vec![window_id],
windows,
source_ids: Some(vec![source_id]),
sources: Some(sources),
axes: Some(axes),
privacy: HsiPrivacy::default(),
meta: Some(meta),
}
}
pub fn build_json(&self, window: &EventWindow, features: &WindowFeatures) -> String {
let snapshot = self.build(window, features);
serde_json::to_string_pretty(&snapshot).unwrap_or_else(|_| "{}".to_string())
}
}
impl Default for HsiBuilder {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::features::compute_features;
use chrono::Duration;
#[test]
fn test_hsi_builder_instance_id() {
let builder1 = HsiBuilder::new();
let builder2 = HsiBuilder::new();
assert_ne!(builder1.instance_id(), builder2.instance_id());
}
#[test]
fn test_hsi_snapshot_creation() {
let builder = HsiBuilder::new();
let window = EventWindow::new(Utc::now(), Duration::seconds(10));
let features = compute_features(&window);
let snapshot = builder.build(&window, &features);
assert_eq!(snapshot.hsi_version, HSI_VERSION);
assert_eq!(snapshot.producer.name, PRODUCER_NAME);
assert!(!snapshot.privacy.contains_pii);
assert!(snapshot.privacy.derived_metrics_allowed);
}
#[test]
fn test_hsi_1_0_compliance() {
let builder = HsiBuilder::new();
let window = EventWindow::new(Utc::now(), Duration::seconds(10));
let features = compute_features(&window);
let snapshot = builder.build(&window, &features);
assert_eq!(snapshot.hsi_version, "1.0");
assert!(!snapshot.observed_at_utc.is_empty());
assert!(!snapshot.computed_at_utc.is_empty());
assert!(!snapshot.window_ids.is_empty());
assert!(!snapshot.windows.is_empty());
for id in &snapshot.window_ids {
assert!(snapshot.windows.contains_key(id));
}
assert!(!snapshot.privacy.contains_pii);
let axes = snapshot.axes.as_ref().unwrap();
let behavior = axes.behavior.as_ref().unwrap();
assert!(!behavior.readings.is_empty());
for reading in &behavior.readings {
assert!(!reading.axis.is_empty());
assert!(reading.confidence >= 0.0 && reading.confidence <= 1.0);
assert!(!reading.window_id.is_empty());
if let Some(score) = reading.score {
assert!((0.0..=1.0).contains(&score), "score out of range: {score}");
}
}
}
#[test]
fn test_hsi_json_serialization() {
let builder = HsiBuilder::new();
let window = EventWindow::new(Utc::now(), Duration::seconds(10));
let features = compute_features(&window);
let json = builder.build_json(&window, &features);
assert!(json.contains("hsi_version"));
assert!(json.contains("observed_at_utc"));
assert!(json.contains("computed_at_utc"));
assert!(json.contains("producer"));
assert!(json.contains("window_ids"));
assert!(json.contains("windows"));
assert!(json.contains("privacy"));
assert!(json.contains("contains_pii"));
}
#[test]
fn test_source_quality_calculation() {
let builder = HsiBuilder::new();
let window = EventWindow::new(Utc::now(), Duration::seconds(10));
let features = compute_features(&window);
let snapshot = builder.build(&window, &features);
let sources = snapshot.sources.as_ref().unwrap();
let source = sources.values().next().unwrap();
assert!(source.quality < 0.5);
assert!(source.degraded);
}
#[test]
fn test_hsi_meta_includes_app_id_when_present() {
let builder = HsiBuilder::new();
let mut window = EventWindow::new(Utc::now(), Duration::seconds(10));
window.app_id = Some("com.test.App".to_string());
let features = compute_features(&window);
let snapshot = builder.build(&window, &features);
let meta = snapshot.meta.as_ref().unwrap();
assert_eq!(
meta.get("app_id"),
Some(&serde_json::Value::String("com.test.App".to_string()))
);
}
#[test]
fn test_hsi_meta_excludes_app_id_when_none() {
let builder = HsiBuilder::new();
let window = EventWindow::new(Utc::now(), Duration::seconds(10));
let features = compute_features(&window);
let snapshot = builder.build(&window, &features);
let meta = snapshot.meta.as_ref().unwrap();
assert!(!meta.contains_key("app_id"));
}
}