Skip to main content

somatize_runtime/tracking/
summary.rs

1//! The join: one run directory → one self-describing summary.
2//!
3//! [`RunReader`] already computes every aggregate a reader could want,
4//! but each in its own shape and each requiring the caller to know
5//! which files exist. [`summarize`] folds all of them — manifest,
6//! status, node timings, cache activity, health flags, metrics, study,
7//! trial timeline, graph, `fingerprint.json`, `diagnostics/report.json`
8//! — into a single [`RunSummary`].
9//!
10//! Only the reading lives here. The shapes it produces, and the
11//! deterministic headline template, are pure data and live in
12//! `somatize_core::summary` so consumers of the journal never have to
13//! depend on the execution engine.
14//!
15//! Every input is optional. A run that crashed before writing anything
16//! but its manifest still summarizes — it just collects warnings.
17
18use serde::Deserialize;
19use somatize_core::error::Result;
20use somatize_core::fingerprint::{ArchitectureFingerprint, pipeline_summary};
21use somatize_core::summary::{
22    AgentCost, FlagCount, NodeCost, RunConclusion, RunOutcome, RunSummary, TrialSummary,
23};
24use std::collections::BTreeMap;
25use std::fs;
26
27use super::reader::RunReader;
28
29/// Fold a run directory into a [`RunSummary`].
30///
31/// Fails only if the manifest is unreadable — every other missing or
32/// corrupt file lands in `conclusion.warnings`.
33pub fn summarize(reader: &RunReader) -> Result<RunSummary> {
34    let manifest = reader.manifest()?;
35    let info = reader.info()?;
36    let mut warnings = Vec::new();
37
38    let architecture = read_fingerprint(reader, &mut warnings);
39    let pipeline = match reader.graph() {
40        Ok(Some(graph)) => pipeline_summary(&graph),
41        Ok(None) => String::new(),
42        Err(e) => {
43            warnings.push(format!("graph.json is unreadable: {e}"));
44            String::new()
45        }
46    };
47
48    let outcome = RunOutcome::from_state(&info.state);
49    let (dominant_cost, node_error) = node_cost(reader, &mut warnings);
50    let cache_hit_ratio = cache_ratio(reader, &mut warnings);
51    let health_flags = health_flags(reader, &mut warnings);
52    let audit_flags = audit_flags(reader, &mut warnings);
53    let metrics = final_metrics(reader, &mut warnings);
54    let trials = trial_summary(reader, &mut warnings);
55    let agent_cost = agent_cost(reader, &mut warnings);
56
57    // A study run has no graph to describe, but it is not shapeless:
58    // the sweep itself is the pipeline.
59    let pipeline = match (&trials, pipeline.is_empty()) {
60        (Some(trials), true) => format!("study over {} trials", trials.total),
61        _ => pipeline,
62    };
63
64    if metrics.is_empty() && trials.is_none() && matches!(outcome, RunOutcome::Completed) {
65        warnings.push("run completed without recording any metric".into());
66    }
67
68    let mut conclusion = RunConclusion {
69        headline: String::new(),
70        outcome: Some(outcome),
71        dominant_cost,
72        cache_hit_ratio,
73        health_flags,
74        audit_flags,
75        trials,
76        agent_cost,
77        warnings,
78    };
79    conclusion.headline =
80        conclusion.render_headline(info.duration_ms, &metrics, node_error.as_deref());
81
82    Ok(RunSummary {
83        run_id: manifest.run_id,
84        run_dir: reader.dir().display().to_string(),
85        name: manifest.name,
86        kind: info.kind,
87        created_at: manifest.created_at,
88        finished_at: info.finished_at,
89        duration_ms: info.duration_ms,
90        tags: manifest.tags,
91        git: manifest.git,
92        seeds: manifest.seeds.into_iter().collect(),
93        params: manifest.params.into_iter().collect(),
94        hypothesis: manifest.hypothesis,
95        parent_run_id: manifest.parent_run_id,
96        architecture,
97        pipeline_summary: pipeline,
98        metrics,
99        conclusion,
100    })
101}
102
103fn read_fingerprint(
104    reader: &RunReader,
105    warnings: &mut Vec<String>,
106) -> Option<ArchitectureFingerprint> {
107    let path = reader.dir().join("fingerprint.json");
108    if !path.exists() {
109        return None;
110    }
111    match fs::read(&path).map(|b| serde_json::from_slice(&b)) {
112        Ok(Ok(fingerprint)) => Some(fingerprint),
113        Ok(Err(e)) => {
114            warnings.push(format!("fingerprint.json is malformed: {e}"));
115            None
116        }
117        Err(e) => {
118            warnings.push(format!("fingerprint.json is unreadable: {e}"));
119            None
120        }
121    }
122}
123
124/// Slowest node plus, when the run failed, the first node error —
125/// which is the single most useful thing a failed run can tell you.
126fn node_cost(reader: &RunReader, warnings: &mut Vec<String>) -> (Option<NodeCost>, Option<String>) {
127    let spans = match reader.node_timings() {
128        Ok(spans) => spans,
129        Err(e) => {
130            warnings.push(format!("event log is unreadable: {e}"));
131            return (None, None);
132        }
133    };
134    let error = spans.iter().find_map(|s| s.error.clone());
135    let mut totals: BTreeMap<&str, u64> = BTreeMap::new();
136    for span in &spans {
137        if let Some(ms) = span.duration_ms {
138            *totals.entry(span.node_id.as_str()).or_default() += ms;
139        }
140    }
141    let total: u64 = totals.values().sum();
142    // Ties break on node id (BTreeMap order), keeping this deterministic.
143    let cost = totals
144        .iter()
145        .max_by_key(|(_, ms)| **ms)
146        .filter(|_| total > 0)
147        .map(|(node_id, ms)| NodeCost {
148            node_id: (*node_id).to_string(),
149            duration_ms: *ms,
150            share: *ms as f64 / total as f64,
151        });
152    if !spans.is_empty() && spans.iter().any(|s| s.outcome == "running") {
153        warnings.push("some nodes never reported completion".into());
154    }
155    (cost, error)
156}
157
158fn cache_ratio(reader: &RunReader, warnings: &mut Vec<String>) -> Option<f64> {
159    let activity = match reader.cache_activity() {
160        Ok(a) => a,
161        Err(e) => {
162            warnings.push(format!("cache activity is unreadable: {e}"));
163            return None;
164        }
165    };
166    let total = activity.hits + activity.misses;
167    (total > 0).then(|| activity.hits as f64 / total as f64)
168}
169
170fn health_flags(reader: &RunReader, warnings: &mut Vec<String>) -> Vec<FlagCount> {
171    let records = match reader.health_flags() {
172        Ok(r) => r,
173        Err(e) => {
174            warnings.push(format!("health flags are unreadable: {e}"));
175            return Vec::new();
176        }
177    };
178    let mut grouped: BTreeMap<String, Vec<String>> = BTreeMap::new();
179    for record in records {
180        grouped.entry(record.flag).or_default().push(record.node_id);
181    }
182    grouped
183        .into_iter()
184        .map(|(flag, nodes)| FlagCount::group(flag, nodes))
185        .collect()
186}
187
188/// The audit's own view, from `diagnostics/report.json`.
189///
190/// That file is a serialized Python dataclass, not a Rust type, so it
191/// is parsed structurally: anything that does not match the shape is a
192/// warning, never an error.
193fn audit_flags(reader: &RunReader, warnings: &mut Vec<String>) -> Vec<FlagCount> {
194    #[derive(Deserialize)]
195    struct AuditReport {
196        #[serde(default)]
197        filters: Vec<AuditFilter>,
198    }
199    #[derive(Deserialize)]
200    struct AuditFilter {
201        #[serde(rename = "filter")]
202        filter_id: String,
203        #[serde(default)]
204        flags: Vec<String>,
205    }
206
207    let path = reader.dir().join("diagnostics").join("report.json");
208    if !path.exists() {
209        return Vec::new();
210    }
211    let report: AuditReport = match fs::read(&path).map(|b| serde_json::from_slice(&b)) {
212        Ok(Ok(report)) => report,
213        Ok(Err(e)) => {
214            warnings.push(format!("diagnostics/report.json is malformed: {e}"));
215            return Vec::new();
216        }
217        Err(e) => {
218            warnings.push(format!("diagnostics/report.json is unreadable: {e}"));
219            return Vec::new();
220        }
221    };
222    let mut grouped: BTreeMap<String, Vec<String>> = BTreeMap::new();
223    for filter in report.filters {
224        for flag in filter.flags {
225            grouped
226                .entry(flag)
227                .or_default()
228                .push(filter.filter_id.clone());
229        }
230    }
231    grouped
232        .into_iter()
233        .map(|(flag, nodes)| FlagCount::group(flag, nodes))
234        .collect()
235}
236
237/// Last value per metric name, in log order.
238fn final_metrics(reader: &RunReader, warnings: &mut Vec<String>) -> BTreeMap<String, f64> {
239    let points = match reader.metric_series(None) {
240        Ok(points) => points,
241        Err(e) => {
242            warnings.push(format!("metrics are unreadable: {e}"));
243            return BTreeMap::new();
244        }
245    };
246    let mut latest: BTreeMap<String, (u64, f64)> = BTreeMap::new();
247    for point in points {
248        let entry = latest
249            .entry(point.name)
250            .or_insert((point.step, point.value));
251        if point.step >= entry.0 {
252            *entry = (point.step, point.value);
253        }
254    }
255    latest
256        .into_iter()
257        .map(|(name, (_, value))| (name, value))
258        .collect()
259}
260
261/// Totals from the agent-step telemetry; `None` for runs with none.
262fn agent_cost(reader: &RunReader, warnings: &mut Vec<String>) -> Option<AgentCost> {
263    let activity = match reader.agentic_activity() {
264        Ok(a) => a,
265        Err(e) => {
266            warnings.push(format!("agent activity is unreadable: {e}"));
267            return None;
268        }
269    };
270    if activity.by_node.is_empty() {
271        return None;
272    }
273    Some(AgentCost {
274        turns: activity.turns,
275        input_tokens: activity.input_tokens,
276        output_tokens: activity.output_tokens,
277        tool_calls: activity.tool_calls,
278        steps_failed: activity.steps_failed,
279        suspensions: activity.suspensions,
280    })
281}
282
283fn trial_summary(reader: &RunReader, warnings: &mut Vec<String>) -> Option<TrialSummary> {
284    let study = match reader.study() {
285        Ok(Some(study)) => study,
286        Ok(None) => return None,
287        Err(e) => {
288            warnings.push(format!("study.json is unreadable: {e}"));
289            return None;
290        }
291    };
292    let mut summary = TrialSummary {
293        total: study.trials.len(),
294        objective: study
295            .composite
296            .as_ref()
297            .map(|_| "composite".to_string())
298            .or_else(|| study.objectives.first().map(|o| o.metric.clone())),
299        best_value: study.best_value(),
300        best_trial_id: study.best_trial().map(|t| t.id.clone()),
301        ..TrialSummary::default()
302    };
303    for span in reader.trial_timeline().unwrap_or_default() {
304        match span.state.as_str() {
305            "completed" => summary.completed += 1,
306            "pruned" => summary.pruned += 1,
307            "failed" => summary.failed += 1,
308            _ => {}
309        }
310    }
311    Some(summary)
312}
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317    use crate::tracking::LocalTracker;
318    use chrono::Utc;
319    use somatize_core::cache::CacheKey;
320    use somatize_core::event::{Event, MetricRecord};
321    use somatize_core::filter::FilterKind;
322    use somatize_core::graph::{Node, linear_pipeline};
323    use somatize_core::tracking::{RunKind, RunState, Tracker};
324    use std::time::Duration;
325    use tempfile::TempDir;
326
327    /// A tracker over a fresh root, plus the root itself.
328    fn tracker(kind: RunKind, name: &str) -> (TempDir, LocalTracker) {
329        let root = TempDir::new().unwrap();
330        let tracker = LocalTracker::create(root.path(), kind, name).unwrap();
331        (root, tracker)
332    }
333
334    fn metric(name: &str, value: f64, step: usize) -> MetricRecord {
335        MetricRecord {
336            name: name.into(),
337            value,
338            step,
339            timestamp: Utc::now(),
340        }
341    }
342
343    #[test]
344    fn summarizes_a_completed_run_end_to_end() {
345        let (_root, tracker) = tracker(RunKind::Train, "baseline");
346        let graph = linear_pipeline(vec![
347            Node::new("a", "Scaler", "StandardScaler"),
348            Node::new("b", "Model", "SVM"),
349        ]);
350        tracker
351            .save_artifact("graph.json", &serde_json::to_vec(&graph).unwrap())
352            .unwrap();
353        let fingerprint = ArchitectureFingerprint::of(&graph).unwrap();
354        tracker
355            .save_artifact(
356                "fingerprint.json",
357                &serde_json::to_vec(&fingerprint).unwrap(),
358            )
359            .unwrap();
360
361        let sink = tracker.sink();
362        let run_id = tracker.run_id().to_string();
363        for (node, ms) in [("a", 100u64), ("b", 900)] {
364            sink.record(&Event::NodeStarted {
365                run_id: run_id.clone(),
366                node_id: node.into(),
367                kind: FilterKind::Trainable,
368                effectful: false,
369            });
370            sink.record(&Event::NodeCompleted {
371                run_id: run_id.clone(),
372                node_id: node.into(),
373                duration: Duration::from_millis(ms),
374                output_summary: "ok".into(),
375            });
376        }
377        sink.record(&Event::MetricReported {
378            run_id: run_id.clone(),
379            metric: metric("val_f1", 0.9125, 3),
380            node_id: None,
381            trial_id: None,
382        });
383        sink.record(&Event::MetricReported {
384            run_id: run_id.clone(),
385            metric: metric("val_f1", 0.75, 1),
386            node_id: None,
387            trial_id: None,
388        });
389        sink.record(&Event::HealthFlag {
390            run_id: run_id.clone(),
391            node_id: "b".into(),
392            step: 3,
393            flag: "DEAD_CHANNELS".into(),
394            detail: "12 dead".into(),
395        });
396        tracker.finalize(RunState::Completed).unwrap();
397
398        let summary = summarize(&RunReader::open(tracker.run_dir()).unwrap()).unwrap();
399        assert_eq!(summary.name, "baseline");
400        assert_eq!(summary.kind, "train");
401        assert_eq!(summary.pipeline_summary, "a(StandardScaler) → b(SVM)");
402        assert_eq!(
403            summary.architecture.as_ref().unwrap().digest,
404            fingerprint.digest
405        );
406        // Last-by-step wins, not last-by-log-order.
407        assert_eq!(summary.metrics["val_f1"], 0.9125);
408
409        let c = &summary.conclusion;
410        assert_eq!(c.outcome, Some(RunOutcome::Completed));
411        let cost = c.dominant_cost.as_ref().unwrap();
412        assert_eq!(cost.node_id, "b");
413        assert_eq!(cost.duration_ms, 900);
414        assert!((cost.share - 0.9).abs() < 1e-9);
415        assert_eq!(c.health_flags[0].flag, "DEAD_CHANNELS");
416        assert_eq!(c.health_flags[0].nodes, vec!["b"]);
417        assert!(c.warnings.is_empty(), "{:?}", c.warnings);
418
419        assert!(c.headline.starts_with("completed in "), "{}", c.headline);
420        assert!(c.headline.contains("val_f1=0.9125"), "{}", c.headline);
421        assert!(c.headline.contains("slowest b (900ms, 90% of compute)"));
422        assert!(c.headline.contains("flags: DEAD_CHANNELS"));
423    }
424
425    #[test]
426    fn headline_is_deterministic() {
427        let (_root, tracker) = tracker(RunKind::Fit, "repeat");
428        let sink = tracker.sink();
429        for name in ["b_metric", "a_metric"] {
430            sink.record(&Event::MetricReported {
431                run_id: tracker.run_id().into(),
432                metric: metric(name, 1.0, 0),
433                node_id: None,
434                trial_id: None,
435            });
436        }
437        tracker.finalize(RunState::Completed).unwrap();
438        let reader = RunReader::open(tracker.run_dir()).unwrap();
439        let first = summarize(&reader).unwrap().conclusion.headline;
440        for _ in 0..5 {
441            assert_eq!(summarize(&reader).unwrap().conclusion.headline, first);
442        }
443        // Metric order comes from the name, not the log.
444        assert!(first.contains("a_metric=1 b_metric=1"), "{first}");
445    }
446
447    #[test]
448    fn a_failed_run_leads_with_its_error() {
449        let (_root, tracker) = tracker(RunKind::Train, "boom");
450        let sink = tracker.sink();
451        sink.record(&Event::NodeStarted {
452            run_id: tracker.run_id().into(),
453            node_id: "encoder".into(),
454            kind: FilterKind::Trainable,
455            effectful: false,
456        });
457        sink.record(&Event::NodeFailed {
458            run_id: tracker.run_id().into(),
459            node_id: "encoder".into(),
460            error: "shape mismatch: expected [32, 8]\ngot [32, 16]".into(),
461        });
462        tracker.finalize(RunState::Failed).unwrap();
463
464        let summary = summarize(&RunReader::open(tracker.run_dir()).unwrap()).unwrap();
465        assert_eq!(summary.conclusion.outcome, Some(RunOutcome::Failed));
466        let headline = &summary.conclusion.headline;
467        assert!(headline.starts_with("failed after "), "{headline}");
468        assert!(headline.contains("error: shape mismatch"), "{headline}");
469        // Newlines never leak into a one-line headline.
470        assert!(!headline.contains('\n'));
471    }
472
473    #[test]
474    fn a_bare_run_dir_summarizes_with_warnings() {
475        let (_root, tracker) = tracker(RunKind::Other, "empty");
476        tracker.finalize(RunState::Completed).unwrap();
477        let summary = summarize(&RunReader::open(tracker.run_dir()).unwrap()).unwrap();
478        assert_eq!(summary.pipeline_summary, "");
479        assert!(summary.architecture.is_none());
480        assert!(summary.metrics.is_empty());
481        assert!(summary.conclusion.dominant_cost.is_none());
482        assert!(summary.conclusion.cache_hit_ratio.is_none());
483        assert!(
484            summary
485                .conclusion
486                .warnings
487                .iter()
488                .any(|w| w.contains("without recording any metric"))
489        );
490        assert!(summary.conclusion.headline.starts_with("completed in "));
491    }
492
493    #[test]
494    fn malformed_artifacts_warn_instead_of_failing() {
495        let (_root, tracker) = tracker(RunKind::Train, "torn");
496        tracker.save_artifact("graph.json", b"{not json").unwrap();
497        tracker.save_artifact("fingerprint.json", b"[]").unwrap();
498        tracker
499            .save_artifact("diagnostics/report.json", b"{\"filters\": 3}")
500            .unwrap();
501        // A torn tail from a crash mid-write: skipped, never fatal.
502        tracker
503            .save_artifact("events.jsonl", b"{\"seq\":0,\"ts\":\"nope\"}\n{trunc")
504            .unwrap();
505        tracker.finalize(RunState::Completed).unwrap();
506
507        let summary = summarize(&RunReader::open(tracker.run_dir()).unwrap()).unwrap();
508        let warnings = summary.conclusion.warnings.join(" | ");
509        assert!(warnings.contains("graph.json is unreadable"), "{warnings}");
510        assert!(
511            warnings.contains("fingerprint.json is malformed"),
512            "{warnings}"
513        );
514        assert!(
515            warnings.contains("diagnostics/report.json is malformed"),
516            "{warnings}"
517        );
518        assert!(!summary.conclusion.headline.is_empty());
519    }
520
521    #[test]
522    fn audit_report_flags_are_grouped_by_family() {
523        let (_root, tracker) = tracker(RunKind::Train, "audited");
524        let report = serde_json::json!({
525            "n_steps": 30,
526            "filters": [
527                {"filter": "enc", "n_steps": 30, "metrics": {}, "flags": ["DEAD_CHANNELS"]},
528                {"filter": "enc/layers.0", "n_steps": 30, "metrics": {},
529                 "flags": ["DEAD_CHANNELS", "LEAKAGE"]},
530            ],
531        });
532        tracker
533            .save_artifact("diagnostics/report.json", report.to_string().as_bytes())
534            .unwrap();
535        tracker.finalize(RunState::Completed).unwrap();
536
537        let summary = summarize(&RunReader::open(tracker.run_dir()).unwrap()).unwrap();
538        let flags = &summary.conclusion.audit_flags;
539        assert_eq!(flags.len(), 2);
540        assert_eq!(flags[0].flag, "DEAD_CHANNELS");
541        assert_eq!(flags[0].count, 2);
542        assert_eq!(flags[0].nodes, vec!["enc", "enc/layers.0"]);
543        assert_eq!(flags[1].flag, "LEAKAGE");
544        assert!(
545            summary
546                .conclusion
547                .headline
548                .contains("flags: DEAD_CHANNELS×2, LEAKAGE")
549        );
550    }
551
552    #[test]
553    fn cache_ratio_counts_hits_over_attempts() {
554        let (_root, tracker) = tracker(RunKind::Fit, "cached");
555        let sink = tracker.sink();
556        let run_id = tracker.run_id().to_string();
557        sink.record(&Event::NodeCacheHit {
558            run_id: run_id.clone(),
559            node_id: "a".into(),
560            key: CacheKey::hash_data(b"k"),
561            tier: somatize_core::cache::CacheTier::Memory,
562            load_time: Duration::from_millis(2),
563        });
564        for node in ["b", "c", "d"] {
565            sink.record(&Event::NodeCacheMiss {
566                run_id: run_id.clone(),
567                node_id: node.into(),
568                key: CacheKey::hash_data(b"k"),
569            });
570        }
571        tracker.finalize(RunState::Completed).unwrap();
572
573        let summary = summarize(&RunReader::open(tracker.run_dir()).unwrap()).unwrap();
574        assert_eq!(summary.conclusion.cache_hit_ratio, Some(0.25));
575        assert!(summary.conclusion.headline.contains("cache 25% hits"));
576    }
577
578    #[test]
579    fn a_study_run_summarizes_its_trials() {
580        use somatize_core::search::SearchSpace;
581        use somatize_core::study::{
582            Direction, Objective, SearchStrategy, Study, Trial, TrialState,
583        };
584
585        let (_root, tracker) = tracker(RunKind::Study, "sweep");
586        let mut study = Study::new(
587            "sweep",
588            SearchSpace::new(),
589            SearchStrategy::Random {
590                n_trials: 4,
591                seed: Some(0),
592            },
593            vec![Objective {
594                metric: "val_f1".into(),
595                direction: Direction::Maximize,
596            }],
597        );
598        for (id, state, value) in [
599            ("t0", TrialState::Completed, 0.80),
600            ("t1", TrialState::Completed, 0.91),
601            (
602                "t2",
603                TrialState::Pruned {
604                    step: 2,
605                    reason: "median".into(),
606                },
607                0.40,
608            ),
609            (
610                "t3",
611                TrialState::Failed {
612                    error: "oom".into(),
613                },
614                0.0,
615            ),
616        ] {
617            let mut trial = Trial::new(id, Default::default());
618            trial.state = state;
619            trial.metrics.push(metric("val_f1", value, 0));
620            study.trials.push(trial);
621        }
622        tracker.save_study(&study).unwrap();
623        tracker.finalize(RunState::Completed).unwrap();
624
625        let summary = summarize(&RunReader::open(tracker.run_dir()).unwrap()).unwrap();
626        let trials = summary.conclusion.trials.as_ref().unwrap();
627        assert_eq!(trials.total, 4);
628        assert_eq!(trials.completed, 2);
629        assert_eq!(trials.pruned, 1);
630        assert_eq!(trials.failed, 1);
631        assert_eq!(trials.objective.as_deref(), Some("val_f1"));
632        assert_eq!(trials.best_trial_id.as_deref(), Some("t1"));
633        assert_eq!(trials.best_value, Some(0.91));
634        assert!(
635            summary
636                .conclusion
637                .headline
638                .contains("4 trials (1 pruned, 1 failed), best val_f1=0.91"),
639            "{}",
640            summary.conclusion.headline
641        );
642        // A study run has no graph, but the sweep is its shape.
643        assert_eq!(summary.pipeline_summary, "study over 4 trials");
644        assert!(summary.conclusion.warnings.is_empty());
645    }
646}