Skip to main content

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}