Skip to main content

rvcsi_core/
event.rs

1//! The [`CsiEvent`] aggregate — semantic interpretation of one or more windows.
2
3use serde::{Deserialize, Serialize};
4
5use crate::ids::{EventId, SessionId, SourceId, WindowId};
6
7/// Kinds of event the runtime emits (ADR-095 FR5).
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
9pub enum CsiEventKind {
10    /// Presence appeared in the sensed space.
11    PresenceStarted,
12    /// Presence ended.
13    PresenceEnded,
14    /// Motion above threshold detected.
15    MotionDetected,
16    /// Motion fell back to baseline.
17    MotionSettled,
18    /// The learned baseline shifted (re-calibration may be warranted).
19    BaselineChanged,
20    /// Signal quality dropped below a usable threshold.
21    SignalQualityDropped,
22    /// The source disconnected.
23    DeviceDisconnected,
24    /// A candidate breathing-rate observation (when signal quality permits).
25    BreathingCandidate,
26    /// A significant unexplained deviation.
27    AnomalyDetected,
28    /// Calibration is required before detection can be trusted.
29    CalibrationRequired,
30}
31
32impl CsiEventKind {
33    /// Stable lower-case slug used in logs and the SDK (`"presence_started"`...).
34    pub fn slug(self) -> &'static str {
35        match self {
36            CsiEventKind::PresenceStarted => "presence_started",
37            CsiEventKind::PresenceEnded => "presence_ended",
38            CsiEventKind::MotionDetected => "motion_detected",
39            CsiEventKind::MotionSettled => "motion_settled",
40            CsiEventKind::BaselineChanged => "baseline_changed",
41            CsiEventKind::SignalQualityDropped => "signal_quality_dropped",
42            CsiEventKind::DeviceDisconnected => "device_disconnected",
43            CsiEventKind::BreathingCandidate => "breathing_candidate",
44            CsiEventKind::AnomalyDetected => "anomaly_detected",
45            CsiEventKind::CalibrationRequired => "calibration_required",
46        }
47    }
48}
49
50/// A detected event with confidence and the evidence windows that justify it.
51///
52/// Invariant: `evidence_window_ids` is non-empty and `0.0 <= confidence <= 1.0`.
53#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
54pub struct CsiEvent {
55    /// Event id.
56    pub event_id: EventId,
57    /// What happened.
58    pub kind: CsiEventKind,
59    /// Owning session.
60    pub session_id: SessionId,
61    /// Source that produced the evidence.
62    pub source_id: SourceId,
63    /// When the event was detected (ns).
64    pub timestamp_ns: u64,
65    /// Confidence in `[0.0, 1.0]`.
66    pub confidence: f32,
67    /// Windows that justify this event (at least one).
68    pub evidence_window_ids: Vec<WindowId>,
69    /// Calibration version detection ran against, if any.
70    pub calibration_version: Option<String>,
71    /// Free-form JSON metadata (motion energy, estimated rate, ...).
72    pub metadata_json: String,
73}
74
75/// Why a [`CsiEvent`] is malformed.
76#[derive(Debug, Clone, PartialEq, thiserror::Error)]
77#[non_exhaustive]
78pub enum EventError {
79    /// No evidence window referenced.
80    #[error("event has no evidence window")]
81    NoEvidence,
82    /// `confidence` escaped `[0, 1]`.
83    #[error("confidence {0} out of [0,1]")]
84    ConfidenceOutOfRange(f32),
85}
86
87impl CsiEvent {
88    /// Minimal constructor; sets `metadata_json` to `"{}"`.
89    pub fn new(
90        event_id: EventId,
91        kind: CsiEventKind,
92        session_id: SessionId,
93        source_id: SourceId,
94        timestamp_ns: u64,
95        confidence: f32,
96        evidence_window_ids: Vec<WindowId>,
97    ) -> Self {
98        CsiEvent {
99            event_id,
100            kind,
101            session_id,
102            source_id,
103            timestamp_ns,
104            confidence,
105            evidence_window_ids,
106            calibration_version: None,
107            metadata_json: "{}".to_string(),
108        }
109    }
110
111    /// Attach a calibration version.
112    pub fn with_calibration(mut self, version: impl Into<String>) -> Self {
113        self.calibration_version = Some(version.into());
114        self
115    }
116
117    /// Attach metadata (any serializable value).
118    pub fn with_metadata<T: Serialize>(mut self, meta: &T) -> Result<Self, serde_json::Error> {
119        self.metadata_json = serde_json::to_string(meta)?;
120        Ok(self)
121    }
122
123    /// Check the aggregate invariant.
124    pub fn validate(&self) -> Result<(), EventError> {
125        if self.evidence_window_ids.is_empty() {
126            return Err(EventError::NoEvidence);
127        }
128        if !(0.0..=1.0).contains(&self.confidence) || !self.confidence.is_finite() {
129            return Err(EventError::ConfidenceOutOfRange(self.confidence));
130        }
131        Ok(())
132    }
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138
139    #[test]
140    fn slugs_are_stable() {
141        assert_eq!(CsiEventKind::PresenceStarted.slug(), "presence_started");
142        assert_eq!(CsiEventKind::AnomalyDetected.slug(), "anomaly_detected");
143    }
144
145    #[test]
146    fn requires_evidence_and_bounded_confidence() {
147        let mut e = CsiEvent::new(
148            EventId(0),
149            CsiEventKind::MotionDetected,
150            SessionId(0),
151            SourceId::from("t"),
152            1_000,
153            0.7,
154            vec![WindowId(3)],
155        );
156        assert!(e.validate().is_ok());
157
158        e.evidence_window_ids.clear();
159        assert_eq!(e.validate(), Err(EventError::NoEvidence));
160
161        e.evidence_window_ids.push(WindowId(3));
162        e.confidence = 1.2;
163        assert_eq!(e.validate(), Err(EventError::ConfidenceOutOfRange(1.2)));
164    }
165
166    #[test]
167    fn metadata_and_calibration_roundtrip() {
168        #[derive(Serialize)]
169        struct M {
170            motion_energy: f32,
171        }
172        let e = CsiEvent::new(
173            EventId(1),
174            CsiEventKind::PresenceStarted,
175            SessionId(0),
176            SourceId::from("t"),
177            5,
178            0.9,
179            vec![WindowId(0)],
180        )
181        .with_calibration("livingroom@v3")
182        .with_metadata(&M { motion_energy: 1.25 })
183        .unwrap();
184        assert_eq!(e.calibration_version.as_deref(), Some("livingroom@v3"));
185        assert!(e.metadata_json.contains("1.25"));
186        let json = serde_json::to_string(&e).unwrap();
187        assert_eq!(serde_json::from_str::<CsiEvent>(&json).unwrap(), e);
188    }
189}