1use 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
30const MAX_METRICS: usize = 6;
32
33pub 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
67fn 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
123pub 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
169fn 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
188pub 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
245fn 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
278pub 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
357pub 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
446pub 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
463pub 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
484fn 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
528fn 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
560fn 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
589pub 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
633pub 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 _ => 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 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 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 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}