1use crate::record::ExperimentRecord;
22use serde::{Deserialize, Serialize};
23use somatize_core::fingerprint::ArchitectureFingerprint;
24use somatize_core::summary::round4;
25use std::collections::{BTreeMap, BTreeSet};
26
27#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
29#[serde(tag = "change")]
30#[non_exhaustive]
31pub enum Change {
32 NodeAdded {
34 node: String,
36 filter: String,
38 },
39 NodeRemoved {
41 node: String,
43 filter: String,
45 },
46 NodeReplaced {
48 node: String,
50 from: String,
52 to: String,
54 },
55 NodeReconfigured {
59 node: String,
61 from_hash: String,
63 to_hash: String,
65 },
66 EdgeAdded {
68 source: String,
70 target: String,
72 },
73 EdgeRemoved {
75 source: String,
77 target: String,
79 },
80 ParamChanged {
82 key: String,
84 from: serde_json::Value,
86 to: serde_json::Value,
88 },
89 ParamAdded {
91 key: String,
93 value: serde_json::Value,
95 },
96 ParamRemoved {
98 key: String,
100 value: serde_json::Value,
102 },
103 SearchSpaceChanged {
105 added: Vec<String>,
107 removed: Vec<String>,
109 },
110 CodeChanged {
112 from_sha: Option<String>,
114 to_sha: Option<String>,
116 },
117 Unspecified {
119 reason: String,
121 },
122}
123
124impl Change {
125 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#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
161pub struct MetricDelta {
162 pub before: f64,
164 pub after: f64,
166 pub delta: f64,
170}
171
172#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
175pub struct DerivationMove {
176 pub from: String,
178 pub to: String,
180 pub changes: Vec<Change>,
184 #[serde(default)]
186 pub metric_delta: BTreeMap<String, MetricDelta>,
187 #[serde(default)]
189 pub summary: String,
190}
191
192impl DerivationMove {
193 pub fn is_empty(&self) -> bool {
195 self.changes.is_empty() && self.metric_delta.is_empty()
196 }
197}
198
199const SUMMARY_CHANGES: usize = 4;
201const SUMMARY_METRICS: usize = 3;
203
204pub 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 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
256fn 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 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
338fn 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
360fn 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
405fn 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 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 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 assert!(json.contains("\"change\":\"NodeReplaced\""), "{json}");
683 }
684}