Skip to main content

somatize_mcp/
render.rs

1//! Rendering the experiment pool for a model to read.
2//!
3//! MCP carries text. There is no structured result a client will render
4//! for us, so **the text is the API**: what these functions emit is
5//! what the model sees, and its shape decides whether the model can
6//! follow a lineage, compare two runs, or notice that it is looking at
7//! a dead end.
8//!
9//! Three rules hold everywhere below.
10//!
11//! - **Every result ends with a `next:` line.** A model that has just
12//!   read a hit should not have to guess what the follow-up call is
13//!   named or which argument it takes.
14//! - **Every experiment shows its `run_dir`.** The pool summarizes; the
15//!   run directory has the events, the diagnostics and the figures. A
16//!   model with file tools can go and read them.
17//! - **Absence is stated, never faked.** "no conclusion recorded" is a
18//!   useful sentence; a blank line is not.
19//!
20//! Every function here is pure — a value in, a `String` out — so the
21//! output is snapshot-tested rather than merely eyeballed.
22
23use somatize_core::summary::{RunSummary, human_duration, round4};
24use somatize_memory::knowledge_base::Lineage;
25use somatize_memory::{
26    Change, DerivationMove, ExperimentRecord, ResearchLine, ScoredRecord, is_dead_end,
27};
28use std::fmt::Write as _;
29
30/// Metrics listed inline before the list is cut short.
31const MAX_METRICS: usize = 6;
32
33/// Ranked hits for `kb_find_similar`.
34pub fn find_similar(hits: &[ScoredRecord], query: &str) -> String {
35    if hits.is_empty() {
36        return format!(
37            "No experiments match \"{query}\".\n\n\
38             The pool may simply be empty — `kb_stats` says how much is in it, and \
39             `soma kb reindex` rebuilds it from the run directories if the journal was lost.\n\n\
40             next: kb_stats()"
41        );
42    }
43    let mut out = format!(
44        "# {} experiment{} matching \"{query}\"\n",
45        hits.len(),
46        plural(hits.len())
47    );
48    for (i, hit) in hits.iter().enumerate() {
49        let _ = write!(
50            out,
51            "\n## {}. {} — {:.2}\n{}",
52            i + 1,
53            hit.record.name,
54            hit.score,
55            record_body(&hit.record)
56        );
57        let _ = writeln!(out, "why: {}", hit.why());
58    }
59    out.push_str(&next(&[
60        &format!("kb_lineage(id=\"{}\")", hits[0].record.id),
61        &format!("kb_summarize_run(run_id=\"{}\")", hits[0].record.id),
62        &format!("kb_branch_from(run_id=\"{}\")", hits[0].record.id),
63    ]));
64    out
65}
66
67/// One experiment, as the body of a list entry.
68fn record_body(record: &ExperimentRecord) -> String {
69    let mut out = String::new();
70    let _ = writeln!(out, "id: {}", record.id);
71
72    if let Some(derivation) = &record.derivation
73        && !derivation.summary.is_empty()
74    {
75        let _ = writeln!(out, "move: {}", derivation.summary);
76    }
77    match &record.conclusion {
78        Some(c) if !c.headline.is_empty() => {
79            let _ = writeln!(out, "outcome: {}", c.headline);
80        }
81        _ => out.push_str("outcome: no conclusion recorded\n"),
82    }
83    if is_dead_end(record) {
84        out.push_str("⚠ dead end — worth reading before trying this again\n");
85    }
86    if !record.pipeline_summary.is_empty() {
87        let _ = writeln!(out, "pipeline: {}", record.pipeline_summary);
88    }
89    if let Some(hypothesis) = &record.hypothesis {
90        let _ = writeln!(out, "hypothesis: {hypothesis}");
91    }
92    if let Some(notes) = &record.notes {
93        let _ = writeln!(out, "notes: {notes}");
94    }
95    let mut context = Vec::new();
96    if let Some(line) = &record.research_line {
97        context.push(format!("line {line}"));
98    }
99    if let Some(parent) = &record.parent {
100        context.push(format!("parent {parent}"));
101    }
102    if !record.tags.is_empty() {
103        context.push(format!("tags {}", record.tags.join(", ")));
104    }
105    if !context.is_empty() {
106        let _ = writeln!(out, "context: {}", context.join(" · "));
107    }
108    if !record.metrics.is_empty() {
109        let _ = writeln!(out, "metrics: {}", metrics_line(record));
110    }
111    if !record.params.is_empty() {
112        let _ = writeln!(out, "params: {}", params_line(record));
113    }
114    match &record.run_dir {
115        Some(dir) => {
116            let _ = writeln!(out, "run_dir: {dir}");
117        }
118        None => out.push_str("run_dir: none (recorded without a run directory)\n"),
119    }
120    out
121}
122
123/// A lineage tree with the move on every edge — the whole point of
124/// recording derivations rather than only parents.
125pub fn lineage(lineage: &Lineage) -> String {
126    let mut out = format!(
127        "# Lineage of {} — {}\n\n",
128        lineage.focus.id, lineage.focus.name
129    );
130
131    for (depth, ancestor) in lineage.ancestors.iter().enumerate() {
132        out.push_str(&tree_line(depth, ancestor, false));
133    }
134    let focus_depth = lineage.ancestors.len();
135    out.push_str(&tree_line(focus_depth, &lineage.focus, true));
136    for node in &lineage.descendants {
137        out.push_str(&tree_line(focus_depth + node.depth, &node.record, false));
138    }
139
140    let _ = write!(
141        out,
142        "\n{} ancestor{}, {} descendant{}.\n",
143        lineage.ancestors.len(),
144        plural(lineage.ancestors.len()),
145        lineage.descendants.len(),
146        plural(lineage.descendants.len())
147    );
148    if lineage.ancestors.is_empty() && lineage.descendants.is_empty() {
149        out.push_str(
150            "This experiment stands alone. Runs get a parent from `.soma/HEAD`; \
151             `kb_branch_from` points HEAD at a run so the next one descends from it.\n",
152        );
153    }
154
155    let mut follow_ups = vec![format!("kb_summarize_run(run_id=\"{}\")", lineage.focus.id)];
156    if let Some(parent) = lineage.ancestors.last() {
157        follow_ups.push(format!(
158            "kb_diff(a=\"{}\", b=\"{}\")",
159            parent.id, lineage.focus.id
160        ));
161    }
162    follow_ups.push(format!("kb_branch_from(run_id=\"{}\")", lineage.focus.id));
163    out.push_str(&next(
164        &follow_ups.iter().map(String::as_str).collect::<Vec<_>>(),
165    ));
166    out
167}
168
169/// One indented node, with the move that produced it.
170fn tree_line(depth: usize, record: &ExperimentRecord, is_focus: bool) -> String {
171    let indent = "  ".repeat(depth);
172    let marker = if is_focus { "▶" } else { "·" };
173    let mut line = format!("{indent}{marker} {} — {}", record.id, record.name);
174    if let Some(derivation) = &record.derivation
175        && !derivation.summary.is_empty()
176    {
177        let _ = write!(line, "  ← {}", derivation.summary);
178    }
179    line.push('\n');
180    if let Some(conclusion) = &record.conclusion
181        && !conclusion.headline.is_empty()
182    {
183        let _ = writeln!(line, "{indent}    {}", conclusion.headline);
184    }
185    line
186}
187
188/// Two experiments side by side: what changed, what it did to the
189/// metrics, and what it cost.
190pub fn diff(a: &ExperimentRecord, b: &ExperimentRecord, move_: &DerivationMove) -> String {
191    let mut out = format!(
192        "# {} → {}\n\n{} — {}\n{} — {}\n\n",
193        a.id, b.id, a.id, a.name, b.id, b.name
194    );
195
196    out.push_str("## Changes\n\n");
197    if move_.changes.is_empty() {
198        out.push_str("- (none detected)\n");
199    }
200    for change in &move_.changes {
201        let _ = writeln!(out, "- {}", change.describe());
202        if let Change::Unspecified { reason } = change {
203            let _ = writeln!(
204                out,
205                "  (soma cannot describe this move: {reason}. The run directories hold the \
206                 raw graph.json if they still exist.)"
207            );
208        }
209    }
210
211    out.push_str("\n## Metrics\n\n");
212    if move_.metric_delta.is_empty() {
213        out.push_str("- no metric appears in both runs\n");
214    }
215    for (name, delta) in &move_.metric_delta {
216        let _ = writeln!(
217            out,
218            "- {name}: {} → {} ({}{})",
219            round4(delta.before),
220            round4(delta.after),
221            if delta.delta >= 0.0 { "+" } else { "−" },
222            round4(delta.delta.abs())
223        );
224    }
225    out.push_str(
226        "\nSigns are raw differences. Whether up is good depends on the objective — \
227         soma does not guess.\n",
228    );
229
230    out.push_str("\n## Cost\n\n");
231    out.push_str(&cost_rows(a, b));
232
233    for record in [a, b] {
234        if let Some(dir) = &record.run_dir {
235            let _ = writeln!(out, "\nrun_dir ({}): {dir}", record.id);
236        }
237    }
238    out.push_str(&next(&[
239        &format!("kb_lineage(id=\"{}\")", b.id),
240        &format!("kb_record_conclusion(run_id=\"{}\", notes=\"...\")", b.id),
241    ]));
242    out
243}
244
245/// Wall time and cache effectiveness, which are as much a result as the
246/// metrics are — a variant that matches the baseline in half the time
247/// is a win the metric table cannot show.
248fn cost_rows(a: &ExperimentRecord, b: &ExperimentRecord) -> String {
249    let mut out = String::new();
250    let (ms_a, ms_b) = (a.duration.as_millis() as u64, b.duration.as_millis() as u64);
251    let _ = write!(
252        out,
253        "- duration: {} → {}",
254        human_duration(ms_a),
255        human_duration(ms_b)
256    );
257    if ms_a > 0 {
258        let ratio = ms_b as f64 / ms_a as f64;
259        let _ = write!(out, " ({ratio:.2}×)");
260    }
261    out.push('\n');
262
263    let hit_ratio = |r: &ExperimentRecord| r.conclusion.as_ref().and_then(|c| c.cache_hit_ratio);
264    match (hit_ratio(a), hit_ratio(b)) {
265        (Some(x), Some(y)) => {
266            let _ = writeln!(
267                out,
268                "- cache hits: {}% → {}%",
269                (x * 100.0).round() as i64,
270                (y * 100.0).round() as i64
271            );
272        }
273        _ => out.push_str("- cache hits: not recorded for both runs\n"),
274    }
275    out
276}
277
278/// A run directory summarized on demand — works on runs recorded long
279/// before the pool existed.
280pub fn summarize_run(summary: &RunSummary) -> String {
281    let mut out = format!("# {} — {}\n\n", summary.run_id, summary.name);
282    let _ = writeln!(out, "kind: {}", summary.kind);
283    let _ = writeln!(out, "started: {}", summary.created_at.to_rfc3339());
284    if let Some(ms) = summary.duration_ms {
285        let _ = writeln!(out, "duration: {}", human_duration(ms));
286    }
287    let _ = writeln!(out, "outcome: {}", summary.conclusion.headline);
288    if !summary.pipeline_summary.is_empty() {
289        let _ = writeln!(out, "pipeline: {}", summary.pipeline_summary);
290    }
291    if let Some(architecture) = &summary.architecture {
292        let _ = writeln!(
293            out,
294            "architecture: {} ({} nodes, {} edges)",
295            architecture.short(),
296            architecture.n_nodes,
297            architecture.n_edges
298        );
299    }
300    if let Some(parent) = &summary.parent_run_id {
301        let _ = writeln!(out, "parent: {parent}");
302    }
303    if let Some(hypothesis) = &summary.hypothesis {
304        let _ = writeln!(out, "hypothesis: {hypothesis}");
305    }
306
307    if !summary.metrics.is_empty() {
308        out.push_str("\n## Metrics\n\n");
309        for (name, value) in &summary.metrics {
310            let _ = writeln!(out, "- {name}: {}", round4(*value));
311        }
312    }
313    if let Some(trials) = &summary.conclusion.trials {
314        let _ = write!(
315            out,
316            "\n## Trials\n\n- {} total, {} completed, {} pruned, {} failed\n",
317            trials.total, trials.completed, trials.pruned, trials.failed
318        );
319        if let (Some(objective), Some(best)) = (&trials.objective, trials.best_value) {
320            let _ = writeln!(out, "- best {objective} = {}", round4(best));
321        }
322    }
323    let flags = somatize_core::summary::FlagCount::merge_all(
324        &summary.conclusion.health_flags,
325        &summary.conclusion.audit_flags,
326    );
327    if !flags.is_empty() {
328        out.push_str("\n## Health flags\n\n");
329        for flag in &flags {
330            let _ = writeln!(
331                out,
332                "- {} ×{} at {}",
333                flag.flag,
334                flag.count,
335                flag.nodes.join(", ")
336            );
337        }
338    }
339    if !summary.conclusion.warnings.is_empty() {
340        out.push_str("\n## What could not be read\n\n");
341        for warning in &summary.conclusion.warnings {
342            let _ = writeln!(out, "- {warning}");
343        }
344    }
345
346    let _ = write!(out, "\nrun_dir: {}\n", summary.run_dir);
347    out.push_str(&next(&[
348        &format!("kb_lineage(id=\"{}\")", summary.run_id),
349        &format!(
350            "kb_record_conclusion(run_id=\"{}\", notes=\"...\")",
351            summary.run_id
352        ),
353    ]));
354    out
355}
356
357/// Orientation, with honest coverage: how much of the pool actually
358/// carries the things the other tools depend on.
359pub fn stats(
360    records: &[ExperimentRecord],
361    lines: &[ResearchLine],
362    kb_path: Option<&str>,
363) -> String {
364    let total = records.len();
365    if total == 0 {
366        return format!(
367            "The experiment pool is empty{}.\n\n\
368             Runs are recorded automatically when `graph.track_run(...)` or `study.run(...)` \
369             finishes successfully. If runs exist under `.soma/runs/` but the journal does not, \
370             `soma kb reindex` rebuilds it.\n\n\
371             next: kb_stats()",
372            kb_path.map_or(String::new(), |p| format!(" ({p})"))
373        );
374    }
375    let count = |f: fn(&ExperimentRecord) -> bool| records.iter().filter(|r| f(r)).count();
376    let pct = |n: usize| (n as f64 * 100.0 / total as f64).round() as i64;
377
378    let with_conclusion = count(|r| r.conclusion.as_ref().is_some_and(|c| !c.is_empty()));
379    let with_lineage = count(|r| r.parent.is_some());
380    let with_architecture = count(|r| r.architecture.is_some());
381    let with_run_dir = count(|r| r.run_dir.is_some());
382    let dead_ends = count(is_dead_end);
383    let human_notes = count(|r| r.hypothesis.is_some() || r.notes.is_some());
384
385    let mut out = String::from("# Experiment pool\n\n");
386    if let Some(path) = kb_path {
387        let _ = writeln!(out, "journal: {path}");
388    }
389    let _ = writeln!(out, "experiments: {total}");
390    if let (Some(first), Some(last)) = (
391        records.iter().map(|r| r.timestamp).min(),
392        records.iter().map(|r| r.timestamp).max(),
393    ) {
394        let _ = writeln!(
395            out,
396            "span: {} → {}",
397            first.format("%Y-%m-%d"),
398            last.format("%Y-%m-%d")
399        );
400    }
401
402    out.push_str("\n## Coverage\n\n");
403    for (label, n) in [
404        ("with a conclusion", with_conclusion),
405        ("with a parent (in a lineage)", with_lineage),
406        ("with an architecture fingerprint", with_architecture),
407        ("with a run directory to read", with_run_dir),
408        ("with a human hypothesis or note", human_notes),
409    ] {
410        let _ = writeln!(out, "- {label}: {n}/{total} ({}%)", pct(n));
411    }
412    let _ = writeln!(out, "- dead ends recorded: {dead_ends}");
413    if with_lineage == 0 && total > 1 {
414        out.push_str(
415            "\nNothing in this pool has a parent, so `kb_lineage` and `kb_diff` have \
416             nothing to work with. Runs inherit a parent from `.soma/HEAD`, which advances \
417             after every successful run; `kb_branch_from` rewinds it.\n",
418        );
419    }
420
421    if !lines.is_empty() {
422        out.push_str("\n## Research lines\n\n");
423        for line in lines {
424            let _ = write!(
425                out,
426                "- {} — {} experiment{}, {}",
427                line.name,
428                line.experiments.len(),
429                plural(line.experiments.len()),
430                line.trend
431            );
432            if let (Some(name), Some(value)) = (&line.best_metric_name, line.best_metric_value) {
433                let _ = write!(out, ", best {name}={}", round4(value));
434            }
435            out.push('\n');
436        }
437    }
438
439    out.push_str(&next(&[
440        "kb_find_similar(query=\"...\")",
441        "list_research_lines()",
442    ]));
443    out
444}
445
446/// Confirmation for `kb_record_conclusion`.
447pub fn conclusion_recorded(amendment_id: &str, target: &ExperimentRecord) -> String {
448    format!(
449        "Recorded an amendment to {} — {}.\n\n\
450         amendment id: {amendment_id}\n\n\
451         The journal is append-only: the original record is untouched, and this note is \
452         layered on top of it. It is indexed for retrieval like any other text, so the next \
453         `kb_find_similar` can surface it.\n\n{}",
454        target.id,
455        target.name,
456        next(&[
457            &format!("kb_lineage(id=\"{}\")", target.id),
458            &format!("kb_branch_from(run_id=\"{}\")", target.id),
459        ])
460    )
461}
462
463/// Confirmation for `kb_branch_from`.
464pub fn branched(run_id: &str, record: Option<&ExperimentRecord>) -> String {
465    let mut out = format!("HEAD → {run_id}\n\n");
466    if let Some(record) = record {
467        let _ = writeln!(out, "{} — {}", record.id, record.name);
468        if let Some(conclusion) = &record.conclusion
469            && !conclusion.headline.is_empty()
470        {
471            let _ = writeln!(out, "{}", conclusion.headline);
472        }
473        out.push('\n');
474    }
475    out.push_str(
476        "The next run in this project will record itself as a child of that run, with the \
477         difference between them as the edge. Anything already descended from it stays where \
478         it is — this creates a sibling branch, it does not move history.\n\n",
479    );
480    out.push_str(&next(&[&format!("kb_lineage(id=\"{run_id}\")")]));
481    out
482}
483
484/// The follow-up line every result ends with.
485fn next(calls: &[&str]) -> String {
486    format!("\nnext: {}\n", calls.join(" · "))
487}
488
489fn metrics_line(record: &ExperimentRecord) -> String {
490    let mut names: Vec<&String> = record.metrics.keys().collect();
491    names.sort();
492    let mut rendered: Vec<String> = names
493        .iter()
494        .take(MAX_METRICS)
495        .map(|name| format!("{name}={}", round4(record.metrics[*name])))
496        .collect();
497    if names.len() > MAX_METRICS {
498        rendered.push(format!("+{} more", names.len() - MAX_METRICS));
499    }
500    rendered.join(", ")
501}
502
503fn params_line(record: &ExperimentRecord) -> String {
504    let mut names: Vec<&String> = record.params.keys().collect();
505    names.sort();
506    let mut rendered: Vec<String> = names
507        .iter()
508        .take(MAX_METRICS)
509        .map(|name| {
510            let value = &record.params[*name];
511            let text = match value {
512                serde_json::Value::String(s) => s.clone(),
513                other => other.to_string(),
514            };
515            format!("{name}={}", somatize_core::summary::one_line(&text, 40))
516        })
517        .collect();
518    if names.len() > MAX_METRICS {
519        rendered.push(format!("+{} more", names.len() - MAX_METRICS));
520    }
521    rendered.join(", ")
522}
523
524fn plural(n: usize) -> &'static str {
525    if n == 1 { "" } else { "s" }
526}
527
528// ── Execution ──
529
530/// A truncated preview of an output, for a model that needs the shape.
531fn preview(value: &serde_json::Value) -> String {
532    match value {
533        serde_json::Value::Object(map) if map.contains_key("truncated") => format!(
534            "{} values (showing the first {})",
535            map.get("length").and_then(|v| v.as_u64()).unwrap_or(0),
536            map.get("head")
537                .and_then(|h| h.as_array())
538                .map(|a| a.len())
539                .unwrap_or(0),
540        ),
541        serde_json::Value::Array(items) => {
542            let shown: Vec<String> = items.iter().take(8).map(|v| v.to_string()).collect();
543            if items.len() > shown.len() {
544                format!("[{}, … {} total]", shown.join(", "), items.len())
545            } else {
546                format!("[{}]", shown.join(", "))
547            }
548        }
549        other => {
550            let text = other.to_string();
551            if text.chars().count() > 200 {
552                format!("{}…", text.chars().take(200).collect::<String>())
553            } else {
554                text
555            }
556        }
557    }
558}
559
560/// What the driver reported for a failed run: the error, then whatever
561/// the traceback and the filter's own prints said. A model debugging its
562/// own graph needs the traceback more than it needs a tidy sentence.
563fn render_failure(payload: &serde_json::Value, what: &str) -> String {
564    let mut out = format!(
565        "{what} failed\n\n{}\n",
566        payload
567            .get("error")
568            .and_then(|v| v.as_str())
569            .unwrap_or("the driver reported no reason")
570    );
571    for (label, key) in [
572        ("traceback", "detail"),
573        ("stdout", "stdout"),
574        ("stderr", "stderr"),
575    ] {
576        if let Some(text) = payload.get(key).and_then(|v| v.as_str())
577            && !text.trim().is_empty()
578        {
579            let _ = writeln!(out, "\n{label}:\n{text}");
580        }
581    }
582    let _ = write!(
583        out,
584        "\nnext: read_filter_source(file_path=…) to see the code that failed"
585    );
586    out
587}
588
589/// One pipeline run, as the model reads it.
590pub fn render_pipeline_run(payload: &serde_json::Value) -> String {
591    if payload.get("ok").and_then(|v| v.as_bool()) != Some(true) {
592        return render_failure(payload, "run_pipeline");
593    }
594    let mut out = String::from("run_pipeline: ok\n");
595    if let Some(plan) = payload.get("plan").and_then(|v| v.as_str()) {
596        let _ = writeln!(out, "\nplan:\n{}", plan.trim_end());
597    }
598    if let Some(output) = payload.get("output") {
599        let _ = writeln!(out, "\noutput: {}", preview(output));
600    }
601    if let Some(state) = payload.get("state").and_then(|v| v.as_object())
602        && !state.is_empty()
603    {
604        let names: Vec<&str> = state.keys().take(8).map(|s| s.as_str()).collect();
605        let _ = writeln!(out, "state learned by: {}", names.join(", "));
606    }
607    for (label, key) in [("stdout", "stdout"), ("stderr", "stderr")] {
608        if let Some(text) = payload.get(key).and_then(|v| v.as_str())
609            && !text.trim().is_empty()
610        {
611            let _ = writeln!(out, "\n{label}:\n{text}");
612        }
613    }
614    match payload.get("run_dir").and_then(|v| v.as_str()) {
615        Some(dir) => {
616            let _ = write!(
617                out,
618                "\nrun_dir: {dir}\nnext: kb_summarize_run(run_id=…) for what the \
619                 pool recorded, or kb_find_similar(query=…) for what it resembles"
620            );
621        }
622        None => {
623            let _ = write!(
624                out,
625                "\nrun_dir: none — this run was not tracked, so the pool did not \
626                 record it\nnext: run_pipeline(track=true) to keep the next one"
627            );
628        }
629    }
630    out
631}
632
633/// One study, as the model reads it.
634pub fn render_study_run(payload: &serde_json::Value) -> String {
635    if payload.get("ok").and_then(|v| v.as_bool()) != Some(true) {
636        return render_failure(payload, "run_study");
637    }
638    let mut out = format!(
639        "run_study: {} trials\n",
640        payload
641            .get("n_trials")
642            .and_then(|v| v.as_u64())
643            .unwrap_or(0)
644    );
645    if let Some(objectives) = payload.get("objectives").and_then(|v| v.as_array()) {
646        let pairs: Vec<String> = objectives
647            .iter()
648            .filter_map(|o| o.as_array())
649            .map(|o| {
650                format!(
651                    "{} ({})",
652                    o.first().and_then(|v| v.as_str()).unwrap_or("?"),
653                    o.get(1).and_then(|v| v.as_str()).unwrap_or("?")
654                )
655            })
656            .collect();
657        let _ = writeln!(out, "optimizing: {}", pairs.join(", "));
658    }
659    match payload.get("best_trial") {
660        Some(serde_json::Value::Object(best)) => {
661            out.push_str("\nbest trial:\n");
662            if let Some(params) = best.get("params").and_then(|v| v.as_object()) {
663                for (key, value) in params.iter().take(MAX_METRICS) {
664                    let _ = writeln!(out, "  {key} = {value}");
665                }
666            }
667            if let Some(metrics) = best.get("metrics").and_then(|v| v.as_object()) {
668                for (key, value) in metrics.iter().take(MAX_METRICS) {
669                    let _ = writeln!(out, "  → {key} = {value}");
670                }
671            }
672        }
673        // A study that ran and chose nothing is worth saying out loud:
674        // every trial pruned, or every one failed.
675        _ => out.push_str("\nno best trial: every trial was pruned or errored\n"),
676    }
677    if let Some(text) = payload.get("stderr").and_then(|v| v.as_str())
678        && !text.trim().is_empty()
679    {
680        let _ = writeln!(out, "\nstderr:\n{text}");
681    }
682    let _ = write!(
683        out,
684        "\nrun_dir: {}\nnext: kb_find_similar(query=…) to place this against \
685         earlier work, or run_pipeline(...) with the best params to keep one run",
686        payload
687            .get("run_dir")
688            .and_then(|v| v.as_str())
689            .unwrap_or("none — the study was not tracked")
690    );
691    out
692}
693
694#[cfg(test)]
695mod tests {
696    use super::*;
697    use chrono::{TimeZone, Utc};
698    use somatize_core::summary::{RunConclusion, RunOutcome, TrialSummary};
699    use somatize_memory::knowledge_base::LineageNode;
700    use somatize_memory::{MetricDelta, RetrievalQuery, Trend, rank};
701    use std::collections::BTreeMap;
702    use std::time::Duration;
703
704    fn at(day: u32) -> chrono::DateTime<Utc> {
705        Utc.with_ymd_and_hms(2026, 7, day, 12, 0, 0).unwrap()
706    }
707
708    fn record(id: &str, name: &str) -> ExperimentRecord {
709        let mut r = ExperimentRecord::new(id, name);
710        r.timestamp = at(20);
711        r.run_dir = Some(format!("/proj/.soma/runs/{id}"));
712        r.pipeline_summary = "scaler(Scaler) → model(SVM)".into();
713        r.research_line = Some("mos".into());
714        r.duration = Duration::from_secs(120);
715        r.metrics.insert("val_f1".into(), 0.87);
716        r.conclusion = Some(RunConclusion {
717            headline: "completed in 2m 00s · val_f1=0.87".into(),
718            outcome: Some(RunOutcome::Completed),
719            cache_hit_ratio: Some(0.5),
720            ..RunConclusion::default()
721        });
722        r
723    }
724
725    fn move_from(from: &str, to: &str) -> DerivationMove {
726        DerivationMove {
727            from: from.into(),
728            to: to.into(),
729            changes: vec![Change::ParamChanged {
730                key: "lr".into(),
731                from: serde_json::json!(0.01),
732                to: serde_json::json!(0.05),
733            }],
734            metric_delta: BTreeMap::from([(
735                "val_f1".to_string(),
736                MetricDelta {
737                    before: 0.81,
738                    after: 0.87,
739                    delta: 0.06,
740                },
741            )]),
742            summary: "lr: 0.01 → 0.05 ⇒ val_f1 +0.06".into(),
743        }
744    }
745
746    /// Assertions shared by every rendering: a model must always be
747    /// able to find the next call, and never see an empty result.
748    fn assert_navigable(text: &str) {
749        assert!(!text.trim().is_empty());
750        let last = text.trim_end().lines().last().unwrap();
751        assert!(last.starts_with("next: "), "no follow-up line: {last:?}");
752        assert!(last.contains("("), "follow-ups must be callable: {last:?}");
753    }
754
755    #[test]
756    fn a_hit_shows_its_move_outcome_and_run_dir() {
757        let mut hit = record("run_b", "mos-wider");
758        hit.derivation = Some(move_from("run_a", "run_b"));
759        hit.parent = Some("run_a".into());
760        hit.tags = vec!["mos".into()];
761        hit.params.insert("lr".into(), serde_json::json!(0.05));
762
763        let hits = rank(&[hit], &RetrievalQuery::new("wider", at(21)));
764        let text = find_similar(&hits, "wider");
765
766        assert!(
767            text.starts_with("# 1 experiment matching \"wider\""),
768            "{text}"
769        );
770        assert!(
771            text.contains("move: lr: 0.01 → 0.05 ⇒ val_f1 +0.06"),
772            "{text}"
773        );
774        assert!(text.contains("outcome: completed in 2m 00s · val_f1=0.87"));
775        assert!(text.contains("pipeline: scaler(Scaler) → model(SVM)"));
776        assert!(text.contains("context: line mos · parent run_a · tags mos"));
777        assert!(text.contains("metrics: val_f1=0.87"));
778        assert!(text.contains("params: lr=0.05"));
779        assert!(text.contains("run_dir: /proj/.soma/runs/run_b"));
780        assert!(text.contains("why: score "));
781        assert_navigable(&text);
782    }
783
784    #[test]
785    fn a_missing_conclusion_is_stated_not_hidden() {
786        let mut bare = record("run_x", "unexplained");
787        bare.conclusion = None;
788        bare.run_dir = None;
789        let hits = rank(&[bare], &RetrievalQuery::new("unexplained", at(21)));
790        let text = find_similar(&hits, "unexplained");
791        assert!(text.contains("outcome: no conclusion recorded"), "{text}");
792        assert!(
793            text.contains("run_dir: none (recorded without a run directory)"),
794            "{text}"
795        );
796    }
797
798    #[test]
799    fn a_dead_end_is_flagged_for_the_model() {
800        let mut failed = record("run_f", "collapsed");
801        failed.conclusion = Some(RunConclusion {
802            headline: "failed after 12.0s · error: loss became NaN".into(),
803            outcome: Some(RunOutcome::Failed),
804            ..RunConclusion::default()
805        });
806        let hits = rank(&[failed], &RetrievalQuery::new("collapsed", at(21)));
807        let text = find_similar(&hits, "collapsed");
808        assert!(text.contains("⚠ dead end"), "{text}");
809    }
810
811    #[test]
812    fn no_hits_explains_itself_instead_of_returning_nothing() {
813        let text = find_similar(&[], "nothing like this");
814        assert!(text.contains("No experiments match"));
815        assert!(text.contains("soma kb reindex"), "tells the model the fix");
816        assert_navigable(&text);
817    }
818
819    #[test]
820    fn a_lineage_puts_the_move_on_every_edge() {
821        let root = record("run_a", "baseline");
822        let mut focus = record("run_b", "wider");
823        focus.derivation = Some(move_from("run_a", "run_b"));
824        let mut child = record("run_c", "wider+deeper");
825        child.derivation = Some(DerivationMove {
826            summary: "+depth=4 ⇒ val_f1 −0.02".into(),
827            ..move_from("run_b", "run_c")
828        });
829
830        let text = lineage(&Lineage {
831            focus: focus.clone(),
832            ancestors: vec![root],
833            descendants: vec![LineageNode {
834                record: child,
835                depth: 1,
836            }],
837        });
838
839        assert!(text.contains("· run_a — baseline"), "{text}");
840        assert!(
841            text.contains("▶ run_b — wider  ← lr: 0.01 → 0.05"),
842            "{text}"
843        );
844        assert!(
845            text.contains("· run_c — wider+deeper  ← +depth=4"),
846            "{text}"
847        );
848        assert!(text.contains("1 ancestor, 1 descendant."));
849        // Indentation grows with depth, so the tree reads as a tree.
850        let focus_line = text.lines().find(|l| l.contains("▶")).unwrap();
851        let child_line = text.lines().find(|l| l.contains("run_c")).unwrap();
852        assert!(
853            child_line.len() - child_line.trim_start().len()
854                > focus_line.len() - focus_line.trim_start().len()
855        );
856        assert!(text.contains("kb_diff(a=\"run_a\", b=\"run_b\")"));
857        assert_navigable(&text);
858    }
859
860    #[test]
861    fn a_lone_experiment_is_told_how_to_get_a_lineage() {
862        let text = lineage(&Lineage {
863            focus: record("run_solo", "alone"),
864            ancestors: Vec::new(),
865            descendants: Vec::new(),
866        });
867        assert!(text.contains("0 ancestors, 0 descendants."));
868        assert!(text.contains("stands alone"));
869        assert!(text.contains("kb_branch_from"));
870        assert_navigable(&text);
871    }
872
873    #[test]
874    fn a_diff_reports_cost_as_well_as_metrics() {
875        let mut a = record("run_a", "baseline");
876        a.duration = Duration::from_secs(240);
877        let mut b = record("run_b", "wider");
878        b.duration = Duration::from_secs(120);
879        b.conclusion = Some(RunConclusion {
880            cache_hit_ratio: Some(0.75),
881            ..a.conclusion.clone().unwrap()
882        });
883
884        let text = diff(&a, &b, &move_from("run_a", "run_b"));
885        assert!(text.contains("- lr: 0.01 → 0.05"), "{text}");
886        assert!(text.contains("- val_f1: 0.81 → 0.87 (+0.06)"), "{text}");
887        assert!(
888            text.contains("- duration: 4m 00s → 2m 00s (0.50×)"),
889            "{text}"
890        );
891        assert!(text.contains("- cache hits: 50% → 75%"), "{text}");
892        assert!(text.contains("run_dir (run_a):"));
893        assert!(text.contains("soma does not guess"));
894        assert_navigable(&text);
895    }
896
897    #[test]
898    fn a_diff_says_so_when_it_cannot_describe_the_move() {
899        let a = record("run_a", "baseline");
900        let b = record("run_b", "variant");
901        let unspecified = DerivationMove {
902            from: "run_a".into(),
903            to: "run_b".into(),
904            changes: vec![Change::Unspecified {
905                reason: "no architecture recorded for parent run_a".into(),
906            }],
907            metric_delta: BTreeMap::new(),
908            summary: String::new(),
909        };
910        let text = diff(&a, &b, &unspecified);
911        assert!(text.contains("soma cannot describe this move"), "{text}");
912        assert!(text.contains("no metric appears in both runs"), "{text}");
913    }
914
915    #[test]
916    fn a_run_summary_reports_what_it_could_not_read() {
917        let summary = RunSummary {
918            run_id: "run_old".into(),
919            run_dir: "/proj/.soma/runs/run_old".into(),
920            name: "ancient".into(),
921            kind: "train".into(),
922            created_at: at(1),
923            finished_at: None,
924            duration_ms: Some(65_000),
925            tags: Vec::new(),
926            git: Default::default(),
927            seeds: BTreeMap::new(),
928            params: BTreeMap::new(),
929            hypothesis: None,
930            parent_run_id: None,
931            architecture: None,
932            pipeline_summary: String::new(),
933            metrics: BTreeMap::from([("f1".to_string(), 0.5)]),
934            conclusion: RunConclusion {
935                headline: "completed in 1m 05s · f1=0.5".into(),
936                outcome: Some(RunOutcome::Completed),
937                trials: Some(TrialSummary {
938                    total: 4,
939                    completed: 3,
940                    pruned: 1,
941                    objective: Some("f1".into()),
942                    best_value: Some(0.5),
943                    ..TrialSummary::default()
944                }),
945                warnings: vec!["graph.json is unreadable: unexpected EOF".into()],
946                ..RunConclusion::default()
947            },
948        };
949        let text = summarize_run(&summary);
950        assert!(text.contains("# run_old — ancient"), "{text}");
951        assert!(text.contains("duration: 1m 05s"));
952        assert!(text.contains("- f1: 0.5"));
953        assert!(text.contains("4 total, 3 completed, 1 pruned, 0 failed"));
954        assert!(text.contains("- best f1 = 0.5"));
955        assert!(text.contains("## What could not be read"));
956        assert!(text.contains("graph.json is unreadable"));
957        assert!(text.contains("run_dir: /proj/.soma/runs/run_old"));
958        assert_navigable(&text);
959    }
960
961    #[test]
962    fn stats_report_coverage_honestly() {
963        let mut with_everything = record("run_a", "complete");
964        with_everything.parent = Some("run_0".into());
965        with_everything.hypothesis = Some("wider helps".into());
966        with_everything.architecture = Some(Default::default());
967        let mut bare = ExperimentRecord::new("run_b", "bare");
968        bare.timestamp = at(25);
969
970        let lines = vec![ResearchLine {
971            name: "mos".into(),
972            experiments: vec!["run_a".into()],
973            trend: Trend::Improving,
974            best_metric_value: Some(0.87),
975            best_metric_name: Some("val_f1".into()),
976        }];
977        let text = stats(
978            &[with_everything, bare],
979            &lines,
980            Some("/proj/.soma/experiments.jsonl"),
981        );
982
983        assert!(text.contains("experiments: 2"));
984        assert!(text.contains("span: 2026-07-20 → 2026-07-25"));
985        assert!(text.contains("- with a conclusion: 1/2 (50%)"), "{text}");
986        assert!(text.contains("- with a parent (in a lineage): 1/2 (50%)"));
987        assert!(text.contains("- with an architecture fingerprint: 1/2 (50%)"));
988        assert!(text.contains("mos — 1 experiment, improving, best val_f1=0.87"));
989        assert_navigable(&text);
990    }
991
992    #[test]
993    fn an_empty_pool_says_how_to_fill_it() {
994        let text = stats(&[], &[], Some("/proj/.soma/experiments.jsonl"));
995        assert!(text.contains("empty"));
996        assert!(text.contains("track_run"));
997        assert!(text.contains("soma kb reindex"));
998        assert_navigable(&text);
999    }
1000
1001    #[test]
1002    fn a_pool_with_no_lineage_at_all_is_told_why() {
1003        let text = stats(&[record("a", "one"), record("b", "two")], &[], None);
1004        assert!(text.contains("Nothing in this pool has a parent"), "{text}");
1005        assert!(text.contains(".soma/HEAD"));
1006    }
1007
1008    #[test]
1009    fn confirmations_point_at_the_next_move() {
1010        let target = record("run_a", "baseline");
1011        let text = conclusion_recorded("amend_1", &target);
1012        assert!(text.contains("append-only"));
1013        assert_navigable(&text);
1014
1015        let text = branched("run_a", Some(&target));
1016        assert!(text.starts_with("HEAD → run_a"));
1017        assert!(text.contains("sibling branch, it does not move history"));
1018        assert_navigable(&text);
1019
1020        // Branching to a run the journal has never seen still explains
1021        // itself rather than rendering a blank.
1022        let text = branched("run_unknown", None);
1023        assert!(text.starts_with("HEAD → run_unknown"));
1024        assert_navigable(&text);
1025    }
1026
1027    #[test]
1028    fn long_metric_and_param_lists_are_capped() {
1029        let mut wide = record("run_w", "many");
1030        for i in 0..12 {
1031            wide.metrics.insert(format!("m{i:02}"), i as f64);
1032            wide.params.insert(format!("p{i:02}"), serde_json::json!(i));
1033        }
1034        let hits = rank(&[wide], &RetrievalQuery::new("many", at(21)));
1035        let text = find_similar(&hits, "many");
1036        assert!(text.contains("+7 more"), "metrics capped: {text}");
1037        assert!(text.contains("+6 more"), "params capped: {text}");
1038    }
1039}