somatize_core/event.rs
1//! Runtime lifecycle events — emitted during plan execution.
2//!
3//! Events track run/node/study/trial state transitions and are
4//! broadcast via the runtime's `EventBus` for observability and debugging.
5
6use crate::cache::{CacheKey, CacheTier};
7use crate::filter::FilterKind;
8use crate::graph::NodeId;
9use chrono::{DateTime, Utc};
10use serde::{Deserialize, Serialize};
11use std::time::Duration;
12
13/// Unique identifier for a pipeline run.
14pub type RunId = String;
15
16/// Unique identifier for an optimization study.
17pub type StudyId = String;
18
19/// Unique identifier for a trial within a study.
20pub type TrialId = String;
21
22/// A metric measurement reported during training.
23#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
24pub struct MetricRecord {
25 /// Metric name, e.g. `loss` or `val_f1`.
26 pub name: String,
27 /// The measured value.
28 pub value: f64,
29 /// Training step at which the measurement was taken.
30 pub step: usize,
31 /// When the measurement was recorded.
32 pub timestamp: DateTime<Utc>,
33}
34
35/// Summary of a compiled plan (for event payloads without the full plan).
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct PlanSummary {
38 /// Number of nodes in the compiled plan.
39 pub total_nodes: usize,
40 /// Always 0 from a compiled plan, and not a placeholder: cache keys are
41 /// resolved per node at run time (a node's key depends on its input's
42 /// content hash), so at `RunStarted` nothing is yet known about what
43 /// will be served from cache. The answer arrives as
44 /// [`Event::NodeCacheHit`] per node — count those, not this.
45 pub cached_nodes: usize,
46 /// Number of branches the plan can execute concurrently.
47 pub parallel_branches: usize,
48}
49
50/// Structured events emitted during execution at three levels.
51#[derive(Debug, Clone, Serialize, Deserialize)]
52#[serde(tag = "event_type")]
53#[non_exhaustive]
54pub enum Event {
55 // ── Level 1: Pipeline execution (per run) ──
56 /// A pipeline run has started.
57 RunStarted {
58 /// The run this event belongs to.
59 run_id: RunId,
60 /// Coarse shape of the plan about to execute — see
61 /// [`PlanSummary::cached_nodes`] for what it deliberately cannot say.
62 plan_summary: PlanSummary,
63 },
64
65 /// A filter node has started execution.
66 NodeStarted {
67 /// The run this event belongs to.
68 run_id: RunId,
69 /// The node this event concerns.
70 node_id: NodeId,
71 /// Structural kind of the node, from its metadata — see [`FilterKind`].
72 kind: FilterKind,
73 /// Does this node reach outside the graph — a model, a tool, a
74 /// person?
75 ///
76 /// An effectful node has no honest [`FilterKind`], and reporting
77 /// it as `Opaque` made every consumer see an agent as a filter it
78 /// could not look inside. Defaulted so run logs written before
79 /// this field existed still parse.
80 #[serde(default)]
81 effectful: bool,
82 },
83
84 /// A filter node reports progress (0.0 to 1.0).
85 NodeProgress {
86 /// The run this event belongs to.
87 run_id: RunId,
88 /// The node this event concerns.
89 node_id: NodeId,
90 /// Fraction complete, from 0.0 to 1.0.
91 progress: f32,
92 },
93
94 /// A filter node's result was loaded from cache.
95 NodeCacheHit {
96 /// The run this event belongs to.
97 run_id: RunId,
98 /// The node this event concerns.
99 node_id: NodeId,
100 /// The key the result was found under.
101 key: CacheKey,
102 /// Which [`CacheTier`] served the hit.
103 tier: CacheTier,
104 /// Time spent loading the cached result.
105 #[serde(with = "duration_millis")]
106 load_time: Duration,
107 },
108
109 /// A cacheable node's key was computed but not found — the filter
110 /// executes and (on success) fills this key.
111 NodeCacheMiss {
112 /// The run this event belongs to.
113 run_id: RunId,
114 /// The node this event concerns.
115 node_id: NodeId,
116 /// The key that was looked up and not found — the same key the
117 /// node's output will be stored under on success.
118 key: CacheKey,
119 },
120
121 /// A filter node completed successfully.
122 NodeCompleted {
123 /// The run this event belongs to.
124 run_id: RunId,
125 /// The node this event concerns.
126 node_id: NodeId,
127 /// Wall time from start to finish. Control-flow constructs (loops,
128 /// branches) report zero — their time is in the nodes they ran.
129 #[serde(with = "duration_millis")]
130 duration: Duration,
131 /// Short human-readable rendering of the output, never the payload
132 /// itself.
133 output_summary: String,
134 },
135
136 /// A filter node failed.
137 NodeFailed {
138 /// The run this event belongs to.
139 run_id: RunId,
140 /// The node this event concerns.
141 node_id: NodeId,
142 /// The error, rendered as a string.
143 error: String,
144 },
145
146 /// The pipeline run completed.
147 RunCompleted {
148 /// The run this event belongs to.
149 run_id: RunId,
150 /// Wall time for the whole run.
151 #[serde(with = "duration_millis")]
152 duration: Duration,
153 },
154
155 /// The pipeline run failed.
156 RunFailed {
157 /// The run this event belongs to.
158 run_id: RunId,
159 /// The error, rendered as a string.
160 error: String,
161 },
162
163 // ── Level 2: Trial execution (per hyperparameter set) ──
164 /// A new trial has started.
165 TrialStarted {
166 /// The study this event belongs to.
167 study_id: StudyId,
168 /// The trial this event concerns.
169 trial_id: TrialId,
170 /// The hyperparameters sampled for this trial, as JSON.
171 params: serde_json::Value,
172 },
173
174 /// A trial reports an intermediate metric.
175 TrialMetric {
176 /// The study this event belongs to.
177 study_id: StudyId,
178 /// The trial this event concerns.
179 trial_id: TrialId,
180 /// The intermediate measurement — what pruners decide on.
181 metric: MetricRecord,
182 },
183
184 /// A trial was pruned (stopped early).
185 TrialPruned {
186 /// The study this event belongs to.
187 study_id: StudyId,
188 /// The trial this event concerns.
189 trial_id: TrialId,
190 /// The training step at which the pruner struck.
191 step: usize,
192 /// Why the pruner stopped it, human-readable (e.g. `below median`).
193 reason: String,
194 },
195
196 /// A trial completed successfully.
197 TrialCompleted {
198 /// The study this event belongs to.
199 study_id: StudyId,
200 /// The trial this event concerns.
201 trial_id: TrialId,
202 /// The measurements the trial finished with — the values the
203 /// sampler and the study's best-trial bookkeeping consume.
204 final_metrics: Vec<MetricRecord>,
205 },
206
207 /// A trial failed.
208 TrialFailed {
209 /// The study this event belongs to.
210 study_id: StudyId,
211 /// The trial this event concerns.
212 trial_id: TrialId,
213 /// The error, rendered as a string.
214 error: String,
215 },
216
217 // ── Level 3: Study execution (optimization session) ──
218 /// An optimization study has started.
219 StudyStarted {
220 /// The study this event belongs to.
221 study_id: StudyId,
222 /// Human-readable study name.
223 name: String,
224 /// How many trials the study intends to run.
225 total_trials: usize,
226 },
227
228 /// Study progress update.
229 StudyProgress {
230 /// The study this event belongs to.
231 study_id: StudyId,
232 /// Trials finished so far.
233 completed: usize,
234 /// Total trials planned.
235 total: usize,
236 /// Best objective value seen so far; `NaN` until a trial has
237 /// completed (consumers render it as "no best yet", they do not
238 /// compare it).
239 best_value: f64,
240 },
241
242 /// The best trial has been updated.
243 BestUpdated {
244 /// The study this event belongs to.
245 study_id: StudyId,
246 /// The trial this event concerns.
247 trial_id: TrialId,
248 /// The new best objective value.
249 value: f64,
250 /// The hyperparameters that produced it, as JSON.
251 params: serde_json::Value,
252 },
253
254 /// The Pareto front has changed (multi-objective).
255 ParetoUpdated {
256 /// The study this event belongs to.
257 study_id: StudyId,
258 /// Number of non-dominated trials after the update.
259 front_size: usize,
260 },
261
262 /// The study completed.
263 StudyCompleted {
264 /// The study this event belongs to.
265 study_id: StudyId,
266 /// The winning trial.
267 best_trial_id: TrialId,
268 /// Its objective value (`NaN` when no trial completed).
269 best_value: f64,
270 },
271
272 // ── Level 4: Population-Based Training ──
273 /// A PBT generation started (train → evaluate → exploit/explore).
274 GenerationStarted {
275 /// The study this event belongs to.
276 study_id: StudyId,
277 /// The generation index.
278 generation: usize,
279 /// How many population members train in this generation.
280 population_size: usize,
281 },
282
283 /// A PBT generation completed.
284 GenerationCompleted {
285 /// The study this event belongs to.
286 study_id: StudyId,
287 /// The generation index.
288 generation: usize,
289 /// Best fitness in the population after evaluation.
290 best_fitness: f64,
291 /// Mean fitness across the population — tracks whether the whole
292 /// population improves, not just its champion.
293 mean_fitness: f64,
294 },
295
296 /// A population member was replaced during exploit step.
297 MemberExploited {
298 /// The study this event belongs to.
299 study_id: StudyId,
300 /// The generation index.
301 generation: usize,
302 /// The underperforming member whose params and state were
303 /// overwritten.
304 replaced_id: String,
305 /// The better-performing member it copied them from (explore then
306 /// perturbs the copy).
307 donor_id: String,
308 },
309
310 // ── Level 5: Training telemetry (native training loop) ──
311 /// A training epoch started.
312 EpochStarted {
313 /// The run this event belongs to.
314 run_id: RunId,
315 /// Zero-based epoch index.
316 epoch: usize,
317 /// Planned epoch count when the loop knows it up front; `None` for
318 /// open-ended training, where progress cannot be a percentage.
319 total_epochs: Option<usize>,
320 },
321
322 /// A training epoch completed with its summary metrics.
323 EpochCompleted {
324 /// The run this event belongs to.
325 run_id: RunId,
326 /// Zero-based epoch index.
327 epoch: usize,
328 /// Summary metrics for the epoch (e.g. mean loss).
329 metrics: Vec<MetricRecord>,
330 },
331
332 /// One optimizer step completed (coarse liveness marker).
333 StepCompleted {
334 /// The run this event belongs to.
335 run_id: RunId,
336 /// The optimizer step index.
337 step: usize,
338 /// The epoch this step belongs to, when the loop tracks one.
339 epoch: Option<usize>,
340 },
341
342 /// A user- or node-scoped metric reported outside a trial.
343 MetricReported {
344 /// The run this event belongs to.
345 run_id: RunId,
346 /// The measurement.
347 metric: MetricRecord,
348 /// The node the metric is scoped to, if any.
349 node_id: Option<NodeId>,
350 /// The trial the metric is scoped to, if any.
351 trial_id: Option<TrialId>,
352 },
353
354 /// A training-health diagnostic fired for a node (e.g.
355 /// `DEAD_CHANNELS`, `IGNORED_CHANNELS`, `LEAKAGE`, `NONFINITE`).
356 HealthFlag {
357 /// The run this event belongs to.
358 run_id: RunId,
359 /// The node this event concerns.
360 node_id: NodeId,
361 /// The training step at which the diagnostic fired.
362 step: usize,
363 /// The flag family, optionally with a count — e.g.
364 /// `DEAD_CHANNELS(3)`.
365 flag: String,
366 /// Supporting numbers behind the flag, e.g. `zero_frac=0.98`.
367 detail: String,
368 },
369
370 // ── Level 6: Effectful steps (agentic execution) ──
371 //
372 // Payloads carry *labels*, never prompts or completions. These events
373 // land in `.soma/runs/<id>/events.jsonl` and are rendered into reports;
374 // the conversation itself belongs in the journal, which is subject to
375 // `StepMeta::journal`, not in a telemetry stream.
376 //
377 // Naming note: `AgentTurnStarted`/`AgentStepCompleted` are spelled out
378 // because `StepCompleted` above already means "one optimizer step".
379 /// A step began a turn (one `poll`).
380 AgentTurnStarted {
381 /// The run this event belongs to.
382 run_id: RunId,
383 /// The node this event concerns.
384 node_id: NodeId,
385 /// Zero-based index of the turn being started.
386 turn: usize,
387 },
388
389 /// A step asked for an effect to be performed.
390 EffectRequested {
391 /// The run this event belongs to.
392 run_id: RunId,
393 /// The node this event concerns.
394 node_id: NodeId,
395 /// The turn (one `poll`) that requested the effect.
396 turn: usize,
397 /// `Effect::label()` — e.g. `llm:claude-opus-5`, `tool:search`.
398 effect: String,
399 },
400
401 /// An effect finished.
402 EffectCompleted {
403 /// The run this event belongs to.
404 run_id: RunId,
405 /// The node this event concerns.
406 node_id: NodeId,
407 /// The turn (one `poll`) that requested the effect.
408 turn: usize,
409 /// `Effect::label()` — matches the [`Event::EffectRequested`] this
410 /// answers.
411 effect: String,
412 /// How long performing (or replaying) the effect took.
413 #[serde(with = "duration_millis")]
414 duration: Duration,
415 /// Served from the journal rather than actually performed.
416 /// A replay should be nearly all `true`.
417 replayed: bool,
418 /// The effect's result was an error. It is still delivered to the
419 /// step, which decides whether that is fatal.
420 is_error: bool,
421 },
422
423 /// A tool ran. Separate from `EffectCompleted` because tool usage is the
424 /// thing worth counting per run, and it is what a permission or audit
425 /// layer hooks into.
426 ToolCalled {
427 /// The run this event belongs to.
428 run_id: RunId,
429 /// The node this event concerns.
430 node_id: NodeId,
431 /// The tool's name.
432 tool: String,
433 /// The tool returned an error result.
434 is_error: bool,
435 },
436
437 /// Control passed from one node to another.
438 Handoff {
439 /// The run this event belongs to.
440 run_id: RunId,
441 /// The node handing control off.
442 from: NodeId,
443 /// The node receiving it.
444 to: NodeId,
445 },
446
447 /// The run stopped, pending something outside it.
448 ///
449 /// Carries what the step has cost *so far*: a suspended step has not
450 /// finished, so no [`Event::AgentStepCompleted`] fires for it. When the
451 /// run resumes and finishes, that final event's totals are cumulative
452 /// (replayed effects re-count their recorded usage), superseding these.
453 /// The fields default to zero so run dirs written before they existed
454 /// still deserialize.
455 Suspended {
456 /// The run this event belongs to.
457 run_id: RunId,
458 /// The node this event concerns.
459 node_id: NodeId,
460 /// What the run is waiting on, as a short label (e.g. `human`).
461 reason: String,
462 /// Turns taken up to and including the one that suspended.
463 #[serde(default)]
464 turns: usize,
465 /// Wall time spent so far.
466 #[serde(default, with = "duration_millis")]
467 duration: Duration,
468 /// LLM input tokens consumed so far.
469 #[serde(default)]
470 input_tokens: u64,
471 /// LLM output tokens generated so far.
472 #[serde(default)]
473 output_tokens: u64,
474 },
475
476 /// A suspended run picked up again.
477 Resumed {
478 /// The run this event belongs to.
479 run_id: RunId,
480 /// The node this event concerns.
481 node_id: NodeId,
482 /// The turn the step resumes at.
483 turn: usize,
484 },
485
486 /// A step finished, with what it cost — however it finished.
487 ///
488 /// `Done`, a handoff, a failed poll or effect, and turn exhaustion all
489 /// emit this: the cost was paid either way, and telemetry that loses
490 /// the expensive failures undercounts exactly the runs worth studying.
491 /// Suspension is the one exit that does not — see [`Event::Suspended`].
492 AgentStepCompleted {
493 /// The run this event belongs to.
494 run_id: RunId,
495 /// The node this event concerns.
496 node_id: NodeId,
497 /// Total turns taken.
498 turns: usize,
499 /// Total wall time across all turns.
500 #[serde(with = "duration_millis")]
501 duration: Duration,
502 /// Total LLM input tokens consumed. Cumulative across a resume:
503 /// replayed effects re-count their recorded usage.
504 input_tokens: u64,
505 /// Total LLM output tokens generated (same accounting as
506 /// `input_tokens`).
507 output_tokens: u64,
508 /// The step ended in an error (the node will also emit
509 /// [`Event::NodeFailed`]). Defaults to `false` so run dirs written
510 /// before the field existed still deserialize.
511 #[serde(default)]
512 failed: bool,
513 },
514
515 /// A step fanned work out to spawned instances.
516 ///
517 /// `children` are the hierarchical ids (`parent/label`) the instances
518 /// run under; their own turn and completion events appear under those
519 /// ids, which is how a reader ties the sub-tree together.
520 AgentSpawned {
521 /// The run this event belongs to.
522 run_id: RunId,
523 /// The node this event concerns.
524 node_id: NodeId,
525 /// The turn (one `poll`) that spawned the instances.
526 turn: usize,
527 /// Hierarchical ids (`parent/label`) the spawned instances run
528 /// under.
529 children: Vec<NodeId>,
530 /// The join policy, as a label (`all`, `all-settled`, `first`).
531 join: String,
532 },
533}
534
535/// Serde helper: Duration as milliseconds (u64).
536mod duration_millis {
537 use serde::{self, Deserialize, Deserializer, Serializer};
538 use std::time::Duration;
539
540 pub fn serialize<S>(duration: &Duration, serializer: S) -> Result<S::Ok, S::Error>
541 where
542 S: Serializer,
543 {
544 serializer.serialize_u64(duration.as_millis() as u64)
545 }
546
547 pub fn deserialize<'de, D>(deserializer: D) -> Result<Duration, D::Error>
548 where
549 D: Deserializer<'de>,
550 {
551 let millis = u64::deserialize(deserializer)?;
552 Ok(Duration::from_millis(millis))
553 }
554}
555
556#[cfg(test)]
557mod tests {
558 use super::*;
559
560 #[test]
561 fn event_serde_run_started() {
562 let event = Event::RunStarted {
563 run_id: "run_001".into(),
564 plan_summary: PlanSummary {
565 total_nodes: 5,
566 cached_nodes: 2,
567 parallel_branches: 1,
568 },
569 };
570 let json = serde_json::to_string(&event).unwrap();
571 assert!(json.contains("RunStarted"));
572 let deserialized: Event = serde_json::from_str(&json).unwrap();
573 if let Event::RunStarted {
574 run_id,
575 plan_summary,
576 } = deserialized
577 {
578 assert_eq!(run_id, "run_001");
579 assert_eq!(plan_summary.total_nodes, 5);
580 } else {
581 panic!("wrong variant");
582 }
583 }
584
585 /// Agent-level events ride the same envelope as everything else — that
586 /// is the point of putting them on the existing bus rather than building
587 /// a second telemetry path.
588 #[test]
589 fn agent_events_roundtrip() {
590 let events = vec![
591 Event::AgentTurnStarted {
592 run_id: "r".into(),
593 node_id: "researcher".into(),
594 turn: 0,
595 },
596 Event::EffectRequested {
597 run_id: "r".into(),
598 node_id: "researcher".into(),
599 turn: 0,
600 effect: "llm:claude-opus-5".into(),
601 },
602 Event::EffectCompleted {
603 run_id: "r".into(),
604 node_id: "researcher".into(),
605 turn: 0,
606 effect: "llm:claude-opus-5".into(),
607 duration: Duration::from_millis(1200),
608 replayed: false,
609 is_error: false,
610 },
611 Event::ToolCalled {
612 run_id: "r".into(),
613 node_id: "researcher".into(),
614 tool: "search".into(),
615 is_error: false,
616 },
617 Event::Handoff {
618 run_id: "r".into(),
619 from: "router".into(),
620 to: "billing".into(),
621 },
622 Event::Suspended {
623 run_id: "r".into(),
624 node_id: "approve".into(),
625 reason: "human".into(),
626 turns: 2,
627 duration: Duration::from_millis(3400),
628 input_tokens: 600,
629 output_tokens: 120,
630 },
631 Event::Resumed {
632 run_id: "r".into(),
633 node_id: "approve".into(),
634 turn: 3,
635 },
636 Event::AgentStepCompleted {
637 run_id: "r".into(),
638 node_id: "researcher".into(),
639 turns: 4,
640 duration: Duration::from_millis(8000),
641 input_tokens: 1200,
642 output_tokens: 340,
643 failed: false,
644 },
645 Event::AgentSpawned {
646 run_id: "r".into(),
647 node_id: "orchestrator".into(),
648 turn: 1,
649 children: vec!["orchestrator/web".into(), "orchestrator/code".into()],
650 join: "all".into(),
651 },
652 ];
653
654 for event in events {
655 let json = serde_json::to_string(&event).unwrap();
656 let back: Event = serde_json::from_str(&json).unwrap();
657 assert_eq!(
658 serde_json::to_string(&back).unwrap(),
659 json,
660 "agent event did not survive a round trip"
661 );
662 }
663 }
664
665 /// Run dirs written before `Suspended` carried cost and
666 /// `AgentStepCompleted` carried `failed` must still read back — the
667 /// fields default rather than fail the whole line.
668 #[test]
669 fn agent_events_read_back_without_the_newer_fields() {
670 let suspended: Event = serde_json::from_str(
671 r#"{"event_type":"Suspended","run_id":"r","node_id":"approve","reason":"human"}"#,
672 )
673 .unwrap();
674 let Event::Suspended {
675 turns,
676 input_tokens,
677 ..
678 } = suspended
679 else {
680 panic!("wrong variant");
681 };
682 assert_eq!((turns, input_tokens), (0, 0));
683
684 let completed: Event = serde_json::from_str(
685 r#"{"event_type":"AgentStepCompleted","run_id":"r","node_id":"n","turns":2,
686 "duration":100,"input_tokens":10,"output_tokens":5}"#,
687 )
688 .unwrap();
689 let Event::AgentStepCompleted { failed, .. } = completed else {
690 panic!("wrong variant");
691 };
692 assert!(!failed);
693 }
694
695 /// Telemetry must never carry the conversation. A prompt belongs in the
696 /// journal, which honours `StepMeta::journal`; an event stream does not.
697 #[test]
698 fn effect_events_carry_labels_not_payloads() {
699 let effect = crate::effect::Effect::Llm(crate::effect::LlmRequest::new(
700 "claude-opus-5",
701 vec![crate::message::Message::user("my secret prompt")].into(),
702 ));
703 let event = Event::EffectRequested {
704 run_id: "r".into(),
705 node_id: "n".into(),
706 turn: 0,
707 effect: effect.label(),
708 };
709 let json = serde_json::to_string(&event).unwrap();
710 assert!(json.contains("claude-opus-5"));
711 assert!(
712 !json.contains("secret"),
713 "the prompt leaked into telemetry: {json}"
714 );
715 }
716
717 #[test]
718 fn event_serde_node_cache_hit() {
719 let event = Event::NodeCacheHit {
720 run_id: "run_001".into(),
721 node_id: "scaler".into(),
722 key: CacheKey::hash_data(b"test"),
723 tier: CacheTier::Memory,
724 load_time: Duration::from_micros(200),
725 };
726 let json = serde_json::to_string(&event).unwrap();
727 let deserialized: Event = serde_json::from_str(&json).unwrap();
728 if let Event::NodeCacheHit { tier, .. } = deserialized {
729 assert_eq!(tier, CacheTier::Memory);
730 } else {
731 panic!("wrong variant");
732 }
733 }
734
735 #[test]
736 fn event_serde_trial_metric() {
737 let event = Event::TrialMetric {
738 study_id: "study_001".into(),
739 trial_id: "trial_042".into(),
740 metric: MetricRecord {
741 name: "f1".into(),
742 value: 0.847,
743 step: 15,
744 timestamp: Utc::now(),
745 },
746 };
747 let json = serde_json::to_string(&event).unwrap();
748 assert!(json.contains("TrialMetric"));
749 assert!(json.contains("0.847"));
750 }
751
752 #[test]
753 fn event_serde_study_completed() {
754 let event = Event::StudyCompleted {
755 study_id: "study_001".into(),
756 best_trial_id: "trial_042".into(),
757 best_value: 0.91,
758 };
759 let json = serde_json::to_string(&event).unwrap();
760 let deserialized: Event = serde_json::from_str(&json).unwrap();
761 if let Event::StudyCompleted { best_value, .. } = deserialized {
762 assert!((best_value - 0.91).abs() < f64::EPSILON);
763 } else {
764 panic!("wrong variant");
765 }
766 }
767
768 #[test]
769 fn duration_serialized_as_millis() {
770 let event = Event::NodeCompleted {
771 run_id: "r".into(),
772 node_id: "n".into(),
773 duration: Duration::from_millis(1234),
774 output_summary: "ok".into(),
775 };
776 let json = serde_json::to_string(&event).unwrap();
777 assert!(json.contains("1234"));
778 }
779
780 #[test]
781 fn all_event_levels_serialize() {
782 let events: Vec<Event> = vec![
783 // Level 1
784 Event::RunStarted {
785 run_id: "r".into(),
786 plan_summary: PlanSummary {
787 total_nodes: 1,
788 cached_nodes: 0,
789 parallel_branches: 0,
790 },
791 },
792 Event::RunCompleted {
793 run_id: "r".into(),
794 duration: Duration::from_secs(1),
795 },
796 // Level 2
797 Event::TrialStarted {
798 study_id: "s".into(),
799 trial_id: "t".into(),
800 params: serde_json::json!({"lr": 0.01}),
801 },
802 Event::TrialPruned {
803 study_id: "s".into(),
804 trial_id: "t".into(),
805 step: 5,
806 reason: "below median".into(),
807 },
808 // Level 3
809 Event::StudyStarted {
810 study_id: "s".into(),
811 name: "test".into(),
812 total_trials: 100,
813 },
814 Event::BestUpdated {
815 study_id: "s".into(),
816 trial_id: "t".into(),
817 value: 0.95,
818 params: serde_json::json!({"C": 1.0}),
819 },
820 // Level 4
821 Event::GenerationCompleted {
822 study_id: "s".into(),
823 generation: 2,
824 best_fitness: 0.9,
825 mean_fitness: 0.7,
826 },
827 // Level 5
828 Event::EpochStarted {
829 run_id: "r".into(),
830 epoch: 0,
831 total_epochs: Some(30),
832 },
833 Event::EpochStarted {
834 run_id: "r".into(),
835 epoch: 1,
836 total_epochs: None,
837 },
838 Event::EpochCompleted {
839 run_id: "r".into(),
840 epoch: 0,
841 metrics: vec![MetricRecord {
842 name: "loss".into(),
843 value: 0.4,
844 step: 12,
845 timestamp: chrono::Utc::now(),
846 }],
847 },
848 Event::StepCompleted {
849 run_id: "r".into(),
850 step: 7,
851 epoch: Some(1),
852 },
853 Event::StepCompleted {
854 run_id: "r".into(),
855 step: 8,
856 epoch: None,
857 },
858 Event::MetricReported {
859 run_id: "r".into(),
860 metric: MetricRecord {
861 name: "val_f1".into(),
862 value: 0.8,
863 step: 3,
864 timestamp: chrono::Utc::now(),
865 },
866 node_id: Some("encoder".into()),
867 trial_id: Some("trial_0001".into()),
868 },
869 Event::HealthFlag {
870 run_id: "r".into(),
871 node_id: "encoder".into(),
872 step: 50,
873 flag: "DEAD_CHANNELS(3)".into(),
874 detail: "zero_frac=0.98".into(),
875 },
876 ];
877
878 for event in events {
879 let json = serde_json::to_string(&event).unwrap();
880 let back: Event = serde_json::from_str(&json).unwrap();
881 // Typed roundtrip must preserve the variant and its Options.
882 assert_eq!(
883 serde_json::to_value(&back).unwrap(),
884 serde_json::from_str::<serde_json::Value>(&json).unwrap()
885 );
886 }
887 }
888
889 #[test]
890 fn documented_health_flags_roundtrip() {
891 for flag in [
892 "DEAD_CHANNELS(2)",
893 "IGNORED_CHANNELS(1)",
894 "LEAKAGE",
895 "NONFINITE",
896 ] {
897 let event = Event::HealthFlag {
898 run_id: "r".into(),
899 node_id: "n".into(),
900 step: 0,
901 flag: flag.into(),
902 detail: String::new(),
903 };
904 let json = serde_json::to_string(&event).unwrap();
905 let back: Event = serde_json::from_str(&json).unwrap();
906 if let Event::HealthFlag { flag: f, .. } = back {
907 assert_eq!(f, flag);
908 } else {
909 panic!("wrong variant");
910 }
911 }
912 }
913}