Skip to main content

somatize_memory/
derivation.rs

1//! Derivation moves — the *edge* of the experiment pool.
2//!
3//! VisTrails' insight about workflow-evolution provenance is that the
4//! interesting object is not the workflow but the **change applied to
5//! its parent**. A tree of records tells you what you ran; a tree of
6//! records plus the move on each edge tells you what you *tried*, and
7//! whether it worked.
8//!
9//! A [`DerivationMove`] is stored as a field of the child record, never
10//! as a separate journal line. One node, one edge, one append: an edge
11//! can never end up orphaned by a crash between two writes, and the
12//! journal's append-only crash safety is not duplicated.
13//!
14//! [`derive`](fn@derive) is a pure function over two records, so the same code
15//! serves both automatic capture and the on-demand `kb_diff` tool.
16//! When the parent carries no architecture — a legacy record, or one
17//! whose run directory is gone — the result is a single
18//! [`Change::Unspecified`]. Saying "something changed, and I cannot
19//! say what" is worth more than inventing a plausible diff.
20
21use crate::record::ExperimentRecord;
22use serde::{Deserialize, Serialize};
23use somatize_core::fingerprint::ArchitectureFingerprint;
24use somatize_core::summary::round4;
25use std::collections::{BTreeMap, BTreeSet};
26
27/// One atomic difference between a parent experiment and its child.
28#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
29#[serde(tag = "change")]
30#[non_exhaustive]
31pub enum Change {
32    /// A node the parent's graph did not have.
33    NodeAdded {
34        /// The new node's id.
35        node: String,
36        /// The filter type behind it.
37        filter: String,
38    },
39    /// A node the child's graph no longer has.
40    NodeRemoved {
41        /// The removed node's id.
42        node: String,
43        /// The filter type it carried.
44        filter: String,
45    },
46    /// Same node id, different filter behind it.
47    NodeReplaced {
48        /// The node id both graphs share.
49        node: String,
50        /// The parent's filter type.
51        from: String,
52        /// The child's filter type.
53        to: String,
54    },
55    /// Same filter, different configuration (the node's config hash
56    /// moved). The *values* are not recoverable from a fingerprint —
57    /// only the fact that they differ.
58    NodeReconfigured {
59        /// The node whose configuration moved.
60        node: String,
61        /// The parent's config hash.
62        from_hash: String,
63        /// The child's config hash.
64        to_hash: String,
65    },
66    /// A data edge only the child's graph has.
67    EdgeAdded {
68        /// The edge's source node.
69        source: String,
70        /// The edge's target node.
71        target: String,
72    },
73    /// A data edge only the parent's graph had.
74    EdgeRemoved {
75        /// The edge's source node.
76        source: String,
77        /// The edge's target node.
78        target: String,
79    },
80    /// The same parameter key, set to a different value.
81    ParamChanged {
82        /// The parameter key.
83        key: String,
84        /// The parent's value.
85        from: serde_json::Value,
86        /// The child's value.
87        to: serde_json::Value,
88    },
89    /// A parameter only the child sets.
90    ParamAdded {
91        /// The parameter key.
92        key: String,
93        /// The value the child sets it to.
94        value: serde_json::Value,
95    },
96    /// A parameter only the parent set.
97    ParamRemoved {
98        /// The parameter key.
99        key: String,
100        /// The value the parent had set.
101        value: serde_json::Value,
102    },
103    /// A study's search space moved (different dimensions searched).
104    SearchSpaceChanged {
105        /// Dimensions only the child searches.
106        added: Vec<String>,
107        /// Dimensions only the parent searched.
108        removed: Vec<String>,
109    },
110    /// The code changed underneath: different git commit.
111    CodeChanged {
112        /// The parent's commit sha, when it recorded one.
113        from_sha: Option<String>,
114        /// The child's commit sha, when it recorded one.
115        to_sha: Option<String>,
116    },
117    /// There was a move, but the evidence to describe it is gone.
118    Unspecified {
119        /// Why the diff could not be computed.
120        reason: String,
121    },
122}
123
124impl Change {
125    /// Short human rendering, used to build a move's summary line.
126    pub fn describe(&self) -> String {
127        match self {
128            Self::NodeAdded { node, filter } => format!("+node {node} ({filter})"),
129            Self::NodeRemoved { node, filter } => format!("-node {node} ({filter})"),
130            Self::NodeReplaced { node, from, to } => format!("{node}: {from} → {to}"),
131            Self::NodeReconfigured { node, .. } => format!("{node} reconfigured"),
132            Self::EdgeAdded { source, target } => format!("+edge {source}→{target}"),
133            Self::EdgeRemoved { source, target } => format!("-edge {source}→{target}"),
134            Self::ParamChanged { key, from, to } => {
135                format!("{key}: {} → {}", compact(from), compact(to))
136            }
137            Self::ParamAdded { key, value } => format!("+{key}={}", compact(value)),
138            Self::ParamRemoved { key, value } => format!("-{key}={}", compact(value)),
139            Self::SearchSpaceChanged { added, removed } => {
140                let mut parts = Vec::new();
141                if !added.is_empty() {
142                    parts.push(format!("+{}", added.join(",")));
143                }
144                if !removed.is_empty() {
145                    parts.push(format!("-{}", removed.join(",")));
146                }
147                format!("search space {}", parts.join(" "))
148            }
149            Self::CodeChanged { from_sha, to_sha } => format!(
150                "code {} → {}",
151                short_sha(from_sha.as_deref()),
152                short_sha(to_sha.as_deref())
153            ),
154            Self::Unspecified { reason } => format!("unspecified ({reason})"),
155        }
156    }
157}
158
159/// How one metric moved between parent and child.
160#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
161pub struct MetricDelta {
162    /// The parent's value.
163    pub before: f64,
164    /// The child's value.
165    pub after: f64,
166    /// `after - before`. Signed on purpose: whether that is good news
167    /// depends on the objective's direction, which the move does not
168    /// presume to know.
169    pub delta: f64,
170}
171
172/// The edge from a parent experiment to this one: what was changed, and
173/// what it did to the numbers.
174#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
175pub struct DerivationMove {
176    /// Parent experiment id.
177    pub from: String,
178    /// Child experiment id (this record).
179    pub to: String,
180    /// The atomic differences, in the deterministic order
181    /// [`derive`](fn@derive) emits them. Never empty from [`derive`](fn@derive):
182    /// when nothing is visible it holds one [`Change::Unspecified`].
183    pub changes: Vec<Change>,
184    /// Per-metric movement, for metrics both runs reported.
185    #[serde(default)]
186    pub metric_delta: BTreeMap<String, MetricDelta>,
187    /// Deterministic one-line rendering of the move.
188    #[serde(default)]
189    pub summary: String,
190}
191
192impl DerivationMove {
193    /// Whether anything at all is known to have changed.
194    pub fn is_empty(&self) -> bool {
195        self.changes.is_empty() && self.metric_delta.is_empty()
196    }
197}
198
199/// Changes named in a summary before it says "+N more".
200const SUMMARY_CHANGES: usize = 4;
201/// Metrics named in a summary before it stops listing them.
202const SUMMARY_METRICS: usize = 3;
203
204/// Diff two experiment records into the move that separates them.
205///
206/// Pure and deterministic: same pair, same move. Used both when a run
207/// finishes (parent resolved from `.soma/HEAD`) and by `kb_diff` on any
208/// two records the user names.
209pub fn derive(parent: &ExperimentRecord, child: &ExperimentRecord) -> DerivationMove {
210    let mut changes = Vec::new();
211
212    match (&parent.architecture, &child.architecture) {
213        (Some(before), Some(after)) => changes.extend(architecture_changes(before, after)),
214        (None, Some(_)) => changes.push(Change::Unspecified {
215            reason: format!("no architecture recorded for parent {}", parent.id),
216        }),
217        (Some(_), None) => changes.push(Change::Unspecified {
218            reason: format!("no architecture recorded for child {}", child.id),
219        }),
220        (None, None) => {}
221    }
222
223    changes.extend(param_changes(&parent.params, &child.params));
224
225    let (before_sha, after_sha) = (
226        parent.git.as_ref().and_then(|g| g.sha.clone()),
227        child.git.as_ref().and_then(|g| g.sha.clone()),
228    );
229    if before_sha != after_sha && (before_sha.is_some() || after_sha.is_some()) {
230        changes.push(Change::CodeChanged {
231            from_sha: before_sha,
232            to_sha: after_sha,
233        });
234    }
235
236    // Nothing observable moved, yet the user branched deliberately —
237    // say so rather than emitting an empty, uninformative edge.
238    if changes.is_empty() {
239        changes.push(Change::Unspecified {
240            reason: "no difference visible in architecture, params or code".into(),
241        });
242    }
243
244    let metric_delta = metric_deltas(parent, child);
245    let mut derivation = DerivationMove {
246        from: parent.id.clone(),
247        to: child.id.clone(),
248        changes,
249        metric_delta,
250        summary: String::new(),
251    };
252    derivation.summary = summarize_move(&derivation);
253    derivation
254}
255
256/// Node- and edge-level differences between two fingerprints.
257fn architecture_changes(
258    before: &ArchitectureFingerprint,
259    after: &ArchitectureFingerprint,
260) -> Vec<Change> {
261    let mut changes = Vec::new();
262    let ids: BTreeSet<&String> = before.nodes.keys().chain(after.nodes.keys()).collect();
263    for id in ids {
264        match (before.nodes.get(id), after.nodes.get(id)) {
265            (None, Some(filter)) => changes.push(Change::NodeAdded {
266                node: id.clone(),
267                filter: filter.clone(),
268            }),
269            (Some(filter), None) => changes.push(Change::NodeRemoved {
270                node: id.clone(),
271                filter: filter.clone(),
272            }),
273            (Some(from), Some(to)) if from != to => changes.push(Change::NodeReplaced {
274                node: id.clone(),
275                from: from.clone(),
276                to: to.clone(),
277            }),
278            (Some(_), Some(_)) => {
279                // Same filter type: did its configuration move? Only
280                // knowable when both runs captured a config hash.
281                if let (Some(from), Some(to)) =
282                    (before.node_config.get(id), after.node_config.get(id))
283                    && from != to
284                {
285                    changes.push(Change::NodeReconfigured {
286                        node: id.clone(),
287                        from_hash: from.clone(),
288                        to_hash: to.clone(),
289                    });
290                }
291            }
292            (None, None) => unreachable!("id came from one of the two maps"),
293        }
294    }
295
296    let before_edges: BTreeSet<_> = before.edges.iter().collect();
297    let after_edges: BTreeSet<_> = after.edges.iter().collect();
298    for edge in after_edges.difference(&before_edges) {
299        changes.push(Change::EdgeAdded {
300            source: edge.source.clone(),
301            target: edge.target.clone(),
302        });
303    }
304    for edge in before_edges.difference(&after_edges) {
305        changes.push(Change::EdgeRemoved {
306            source: edge.source.clone(),
307            target: edge.target.clone(),
308        });
309    }
310    changes
311}
312
313fn param_changes(
314    before: &std::collections::BTreeMap<String, serde_json::Value>,
315    after: &std::collections::BTreeMap<String, serde_json::Value>,
316) -> Vec<Change> {
317    let keys: BTreeSet<&String> = before.keys().chain(after.keys()).collect();
318    keys.into_iter()
319        .filter_map(|key| match (before.get(key), after.get(key)) {
320            (Some(from), Some(to)) if from != to => Some(Change::ParamChanged {
321                key: key.clone(),
322                from: from.clone(),
323                to: to.clone(),
324            }),
325            (None, Some(value)) => Some(Change::ParamAdded {
326                key: key.clone(),
327                value: value.clone(),
328            }),
329            (Some(value), None) => Some(Change::ParamRemoved {
330                key: key.clone(),
331                value: value.clone(),
332            }),
333            _ => None,
334        })
335        .collect()
336}
337
338/// Movement of every metric both records report.
339fn metric_deltas(
340    parent: &ExperimentRecord,
341    child: &ExperimentRecord,
342) -> BTreeMap<String, MetricDelta> {
343    parent
344        .metrics
345        .iter()
346        .filter_map(|(name, before)| {
347            let after = *child.metrics.get(name)?;
348            Some((
349                name.clone(),
350                MetricDelta {
351                    before: *before,
352                    after,
353                    delta: after - before,
354                },
355            ))
356        })
357        .collect()
358}
359
360/// One deterministic line: what changed, then what it did.
361fn summarize_move(derivation: &DerivationMove) -> String {
362    let mut described: Vec<String> = derivation
363        .changes
364        .iter()
365        .take(SUMMARY_CHANGES)
366        .map(Change::describe)
367        .collect();
368    if derivation.changes.len() > SUMMARY_CHANGES {
369        described.push(format!(
370            "+{} more",
371            derivation.changes.len() - SUMMARY_CHANGES
372        ));
373    }
374    let mut summary = described.join("; ");
375
376    if !derivation.metric_delta.is_empty() {
377        let moved: Vec<String> = derivation
378            .metric_delta
379            .iter()
380            .take(SUMMARY_METRICS)
381            .map(|(name, d)| format!("{name} {}{}", sign(d.delta), round4(d.delta.abs())))
382            .collect();
383        summary.push_str(&format!(" ⇒ {}", moved.join(", ")));
384    }
385    summary
386}
387
388fn sign(delta: f64) -> &'static str {
389    if delta > 0.0 {
390        "+"
391    } else if delta < 0.0 {
392        "−"
393    } else {
394        "±"
395    }
396}
397
398fn short_sha(sha: Option<&str>) -> String {
399    match sha {
400        Some(sha) => sha.chars().take(8).collect(),
401        None => "?".to_string(),
402    }
403}
404
405/// Compact JSON rendering for a parameter value inside a one-liner.
406fn compact(value: &serde_json::Value) -> String {
407    match value {
408        serde_json::Value::String(s) => s.clone(),
409        serde_json::Value::Number(n) => n.to_string(),
410        other => {
411            let text = other.to_string();
412            somatize_core::summary::one_line(&text, 40)
413        }
414    }
415}
416
417#[cfg(test)]
418mod tests {
419    use super::*;
420    use somatize_core::fingerprint::EdgeRef;
421    use somatize_core::graph::{Node, linear_pipeline};
422    use somatize_core::tracking::GitInfo;
423
424    fn fingerprint(nodes: &[(&str, &str)], edges: &[(&str, &str)]) -> ArchitectureFingerprint {
425        ArchitectureFingerprint {
426            digest: format!("{nodes:?}{edges:?}"),
427            nodes: nodes
428                .iter()
429                .map(|(id, filter)| ((*id).to_string(), format!("filter:{filter}")))
430                .collect(),
431            edges: edges
432                .iter()
433                .map(|(s, t)| EdgeRef {
434                    source: (*s).to_string(),
435                    target: (*t).to_string(),
436                    kind: "data".into(),
437                })
438                .collect(),
439            n_nodes: nodes.len(),
440            n_edges: edges.len(),
441            node_config: BTreeMap::new(),
442        }
443    }
444
445    fn record(id: &str) -> ExperimentRecord {
446        ExperimentRecord::new(id, format!("{id}-run"))
447    }
448
449    #[test]
450    fn a_swapped_filter_reads_as_a_replacement() {
451        let mut parent = record("p");
452        parent.architecture = Some(fingerprint(&[("a", "Scaler"), ("b", "SVM")], &[("a", "b")]));
453        let mut child = record("c");
454        child.architecture = Some(fingerprint(
455            &[("a", "Scaler"), ("b", "RandomForest")],
456            &[("a", "b")],
457        ));
458
459        let move_ = derive(&parent, &child);
460        assert_eq!(move_.from, "p");
461        assert_eq!(move_.to, "c");
462        assert_eq!(
463            move_.changes,
464            vec![Change::NodeReplaced {
465                node: "b".into(),
466                from: "filter:SVM".into(),
467                to: "filter:RandomForest".into(),
468            }]
469        );
470        assert_eq!(move_.summary, "b: filter:SVM → filter:RandomForest");
471    }
472
473    #[test]
474    fn added_and_removed_nodes_and_edges() {
475        let mut parent = record("p");
476        parent.architecture = Some(fingerprint(&[("a", "A"), ("b", "B")], &[("a", "b")]));
477        let mut child = record("c");
478        child.architecture = Some(fingerprint(
479            &[("a", "A"), ("c", "C")],
480            &[("a", "c"), ("c", "a")],
481        ));
482
483        let changes = derive(&parent, &child).changes;
484        assert!(changes.contains(&Change::NodeAdded {
485            node: "c".into(),
486            filter: "filter:C".into()
487        }));
488        assert!(changes.contains(&Change::NodeRemoved {
489            node: "b".into(),
490            filter: "filter:B".into()
491        }));
492        assert!(changes.contains(&Change::EdgeAdded {
493            source: "a".into(),
494            target: "c".into()
495        }));
496        assert!(changes.contains(&Change::EdgeRemoved {
497            source: "a".into(),
498            target: "b".into()
499        }));
500    }
501
502    #[test]
503    fn a_reconfigured_node_needs_both_config_hashes() {
504        let mut before = fingerprint(&[("model", "SVM")], &[]);
505        let mut after = before.clone();
506
507        // Without hashes there is nothing to compare — no change claimed.
508        let mut parent = record("p");
509        parent.architecture = Some(before.clone());
510        let mut child = record("c");
511        child.architecture = Some(after.clone());
512        assert!(matches!(
513            derive(&parent, &child).changes.as_slice(),
514            [Change::Unspecified { .. }]
515        ));
516
517        before.node_config = BTreeMap::from([("model".into(), "aaa".into())]);
518        after.node_config = BTreeMap::from([("model".into(), "bbb".into())]);
519        parent.architecture = Some(before);
520        child.architecture = Some(after);
521        assert_eq!(
522            derive(&parent, &child).changes,
523            vec![Change::NodeReconfigured {
524                node: "model".into(),
525                from_hash: "aaa".into(),
526                to_hash: "bbb".into(),
527            }]
528        );
529    }
530
531    #[test]
532    fn params_produce_signed_metric_deltas() {
533        let mut parent = record("p");
534        parent.params.insert("lr".into(), serde_json::json!(0.01));
535        parent
536            .params
537            .insert("dropout".into(), serde_json::json!(0.1));
538        parent.metrics.insert("val_f1".into(), 0.80);
539        parent.metrics.insert("loss".into(), 0.50);
540        parent.metrics.insert("only_parent".into(), 1.0);
541
542        let mut child = record("c");
543        child.params.insert("lr".into(), serde_json::json!(0.05));
544        child.params.insert("seed".into(), serde_json::json!(7));
545        child.metrics.insert("val_f1".into(), 0.87);
546        child.metrics.insert("loss".into(), 0.42);
547
548        let move_ = derive(&parent, &child);
549        assert_eq!(
550            move_.changes,
551            vec![
552                Change::ParamRemoved {
553                    key: "dropout".into(),
554                    value: serde_json::json!(0.1)
555                },
556                Change::ParamChanged {
557                    key: "lr".into(),
558                    from: serde_json::json!(0.01),
559                    to: serde_json::json!(0.05)
560                },
561                Change::ParamAdded {
562                    key: "seed".into(),
563                    value: serde_json::json!(7)
564                },
565            ]
566        );
567        // Only metrics both runs reported are comparable.
568        assert_eq!(move_.metric_delta.len(), 2);
569        let f1 = move_.metric_delta["val_f1"];
570        assert_eq!((f1.before, f1.after), (0.80, 0.87));
571        assert!((f1.delta - 0.07).abs() < 1e-9);
572        assert!(move_.metric_delta["loss"].delta < 0.0);
573        assert!(move_.summary.contains("val_f1 +0.07"), "{}", move_.summary);
574        assert!(move_.summary.contains("loss −0.08"), "{}", move_.summary);
575    }
576
577    #[test]
578    fn a_missing_parent_architecture_is_stated_not_invented() {
579        let parent = record("p");
580        let mut child = record("c");
581        child.architecture = Some(fingerprint(&[("a", "A")], &[]));
582
583        let move_ = derive(&parent, &child);
584        assert_eq!(
585            move_.changes,
586            vec![Change::Unspecified {
587                reason: "no architecture recorded for parent p".into()
588            }]
589        );
590        assert!(
591            move_.summary.starts_with("unspecified ("),
592            "{}",
593            move_.summary
594        );
595    }
596
597    #[test]
598    fn an_identical_pair_still_records_an_honest_edge() {
599        let parent = record("p");
600        let child = record("c");
601        let move_ = derive(&parent, &child);
602        assert!(
603            !move_.is_empty(),
604            "a branch is a fact even when nothing moved"
605        );
606        assert_eq!(
607            move_.changes,
608            vec![Change::Unspecified {
609                reason: "no difference visible in architecture, params or code".into()
610            }]
611        );
612    }
613
614    #[test]
615    fn a_code_change_is_a_change() {
616        let mut parent = record("p");
617        parent.git = Some(GitInfo {
618            sha: Some("abcdef1234567890".into()),
619            branch: Some("main".into()),
620            dirty: Some(false),
621        });
622        let mut child = record("c");
623        child.git = Some(GitInfo {
624            sha: Some("0987654321fedcba".into()),
625            ..GitInfo::default()
626        });
627        let move_ = derive(&parent, &child);
628        assert_eq!(
629            move_.changes,
630            vec![Change::CodeChanged {
631                from_sha: Some("abcdef1234567890".into()),
632                to_sha: Some("0987654321fedcba".into()),
633            }]
634        );
635        assert_eq!(move_.summary, "code abcdef12 → 09876543");
636    }
637
638    #[test]
639    fn the_summary_caps_long_move_lists() {
640        let mut parent = record("p");
641        let mut child = record("c");
642        for i in 0..10 {
643            parent.params.insert(format!("p{i}"), serde_json::json!(i));
644            child
645                .params
646                .insert(format!("p{i}"), serde_json::json!(i + 1));
647        }
648        let move_ = derive(&parent, &child);
649        assert_eq!(move_.changes.len(), 10);
650        assert!(move_.summary.contains("+6 more"), "{}", move_.summary);
651    }
652
653    #[test]
654    fn derive_is_deterministic_and_roundtrips() {
655        let mut parent = record("p");
656        parent.architecture = Some(
657            ArchitectureFingerprint::of(&linear_pipeline(vec![
658                Node::new("a", "Scaler", "StandardScaler"),
659                Node::new("b", "Model", "SVM"),
660            ]))
661            .unwrap(),
662        );
663        parent.metrics.insert("f1".into(), 0.5);
664        let mut child = record("c");
665        child.architecture = Some(
666            ArchitectureFingerprint::of(&linear_pipeline(vec![
667                Node::new("a", "Scaler", "StandardScaler"),
668                Node::new("b", "Model", "RandomForest"),
669            ]))
670            .unwrap(),
671        );
672        child.metrics.insert("f1".into(), 0.6);
673
674        let first = derive(&parent, &child);
675        for _ in 0..5 {
676            assert_eq!(derive(&parent, &child), first);
677        }
678        let json = serde_json::to_string(&first).unwrap();
679        let back: DerivationMove = serde_json::from_str(&json).unwrap();
680        assert_eq!(back, first);
681        // Tagged representation keeps the variant name in the JSON.
682        assert!(json.contains("\"change\":\"NodeReplaced\""), "{json}");
683    }
684}