1use 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
29pub 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 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
124fn 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 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
188fn 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
237fn 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
261fn 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 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 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 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 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 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 assert_eq!(summary.pipeline_summary, "study over 4 trials");
644 assert!(summary.conclusion.warnings.is_empty());
645 }
646}