1use crate::fingerprint::ArchitectureFingerprint;
17use crate::tracking::GitInfo;
18use chrono::{DateTime, Utc};
19use serde::{Deserialize, Serialize};
20use std::collections::BTreeMap;
21use std::fmt::Write as _;
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
25#[serde(rename_all = "snake_case")]
26#[non_exhaustive]
27pub enum RunOutcome {
28 Completed,
30 Failed,
32 Crashed,
34 Running,
36}
37
38impl RunOutcome {
39 pub fn from_state(state: &str) -> Self {
44 match state {
45 "completed" => Self::Completed,
46 "failed" => Self::Failed,
47 "crashed" => Self::Crashed,
48 _ => Self::Running,
49 }
50 }
51
52 pub fn verb(&self) -> &'static str {
54 match self {
55 Self::Completed => "completed",
56 Self::Failed => "failed",
57 Self::Crashed => "crashed",
58 Self::Running => "running",
59 }
60 }
61}
62
63#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
65pub struct NodeCost {
66 pub node_id: String,
68 pub duration_ms: u64,
70 pub share: f64,
72}
73
74#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
76pub struct FlagCount {
77 pub flag: String,
79 pub count: usize,
81 pub nodes: Vec<String>,
83}
84
85impl FlagCount {
86 pub fn group(flag: impl Into<String>, mut nodes: Vec<String>) -> Self {
89 let count = nodes.len();
90 nodes.sort();
91 nodes.dedup();
92 Self {
93 flag: flag.into(),
94 count,
95 nodes,
96 }
97 }
98
99 pub fn merge_all(a: &[FlagCount], b: &[FlagCount]) -> Vec<FlagCount> {
101 let mut grouped: BTreeMap<&str, Vec<String>> = BTreeMap::new();
102 for flag in a.iter().chain(b) {
103 grouped
104 .entry(flag.flag.as_str())
105 .or_default()
106 .extend(flag.nodes.iter().cloned());
107 }
108 grouped
109 .into_iter()
110 .map(|(flag, nodes)| FlagCount::group(flag, nodes))
111 .collect()
112 }
113}
114
115#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
121pub struct AgentCost {
122 pub turns: u64,
124 pub input_tokens: u64,
126 pub output_tokens: u64,
128 pub tool_calls: u64,
130 pub steps_failed: u64,
132 pub suspensions: u64,
134}
135
136#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
138pub struct TrialSummary {
139 pub total: usize,
141 pub completed: usize,
143 pub pruned: usize,
145 pub failed: usize,
147 pub best_trial_id: Option<String>,
149 pub best_value: Option<f64>,
151 pub objective: Option<String>,
153}
154
155#[derive(Debug, Clone, Default, Serialize, Deserialize)]
157pub struct RunConclusion {
158 #[serde(default)]
160 pub headline: String,
161 #[serde(default)]
163 pub outcome: Option<RunOutcome>,
164 #[serde(default)]
166 pub dominant_cost: Option<NodeCost>,
167 #[serde(default)]
169 pub cache_hit_ratio: Option<f64>,
170 #[serde(default)]
172 pub health_flags: Vec<FlagCount>,
173 #[serde(default)]
178 pub audit_flags: Vec<FlagCount>,
179 #[serde(default)]
181 pub trials: Option<TrialSummary>,
182 #[serde(default)]
184 pub agent_cost: Option<AgentCost>,
185 #[serde(default)]
188 pub warnings: Vec<String>,
189}
190
191const HEADLINE_METRICS: usize = 3;
193
194impl RunConclusion {
195 pub fn is_empty(&self) -> bool {
197 self.outcome.is_none()
198 && self.dominant_cost.is_none()
199 && self.cache_hit_ratio.is_none()
200 && self.health_flags.is_empty()
201 && self.audit_flags.is_empty()
202 && self.trials.is_none()
203 && self.agent_cost.is_none()
204 }
205
206 pub fn render_headline(
213 &self,
214 duration_ms: Option<u64>,
215 metrics: &BTreeMap<String, f64>,
216 error: Option<&str>,
217 ) -> String {
218 let mut parts: Vec<String> = Vec::new();
219
220 let outcome = self.outcome.unwrap_or(RunOutcome::Running);
221 let preposition = match outcome {
222 RunOutcome::Completed => "in",
223 RunOutcome::Running => "for",
224 _ => "after",
225 };
226 parts.push(match duration_ms {
227 Some(ms) => format!("{} {preposition} {}", outcome.verb(), human_duration(ms)),
228 None => outcome.verb().to_string(),
229 });
230
231 if let Some(error) = error {
232 parts.push(format!("error: {}", one_line(error, 120)));
233 }
234
235 if let Some(trials) = &self.trials {
236 let mut line = format!("{} trials", trials.total);
237 let mut lost = Vec::new();
238 if trials.pruned > 0 {
239 lost.push(format!("{} pruned", trials.pruned));
240 }
241 if trials.failed > 0 {
242 lost.push(format!("{} failed", trials.failed));
243 }
244 if !lost.is_empty() {
245 let _ = write!(line, " ({})", lost.join(", "));
246 }
247 match (&trials.objective, trials.best_value) {
248 (Some(objective), Some(best)) => {
249 let _ = write!(line, ", best {objective}={}", round4(best));
250 }
251 _ if trials.total > 0 => line.push_str(", no scorable trial"),
254 _ => {}
255 }
256 parts.push(line);
257 }
258
259 if !metrics.is_empty() {
260 let mut named: Vec<String> = metrics
261 .iter()
262 .take(HEADLINE_METRICS)
263 .map(|(name, value)| format!("{name}={}", round4(*value)))
264 .collect();
265 if metrics.len() > HEADLINE_METRICS {
266 named.push(format!("+{} more", metrics.len() - HEADLINE_METRICS));
267 }
268 parts.push(named.join(" "));
269 }
270
271 if let Some(cost) = &self.dominant_cost {
272 parts.push(format!(
273 "slowest {} ({}, {}% of compute)",
274 cost.node_id,
275 human_duration(cost.duration_ms),
276 (cost.share * 100.0).round() as i64
277 ));
278 }
279
280 if let Some(ratio) = self.cache_hit_ratio {
281 parts.push(format!("cache {}% hits", (ratio * 100.0).round() as i64));
282 }
283
284 if let Some(agent) = &self.agent_cost {
285 let mut line = format!("agent {} turns", agent.turns);
286 if agent.input_tokens + agent.output_tokens > 0 {
287 let _ = write!(
288 line,
289 ", {}→{} tokens",
290 human_count(agent.input_tokens),
291 human_count(agent.output_tokens)
292 );
293 }
294 if agent.tool_calls > 0 {
295 let _ = write!(line, ", {} tool calls", agent.tool_calls);
296 }
297 if agent.steps_failed > 0 {
298 let _ = write!(line, ", {} steps failed", agent.steps_failed);
299 }
300 if agent.suspensions > 0 {
301 let _ = write!(line, ", {} suspended", agent.suspensions);
302 }
303 parts.push(line);
304 }
305
306 let flags = FlagCount::merge_all(&self.health_flags, &self.audit_flags);
307 if !flags.is_empty() {
308 let rendered: Vec<String> = flags
309 .iter()
310 .map(|f| {
311 if f.count > 1 {
312 format!("{}×{}", f.flag, f.count)
313 } else {
314 f.flag.clone()
315 }
316 })
317 .collect();
318 parts.push(format!("flags: {}", rendered.join(", ")));
319 }
320
321 parts.join(" · ")
322 }
323}
324
325#[derive(Debug, Clone, Serialize, Deserialize)]
331pub struct RunSummary {
332 pub run_id: String,
334 pub run_dir: String,
336 pub name: String,
338 pub kind: String,
340 pub created_at: DateTime<Utc>,
342 #[serde(default)]
344 pub finished_at: Option<DateTime<Utc>>,
345 #[serde(default)]
347 pub duration_ms: Option<u64>,
348 #[serde(default)]
350 pub tags: Vec<String>,
351 #[serde(default)]
353 pub git: GitInfo,
354 #[serde(default)]
356 pub seeds: BTreeMap<String, i64>,
357 #[serde(default)]
359 pub params: BTreeMap<String, serde_json::Value>,
360 #[serde(default)]
362 pub hypothesis: Option<String>,
363 #[serde(default)]
365 pub parent_run_id: Option<String>,
366 #[serde(default)]
369 pub architecture: Option<ArchitectureFingerprint>,
370 #[serde(default)]
372 pub pipeline_summary: String,
373 #[serde(default)]
375 pub metrics: BTreeMap<String, f64>,
376 #[serde(default)]
378 pub conclusion: RunConclusion,
379}
380
381pub fn human_count(n: u64) -> String {
383 if n < 1_000 {
384 return n.to_string();
385 }
386 if n < 1_000_000 {
387 return format!("{:.1}k", n as f64 / 1_000.0);
388 }
389 format!("{:.1}M", n as f64 / 1_000_000.0)
390}
391
392pub fn human_duration(ms: u64) -> String {
394 if ms < 1_000 {
395 return format!("{ms}ms");
396 }
397 let secs = ms as f64 / 1000.0;
398 if secs < 60.0 {
399 return format!("{secs:.1}s");
400 }
401 let total = ms / 1000;
402 let (h, m, s) = (total / 3600, (total % 3600) / 60, total % 60);
403 if h > 0 {
404 format!("{h}h {m:02}m")
405 } else {
406 format!("{m}m {s:02}s")
407 }
408}
409
410pub fn round4(value: f64) -> String {
412 if !value.is_finite() {
413 return format!("{value}");
414 }
415 let text = format!("{value:.4}");
416 let trimmed = text.trim_end_matches('0').trim_end_matches('.');
417 if trimmed.is_empty() { "0" } else { trimmed }.to_string()
418}
419
420pub fn one_line(text: &str, max: usize) -> String {
423 let text = text.replace('\n', " ");
424 if text.chars().count() <= max {
425 return text;
426 }
427 let head: String = text.chars().take(max).collect();
428 format!("{head}…")
429}
430
431#[cfg(test)]
432mod tests {
433 use super::*;
434
435 fn metrics(pairs: &[(&str, f64)]) -> BTreeMap<String, f64> {
436 pairs.iter().map(|(k, v)| ((*k).to_string(), *v)).collect()
437 }
438
439 #[test]
440 fn headline_sections_appear_in_a_fixed_order() {
441 let conclusion = RunConclusion {
442 outcome: Some(RunOutcome::Completed),
443 dominant_cost: Some(NodeCost {
444 node_id: "encoder".into(),
445 duration_ms: 9_000,
446 share: 0.75,
447 }),
448 cache_hit_ratio: Some(0.5),
449 health_flags: vec![FlagCount::group("LEAKAGE", vec!["a".into()])],
450 audit_flags: vec![FlagCount::group("LEAKAGE", vec!["b".into()])],
451 trials: Some(TrialSummary {
452 total: 12,
453 pruned: 4,
454 objective: Some("val_f1".into()),
455 best_value: Some(0.9),
456 ..TrialSummary::default()
457 }),
458 ..RunConclusion::default()
459 };
460 let headline = conclusion.render_headline(Some(12_000), &metrics(&[("loss", 0.25)]), None);
461 assert_eq!(
462 headline,
463 "completed in 12.0s · 12 trials (4 pruned), best val_f1=0.9 · loss=0.25 · \
464 slowest encoder (9.0s, 75% of compute) · cache 50% hits · flags: LEAKAGE×2"
465 );
466 }
467
468 #[test]
469 fn headline_is_stable_across_renderings() {
470 let conclusion = RunConclusion {
471 outcome: Some(RunOutcome::Completed),
472 ..RunConclusion::default()
473 };
474 let m = metrics(&[("b", 1.0), ("a", 2.0), ("d", 3.0), ("c", 4.0)]);
475 let first = conclusion.render_headline(Some(1_000), &m, None);
476 for _ in 0..5 {
477 assert_eq!(conclusion.render_headline(Some(1_000), &m, None), first);
478 }
479 assert!(first.contains("a=2 b=1 c=4 +1 more"), "{first}");
481 }
482
483 #[test]
484 fn an_error_never_breaks_the_single_line_contract() {
485 let conclusion = RunConclusion {
486 outcome: Some(RunOutcome::Failed),
487 ..RunConclusion::default()
488 };
489 let headline = conclusion.render_headline(
490 Some(500),
491 &BTreeMap::new(),
492 Some("shape mismatch\nexpected [32, 8]\ngot [32, 16]"),
493 );
494 assert_eq!(
495 headline,
496 "failed after 500ms · error: shape mismatch expected [32, 8] got [32, 16]"
497 );
498 assert!(!headline.contains('\n'));
499 }
500
501 #[test]
502 fn a_headline_without_a_duration_still_names_the_outcome() {
503 let conclusion = RunConclusion {
504 outcome: Some(RunOutcome::Running),
505 ..RunConclusion::default()
506 };
507 assert_eq!(
508 conclusion.render_headline(None, &BTreeMap::new(), None),
509 "running"
510 );
511 assert_eq!(
512 conclusion.render_headline(Some(30_000), &BTreeMap::new(), None),
513 "running for 30.0s"
514 );
515 }
516
517 #[test]
518 fn flag_grouping_counts_occurrences_and_dedupes_places() {
519 let flag = FlagCount::group("DEAD_CHANNELS", vec!["b".into(), "a".into(), "a".into()]);
520 assert_eq!(flag.count, 3);
521 assert_eq!(flag.nodes, vec!["a", "b"]);
522
523 let merged = FlagCount::merge_all(
524 &[FlagCount::group("X", vec!["n1".into()])],
525 &[
526 FlagCount::group("X", vec!["n2".into()]),
527 FlagCount::group("A", vec!["n3".into()]),
528 ],
529 );
530 assert_eq!(merged.len(), 2);
531 assert_eq!(merged[0].flag, "A", "merged flags sort by name");
532 assert_eq!(merged[1].flag, "X");
533 assert_eq!(merged[1].count, 2);
534 assert_eq!(merged[1].nodes, vec!["n1", "n2"]);
535 }
536
537 #[test]
538 fn conclusion_emptiness_ignores_the_headline() {
539 assert!(RunConclusion::default().is_empty());
540 let only_text = RunConclusion {
541 headline: "something".into(),
542 ..RunConclusion::default()
543 };
544 assert!(only_text.is_empty(), "prose alone is not a fact");
545 let with_outcome = RunConclusion {
546 outcome: Some(RunOutcome::Failed),
547 ..RunConclusion::default()
548 };
549 assert!(!with_outcome.is_empty());
550 }
551
552 #[test]
553 fn summary_roundtrips_and_tolerates_a_minimal_record() {
554 let summary = RunSummary {
555 run_id: "r1".into(),
556 run_dir: "/tmp/r1".into(),
557 name: "baseline".into(),
558 kind: "train".into(),
559 created_at: Utc::now(),
560 finished_at: None,
561 duration_ms: Some(10),
562 tags: vec!["mos".into()],
563 git: GitInfo::default(),
564 seeds: BTreeMap::from([("torch".into(), 42)]),
565 params: BTreeMap::from([("lr".into(), serde_json::json!(0.01))]),
566 hypothesis: Some("wider is better".into()),
567 parent_run_id: Some("r0".into()),
568 architecture: None,
569 pipeline_summary: "a → b".into(),
570 metrics: metrics(&[("f1", 0.5)]),
571 conclusion: RunConclusion::default(),
572 };
573 let json = serde_json::to_string(&summary).unwrap();
574 let back: RunSummary = serde_json::from_str(&json).unwrap();
575 assert_eq!(back.run_id, "r1");
576 assert_eq!(back.seeds["torch"], 42);
577 assert_eq!(back.params["lr"], serde_json::json!(0.01));
578 assert_eq!(back.hypothesis.as_deref(), Some("wider is better"));
579
580 let minimal = serde_json::json!({
581 "run_id": "r", "run_dir": "/tmp/r", "name": "n", "kind": "fit",
582 "created_at": "2026-07-30T10:00:00Z",
583 });
584 let back: RunSummary = serde_json::from_value(minimal).unwrap();
585 assert!(back.metrics.is_empty());
586 assert!(back.params.is_empty());
587 assert!(back.conclusion.is_empty());
588 }
589
590 #[test]
591 fn unknown_outcome_reads_as_running_not_success() {
592 assert_eq!(RunOutcome::from_state("completed"), RunOutcome::Completed);
593 assert_eq!(RunOutcome::from_state("crashed"), RunOutcome::Crashed);
594 assert_eq!(RunOutcome::from_state("teleported"), RunOutcome::Running);
595 }
596
597 #[test]
598 fn human_duration_scales() {
599 assert_eq!(human_duration(0), "0ms");
600 assert_eq!(human_duration(840), "840ms");
601 assert_eq!(human_duration(2_400), "2.4s");
602 assert_eq!(human_duration(59_900), "59.9s");
603 assert_eq!(human_duration(187_000), "3m 07s");
604 assert_eq!(human_duration(4_320_000), "1h 12m");
605 }
606
607 #[test]
608 fn round4_trims_without_losing_precision() {
609 assert_eq!(round4(1.0), "1");
610 assert_eq!(round4(0.9125), "0.9125");
611 assert_eq!(round4(0.912_549), "0.9125");
612 assert_eq!(round4(-0.5), "-0.5");
613 assert_eq!(round4(f64::NAN), "NaN");
614 }
615
616 #[test]
617 fn one_line_truncates_on_characters_not_bytes() {
618 assert_eq!(one_line("abc", 10), "abc");
619 assert_eq!(one_line("a\nb", 10), "a b");
620 assert_eq!(one_line("ααααα", 3), "ααα…");
621 }
622}