Skip to main content

opsis_core/
event.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use ulid::Ulid;
4
5use crate::clock::WorldTick;
6use crate::feed::{FeedSource, SchemaKey};
7use crate::spatial::{GeoHotspot, GeoPoint};
8use crate::state::{StateDomain, Trend};
9
10/// Unique event identifier (ULID by default).
11#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
12pub struct EventId(pub String);
13
14impl Default for EventId {
15    fn default() -> Self {
16        Self(Ulid::new().to_string())
17    }
18}
19
20/// Who produced this event.
21#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
22pub enum EventSource {
23    Feed(FeedSource),
24    Agent(String),
25    Gaia,
26    System,
27    Universe(String),
28}
29
30/// What happened — extensible, forward-compatible.
31#[derive(Debug, Clone, Serialize, Deserialize)]
32#[serde(tag = "type")]
33#[non_exhaustive]
34pub enum OpsisEventKind {
35    // Feed observations
36    WorldObservation {
37        summary: String,
38    },
39
40    // Gaia insights (future)
41    GaiaCorrelation {
42        domains: Vec<StateDomain>,
43        description: String,
44        confidence: f32,
45    },
46    GaiaAnomaly {
47        domain: StateDomain,
48        sigma: f32,
49        description: String,
50    },
51
52    // Agent actions (future)
53    AgentObservation {
54        insight: String,
55        confidence: f32,
56    },
57    AgentAlert {
58        message: String,
59    },
60
61    // Forward-compatible
62    Custom {
63        event_type: String,
64        data: serde_json::Value,
65    },
66}
67
68/// Universal event envelope for all Opsis events.
69#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct OpsisEvent {
71    pub id: EventId,
72    pub tick: WorldTick,
73    pub timestamp: DateTime<Utc>,
74    pub source: EventSource,
75    pub kind: OpsisEventKind,
76    pub location: Option<GeoPoint>,
77    pub domain: Option<StateDomain>,
78    pub severity: Option<f32>,
79    pub schema_key: SchemaKey,
80    pub tags: Vec<String>,
81}
82
83/// A raw event arriving from an external feed, before normalisation.
84#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct RawFeedEvent {
86    /// Unique event id.
87    pub id: EventId,
88    /// When the event was produced by the source.
89    pub timestamp: DateTime<Utc>,
90    /// Which feed produced this event.
91    pub source: FeedSource,
92    /// Schema describing the payload format.
93    pub feed_schema: SchemaKey,
94    /// Optional geographic location.
95    pub location: Option<GeoPoint>,
96    /// Opaque JSON payload from the feed.
97    pub payload: serde_json::Value,
98}
99
100/// Changes to a single state line within one tick.
101#[derive(Debug, Clone, Serialize, Deserialize)]
102pub struct StateLineDelta {
103    /// Which domain changed.
104    pub domain: StateDomain,
105    /// New activity level after this tick.
106    pub activity: f32,
107    /// New trend after this tick.
108    pub trend: Trend,
109    /// Events ingested this tick (top-K by severity).
110    pub new_events: Vec<OpsisEvent>,
111    /// Updated hotspot list.
112    pub hotspots: Vec<GeoHotspot>,
113}
114
115/// Aggregate delta for one world tick.
116#[derive(Debug, Clone, Serialize, Deserialize)]
117pub struct WorldDelta {
118    /// The tick this delta corresponds to.
119    pub tick: WorldTick,
120    /// Wall-clock time.
121    pub timestamp: DateTime<Utc>,
122    /// Per-domain deltas (only domains that changed).
123    pub state_line_deltas: Vec<StateLineDelta>,
124    /// Gaia-generated insights for this tick (cross-domain correlations, anomalies).
125    #[serde(default)]
126    pub gaia_insights: Vec<OpsisEvent>,
127    /// Events without a domain — not dropped, exposed for pattern discovery.
128    #[serde(default)]
129    pub unrouted_events: Vec<OpsisEvent>,
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135
136    #[test]
137    fn event_id_unique() {
138        let a = EventId::default();
139        let b = EventId::default();
140        assert_ne!(a, b);
141    }
142
143    #[test]
144    fn opsis_event_json_roundtrip() {
145        let evt = OpsisEvent {
146            id: EventId::default(),
147            tick: WorldTick(42),
148            timestamp: Utc::now(),
149            source: EventSource::Feed(FeedSource::new("test-feed")),
150            kind: OpsisEventKind::WorldObservation {
151                summary: "Market spike".into(),
152            },
153            location: Some(GeoPoint::new(4.711, -74.072)),
154            domain: Some(StateDomain::Finance),
155            severity: Some(0.8),
156            schema_key: SchemaKey::new("test.v1"),
157            tags: vec!["finance".into(), "spike".into()],
158        };
159        let json = serde_json::to_string(&evt).unwrap();
160        let restored: OpsisEvent = serde_json::from_str(&json).unwrap();
161        assert_eq!(restored.domain, Some(StateDomain::Finance));
162        assert!((restored.severity.unwrap() - 0.8).abs() < f32::EPSILON);
163    }
164
165    #[test]
166    fn world_delta_serializes() {
167        let delta = WorldDelta {
168            tick: WorldTick(1),
169            timestamp: Utc::now(),
170            state_line_deltas: vec![],
171            gaia_insights: vec![],
172            unrouted_events: vec![],
173        };
174        let json = serde_json::to_string(&delta).unwrap();
175        assert!(json.contains("\"tick\""));
176    }
177
178    #[test]
179    fn world_delta_gaia_insights_default_empty() {
180        // Deserialising old JSON (no gaia_insights/unrouted_events field) must not fail.
181        let json = r#"{"tick":1,"timestamp":"2026-01-01T00:00:00Z","state_line_deltas":[]}"#;
182        let delta: WorldDelta = serde_json::from_str(json).unwrap();
183        assert!(delta.gaia_insights.is_empty());
184        assert!(delta.unrouted_events.is_empty());
185    }
186
187    #[test]
188    fn event_source_variants_serialize() {
189        let sources = vec![
190            EventSource::Feed(FeedSource::new("usgs")),
191            EventSource::Agent("arcan-1".into()),
192            EventSource::Gaia,
193            EventSource::System,
194            EventSource::Universe("test-sim".into()),
195        ];
196        for src in sources {
197            let json = serde_json::to_string(&src).unwrap();
198            let restored: EventSource = serde_json::from_str(&json).unwrap();
199            assert_eq!(restored, src);
200        }
201    }
202
203    #[test]
204    fn opsis_event_kind_tagged_serde() {
205        let kind = OpsisEventKind::WorldObservation {
206            summary: "test".into(),
207        };
208        let json = serde_json::to_string(&kind).unwrap();
209        assert!(json.contains("\"type\":\"WorldObservation\""));
210        let _restored: OpsisEventKind = serde_json::from_str(&json).unwrap();
211    }
212
213    #[test]
214    fn custom_event_kind_roundtrip() {
215        let kind = OpsisEventKind::Custom {
216            event_type: "my.custom.event".into(),
217            data: serde_json::json!({"key": "value"}),
218        };
219        let json = serde_json::to_string(&kind).unwrap();
220        let restored: OpsisEventKind = serde_json::from_str(&json).unwrap();
221        match restored {
222            OpsisEventKind::Custom { event_type, data } => {
223                assert_eq!(event_type, "my.custom.event");
224                assert_eq!(data["key"], "value");
225            }
226            _ => panic!("expected Custom variant"),
227        }
228    }
229
230    #[test]
231    fn system_event_no_domain_or_severity() {
232        let evt = OpsisEvent {
233            id: EventId::default(),
234            tick: WorldTick(1),
235            timestamp: Utc::now(),
236            source: EventSource::System,
237            kind: OpsisEventKind::AgentAlert {
238                message: "startup".into(),
239            },
240            location: None,
241            domain: None,
242            severity: None,
243            schema_key: SchemaKey::new("system.v1"),
244            tags: vec![],
245        };
246        let json = serde_json::to_string(&evt).unwrap();
247        let restored: OpsisEvent = serde_json::from_str(&json).unwrap();
248        assert!(restored.domain.is_none());
249        assert!(restored.severity.is_none());
250    }
251}