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#[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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
32#[serde(tag = "type")]
33#[non_exhaustive]
34pub enum OpsisEventKind {
35 WorldObservation {
37 summary: String,
38 },
39
40 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 AgentObservation {
54 insight: String,
55 confidence: f32,
56 },
57 AgentAlert {
58 message: String,
59 },
60
61 Custom {
63 event_type: String,
64 data: serde_json::Value,
65 },
66}
67
68#[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#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct RawFeedEvent {
86 pub id: EventId,
88 pub timestamp: DateTime<Utc>,
90 pub source: FeedSource,
92 pub feed_schema: SchemaKey,
94 pub location: Option<GeoPoint>,
96 pub payload: serde_json::Value,
98}
99
100#[derive(Debug, Clone, Serialize, Deserialize)]
102pub struct StateLineDelta {
103 pub domain: StateDomain,
105 pub activity: f32,
107 pub trend: Trend,
109 pub new_events: Vec<OpsisEvent>,
111 pub hotspots: Vec<GeoHotspot>,
113}
114
115#[derive(Debug, Clone, Serialize, Deserialize)]
117pub struct WorldDelta {
118 pub tick: WorldTick,
120 pub timestamp: DateTime<Utc>,
122 pub state_line_deltas: Vec<StateLineDelta>,
124 #[serde(default)]
126 pub gaia_insights: Vec<OpsisEvent>,
127 #[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 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}