Skip to main content

somatize_core/
fingerprint.rs

1//! Architecture fingerprints — a stable identity for a graph's *shape*.
2//!
3//! Two questions the experiment pool has to answer are different enough
4//! to need two answers:
5//!
6//! - *"Is this the exact same architecture I already ran?"* →
7//!   [`ArchitectureFingerprint::digest`], an exact hash over node ids,
8//!   node kinds and edges. Sensitive to renaming, which is what makes it
9//!   usable as a dedup key.
10//! - *"What have I run that looks like this?"* →
11//!   [`ArchitectureFingerprint::node_tokens`] / [`edge_tokens`], bags of
12//!   *type* tokens that carry no node ids at all, compared with
13//!   [`structural_similarity`]. Renaming `scaler` to `norm` leaves them
14//!   untouched.
15//!
16//! Both are derived from the same canonical form: nodes sorted by id,
17//! edges sorted, with `edge.id`, `node.label` and `node.target`
18//! excluded — they are cosmetic or deployment detail, not architecture.
19//! `SubGraph` nodes recurse *by digest*, so nesting terminates and the
20//! result stays independent of traversal order.
21//!
22//! [`edge_tokens`]: ArchitectureFingerprint::edge_tokens
23
24use crate::canon::hash_canonical;
25use crate::error::Result;
26use crate::graph::{EdgeKind, Graph, NodeKind};
27use serde::{Deserialize, Serialize};
28use std::collections::BTreeMap;
29
30/// Structural identity of a graph, exact and fuzzy.
31///
32/// Written to `fingerprint.json` in every tracked run directory and
33/// copied into the `ExperimentRecord` so the pool can rank past work by
34/// architectural resemblance without re-reading any graph.
35#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
36pub struct ArchitectureFingerprint {
37    /// Hex SHA-256 of the canonical form — exact, id-sensitive.
38    pub digest: String,
39    /// Node id → type token, in id order. Keeping the ids is what lets
40    /// two fingerprints be *diffed* (which node was swapped) and not
41    /// only compared; the token bags below deliberately drop them.
42    #[serde(default)]
43    pub nodes: BTreeMap<String, String>,
44    /// Every edge as `(source id, target id, kind)`, sorted.
45    #[serde(default)]
46    pub edges: Vec<EdgeRef>,
47    /// Node count — cheap size signal for ranking without opening `nodes`.
48    pub n_nodes: usize,
49    /// Edge count — same role as `n_nodes`.
50    pub n_edges: usize,
51    /// Per-node filter config hash, keyed by node id. Empty unless the
52    /// caller had a filter registry to hand (soma-core has no access to
53    /// filter instances; the Python binding fills this in at run start).
54    #[serde(default)]
55    pub node_config: BTreeMap<String, String>,
56}
57
58/// One edge, by node id — the diffable form.
59#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
60pub struct EdgeRef {
61    /// Source node id.
62    pub source: String,
63    /// Target node id.
64    pub target: String,
65    /// `data` or `control`.
66    pub kind: String,
67}
68
69impl ArchitectureFingerprint {
70    /// Fingerprint `graph`. Errors only if the canonical form is not
71    /// serializable, which for a `Graph` means a bug, not bad input.
72    pub fn of(graph: &Graph) -> Result<Self> {
73        let canonical = canonical_form(graph)?;
74        let digest = hash_canonical(&canonical)?.to_hex();
75        Ok(Self {
76            digest,
77            n_nodes: canonical.nodes.len(),
78            n_edges: canonical.edges.len(),
79            nodes: canonical.nodes.iter().cloned().collect(),
80            edges: canonical
81                .edges
82                .iter()
83                .map(|(source, target, kind)| EdgeRef {
84                    source: source.clone(),
85                    target: target.clone(),
86                    kind: kind.clone(),
87                })
88                .collect(),
89            node_config: BTreeMap::new(),
90        })
91    }
92
93    /// Attach per-node config hashes (node id → hex hash).
94    pub fn with_node_config(mut self, node_config: BTreeMap<String, String>) -> Self {
95        self.node_config = node_config;
96        self
97    }
98
99    /// Short prefix of the digest, for display.
100    pub fn short(&self) -> &str {
101        let end = self.digest.len().min(12);
102        &self.digest[..end]
103    }
104
105    /// One type token per node, sorted, duplicates kept (two scalers are
106    /// structurally different from one). Carries no node ids, so it
107    /// survives renaming — the fuzzy half of the fingerprint.
108    pub fn node_tokens(&self) -> Vec<String> {
109        let mut tokens: Vec<String> = self.nodes.values().cloned().collect();
110        tokens.sort();
111        tokens
112    }
113
114    /// One `sourceToken>targetToken` token per edge (`~>` for control
115    /// edges), sorted, duplicates kept. Also id-free.
116    pub fn edge_tokens(&self) -> Vec<String> {
117        let missing = "missing".to_string();
118        let mut tokens: Vec<String> = self
119            .edges
120            .iter()
121            .map(|edge| {
122                let arrow = if edge.kind == "control" { "~>" } else { ">" };
123                let source = self.nodes.get(&edge.source).unwrap_or(&missing);
124                let target = self.nodes.get(&edge.target).unwrap_or(&missing);
125                format!("{source}{arrow}{target}")
126            })
127            .collect();
128        tokens.sort();
129        tokens
130    }
131}
132
133/// Structural resemblance of two fingerprints in `[0, 1]`.
134///
135/// `0.6 · jaccard(node tokens) + 0.4 · jaccard(edge tokens)`, where
136/// jaccard is the *multiset* variant (intersection sums per-token
137/// minima, union sums maxima) so node counts matter. Deterministic and
138/// linear — deliberately not graph isomorphism, which is both expensive
139/// and too strict for "these two look alike".
140///
141/// Two empty fingerprints score `1.0` (identical), an empty against a
142/// non-empty scores `0.0`.
143pub fn structural_similarity(a: &ArchitectureFingerprint, b: &ArchitectureFingerprint) -> f64 {
144    0.6 * multiset_jaccard(&a.node_tokens(), &b.node_tokens())
145        + 0.4 * multiset_jaccard(&a.edge_tokens(), &b.edge_tokens())
146}
147
148/// One-line human description of a graph's topology, for the
149/// `pipeline_summary` field of an experiment record.
150///
151/// Linear chains render as `a → b → c`; anything with fan-out renders
152/// as the node list plus an edge count. Truncated so a summary never
153/// dominates a search result.
154pub fn pipeline_summary(graph: &Graph) -> String {
155    const MAX_NODES: usize = 8;
156
157    if graph.nodes.is_empty() {
158        return "empty graph".to_string();
159    }
160    let sorted = graph.topological_sort().unwrap_or_default();
161    let order: Vec<&str> = if sorted.len() == graph.nodes.len() {
162        sorted
163    } else {
164        // Cyclic or malformed: fall back to declaration order.
165        graph.nodes.iter().map(|n| n.id.as_str()).collect()
166    };
167    let described: Vec<String> = order
168        .iter()
169        .take(MAX_NODES)
170        .map(|id| match graph.node(id).map(|n| &n.kind) {
171            Some(NodeKind::Filter { filter_name }) if filter_name != id => {
172                format!("{id}({filter_name})")
173            }
174            Some(NodeKind::SubGraph { graph }) => format!("{id}[{} nodes]", graph.nodes.len()),
175            Some(NodeKind::Loop { .. }) => format!("{id}[loop]"),
176            Some(NodeKind::Branch { .. }) => format!("{id}[branch]"),
177            Some(NodeKind::Step { step_name }) => format!("{id}[step:{step_name}]"),
178            _ => (*id).to_string(),
179        })
180        .collect();
181    let mut summary = described.join(" → ");
182    if order.len() > MAX_NODES {
183        summary.push_str(&format!(" → … (+{} more)", order.len() - MAX_NODES));
184    }
185    let is_chain = graph.edges.len() + 1 == graph.nodes.len()
186        && graph
187            .nodes
188            .iter()
189            .all(|n| graph.predecessors(&n.id).len() <= 1 && graph.successors(&n.id).len() <= 1);
190    if is_chain {
191        summary
192    } else {
193        format!(
194            "{summary} ({} nodes, {} edges)",
195            graph.nodes.len(),
196            graph.edges.len()
197        )
198    }
199}
200
201/// The canonical, cosmetics-free view a digest is taken over.
202#[derive(Debug, Serialize)]
203struct CanonicalGraph {
204    /// `(node id, type token)`, sorted by id.
205    nodes: Vec<(String, String)>,
206    /// `(source, target, kind)`, sorted.
207    edges: Vec<(String, String, String)>,
208}
209
210fn canonical_form(graph: &Graph) -> Result<CanonicalGraph> {
211    let mut nodes: Vec<(String, String)> = graph
212        .nodes
213        .iter()
214        .map(|node| Ok((node.id.clone(), kind_token(&node.kind)?)))
215        .collect::<Result<_>>()?;
216    nodes.sort();
217    let mut edges: Vec<(String, String, String)> = graph
218        .edges
219        .iter()
220        .map(|edge| {
221            let kind = match edge.kind {
222                EdgeKind::Data => "data",
223                EdgeKind::Control => "control",
224            };
225            (edge.source.clone(), edge.target.clone(), kind.to_string())
226        })
227        .collect();
228    edges.sort();
229    Ok(CanonicalGraph { nodes, edges })
230}
231
232/// Type token for a node kind — no ids, no labels, no targets.
233///
234/// A sub-graph collapses to its own digest, which keeps recursion
235/// bounded by nesting depth and independent of the order the inner
236/// nodes were declared in.
237fn kind_token(kind: &NodeKind) -> Result<String> {
238    Ok(match kind {
239        NodeKind::Filter { filter_name } => format!("filter:{filter_name}"),
240        NodeKind::SubGraph { graph } => {
241            let inner = ArchitectureFingerprint::of(graph)?;
242            format!("subgraph:{}", inner.short())
243        }
244        NodeKind::Loop { max_iterations, .. } => match max_iterations {
245            Some(n) => format!("loop:{n}"),
246            None => "loop:*".to_string(),
247        },
248        NodeKind::Branch { .. } => "branch".to_string(),
249        NodeKind::Step { step_name } => format!("step:{step_name}"),
250    })
251}
252
253/// Jaccard over multisets: `Σ min(count) / Σ max(count)`.
254fn multiset_jaccard(a: &[String], b: &[String]) -> f64 {
255    if a.is_empty() && b.is_empty() {
256        return 1.0;
257    }
258    let mut counts: BTreeMap<&str, (usize, usize)> = BTreeMap::new();
259    for token in a {
260        counts.entry(token).or_default().0 += 1;
261    }
262    for token in b {
263        counts.entry(token).or_default().1 += 1;
264    }
265    let (mut intersection, mut union) = (0usize, 0usize);
266    for (left, right) in counts.values() {
267        intersection += left.min(right);
268        union += left.max(right);
269    }
270    if union == 0 {
271        return 1.0;
272    }
273    intersection as f64 / union as f64
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279    use crate::graph::{Edge, Node, linear_pipeline};
280
281    fn chain() -> Graph {
282        linear_pipeline(vec![
283            Node::new("a", "Scaler", "StandardScaler"),
284            Node::new("b", "Reducer", "PCA"),
285            Node::new("c", "Model", "SVM"),
286        ])
287    }
288
289    #[test]
290    fn digest_is_deterministic_across_declaration_order() {
291        let forward = chain();
292        let mut shuffled = Graph::new();
293        for node in forward.nodes.iter().rev() {
294            shuffled.add_node(node.clone());
295        }
296        for edge in forward.edges.iter().rev() {
297            shuffled.add_edge(edge.clone());
298        }
299        let a = ArchitectureFingerprint::of(&forward).unwrap();
300        let b = ArchitectureFingerprint::of(&shuffled).unwrap();
301        assert_eq!(a.digest, b.digest);
302        assert_eq!(a.nodes, b.nodes);
303        assert_eq!(a.edges, b.edges);
304        assert_eq!(a.node_tokens(), b.node_tokens());
305        assert_eq!(a.edge_tokens(), b.edge_tokens());
306    }
307
308    #[test]
309    fn digest_ignores_cosmetics_but_not_structure() {
310        let base = ArchitectureFingerprint::of(&chain()).unwrap();
311
312        // Labels and targets are cosmetic / deployment detail.
313        let mut cosmetic = chain();
314        cosmetic.nodes[0].label = "renamed for the paper".into();
315        cosmetic.nodes[1].target = Some("gpu".into());
316        cosmetic.edges[0].id = "totally-different-edge-id".into();
317        cosmetic.edges[0].label = Some("x".into());
318        assert_eq!(
319            base.digest,
320            ArchitectureFingerprint::of(&cosmetic).unwrap().digest
321        );
322
323        // The filter behind a node is not.
324        let mut swapped = chain();
325        swapped.nodes[2].kind = NodeKind::Filter {
326            filter_name: "RandomForest".into(),
327        };
328        assert_ne!(
329            base.digest,
330            ArchitectureFingerprint::of(&swapped).unwrap().digest
331        );
332
333        // Neither is an extra edge.
334        let mut extra = chain();
335        extra.add_edge(Edge::data("skip", "a", "c"));
336        assert_ne!(
337            base.digest,
338            ArchitectureFingerprint::of(&extra).unwrap().digest
339        );
340    }
341
342    #[test]
343    fn digest_is_id_sensitive_but_tokens_are_not() {
344        let base = ArchitectureFingerprint::of(&chain()).unwrap();
345        let renamed = linear_pipeline(vec![
346            Node::new("first", "Scaler", "StandardScaler"),
347            Node::new("second", "Reducer", "PCA"),
348            Node::new("third", "Model", "SVM"),
349        ]);
350        let renamed = ArchitectureFingerprint::of(&renamed).unwrap();
351        assert_ne!(base.digest, renamed.digest, "digest seeds exact dedup");
352        assert_eq!(base.node_tokens(), renamed.node_tokens());
353        assert_eq!(base.edge_tokens(), renamed.edge_tokens());
354        assert_eq!(structural_similarity(&base, &renamed), 1.0);
355    }
356
357    #[test]
358    fn subgraph_recursion_is_by_digest_and_order_independent() {
359        let inner_a = chain();
360        let mut inner_b = Graph::new();
361        for node in inner_a.nodes.iter().rev() {
362            inner_b.add_node(node.clone());
363        }
364        for edge in inner_a.edges.iter().rev() {
365            inner_b.add_edge(edge.clone());
366        }
367        let mut outer_a = Graph::new();
368        outer_a.add_node(Node::subgraph("stage", inner_a));
369        let mut outer_b = Graph::new();
370        outer_b.add_node(Node::subgraph("stage", inner_b));
371        assert_eq!(
372            ArchitectureFingerprint::of(&outer_a).unwrap().digest,
373            ArchitectureFingerprint::of(&outer_b).unwrap().digest
374        );
375
376        // A different inner graph changes the outer digest.
377        let mut inner_c = chain();
378        inner_c.add_node(Node::filter("Calibrator"));
379        let mut outer_c = Graph::new();
380        outer_c.add_node(Node::subgraph("stage", inner_c));
381        assert_ne!(
382            ArchitectureFingerprint::of(&outer_a).unwrap().digest,
383            ArchitectureFingerprint::of(&outer_c).unwrap().digest
384        );
385    }
386
387    #[test]
388    fn similarity_is_bounded_symmetric_and_ordered() {
389        let base = ArchitectureFingerprint::of(&chain()).unwrap();
390
391        let mut one_swap = chain();
392        one_swap.nodes[2].kind = NodeKind::Filter {
393            filter_name: "RandomForest".into(),
394        };
395        let one_swap = ArchitectureFingerprint::of(&one_swap).unwrap();
396
397        let unrelated = ArchitectureFingerprint::of(&linear_pipeline(vec![
398            Node::filter("Tokenizer"),
399            Node::filter("Transformer"),
400        ]))
401        .unwrap();
402
403        for (a, b) in [
404            (&base, &base),
405            (&base, &one_swap),
406            (&base, &unrelated),
407            (&one_swap, &unrelated),
408        ] {
409            let s = structural_similarity(a, b);
410            assert!((0.0..=1.0).contains(&s), "out of bounds: {s}");
411            assert_eq!(s, structural_similarity(b, a), "not symmetric");
412        }
413        assert_eq!(structural_similarity(&base, &base), 1.0);
414        assert!(structural_similarity(&base, &one_swap) > structural_similarity(&base, &unrelated));
415        assert_eq!(structural_similarity(&base, &unrelated), 0.0);
416    }
417
418    #[test]
419    fn similarity_counts_duplicates() {
420        let one =
421            ArchitectureFingerprint::of(&linear_pipeline(vec![Node::filter("Dense")])).unwrap();
422        let three = ArchitectureFingerprint::of(&linear_pipeline(vec![
423            Node::filter_with_id("d1", "Dense"),
424            Node::filter_with_id("d2", "Dense"),
425            Node::filter_with_id("d3", "Dense"),
426        ]))
427        .unwrap();
428        let s = structural_similarity(&one, &three);
429        assert!(
430            s > 0.0 && s < 1.0,
431            "stacking layers must move the score: {s}"
432        );
433    }
434
435    #[test]
436    fn empty_graphs_are_identical_to_each_other() {
437        let empty = ArchitectureFingerprint::of(&Graph::new()).unwrap();
438        assert_eq!(empty.n_nodes, 0);
439        assert_eq!(structural_similarity(&empty, &empty), 1.0);
440        let non_empty = ArchitectureFingerprint::of(&chain()).unwrap();
441        assert_eq!(structural_similarity(&empty, &non_empty), 0.0);
442    }
443
444    #[test]
445    fn control_edges_are_distinct_from_data_edges() {
446        let mut data = Graph::new();
447        data.add_node(Node::filter("A"));
448        data.add_node(Node::filter("B"));
449        data.add_edge(Edge::data("e", "A", "B"));
450        let mut control = Graph::new();
451        control.add_node(Node::filter("A"));
452        control.add_node(Node::filter("B"));
453        control.add_edge(Edge::control("e", "A", "B"));
454        let data = ArchitectureFingerprint::of(&data).unwrap();
455        let control = ArchitectureFingerprint::of(&control).unwrap();
456        assert_ne!(data.digest, control.digest);
457        assert_ne!(data.edge_tokens(), control.edge_tokens());
458        assert_eq!(data.edge_tokens(), vec!["filter:A>filter:B"]);
459        assert_eq!(control.edge_tokens(), vec!["filter:A~>filter:B"]);
460    }
461
462    #[test]
463    fn fingerprint_roundtrips_and_tolerates_missing_node_config() {
464        let fp = ArchitectureFingerprint::of(&chain())
465            .unwrap()
466            .with_node_config(BTreeMap::from([("a".to_string(), "deadbeef".to_string())]));
467        let json = serde_json::to_string(&fp).unwrap();
468        let back: ArchitectureFingerprint = serde_json::from_str(&json).unwrap();
469        assert_eq!(back, fp);
470        assert_eq!(back.node_config["a"], "deadbeef");
471
472        let minimal = serde_json::json!({"digest": "abc", "n_nodes": 1, "n_edges": 0});
473        let back: ArchitectureFingerprint = serde_json::from_value(minimal).unwrap();
474        assert!(back.node_config.is_empty());
475        assert!(back.nodes.is_empty());
476        assert!(back.node_tokens().is_empty());
477    }
478
479    #[test]
480    fn pipeline_summary_reads_as_a_chain_or_reports_shape() {
481        assert_eq!(pipeline_summary(&Graph::new()), "empty graph");
482        assert_eq!(
483            pipeline_summary(&chain()),
484            "a(StandardScaler) → b(PCA) → c(SVM)"
485        );
486
487        let mut forked = chain();
488        forked.add_node(Node::filter("Aux"));
489        forked.add_edge(Edge::data("fork", "a", "Aux"));
490        let summary = pipeline_summary(&forked);
491        assert!(summary.contains("4 nodes, 3 edges"), "{summary}");
492
493        let mut wide = Graph::new();
494        for i in 0..12 {
495            wide.add_node(Node::filter_with_id(format!("n{i}"), "Dense"));
496        }
497        let summary = pipeline_summary(&wide);
498        assert!(summary.contains("+4 more"), "{summary}");
499    }
500
501    #[test]
502    fn pipeline_summary_survives_a_cycle() {
503        let mut cyclic = Graph::new();
504        cyclic.add_node(Node::filter("A"));
505        cyclic.add_node(Node::filter("B"));
506        cyclic.add_edge(Edge::data("e1", "A", "B"));
507        cyclic.add_edge(Edge::data("e2", "B", "A"));
508        assert!(cyclic.topological_sort().is_err());
509        let summary = pipeline_summary(&cyclic);
510        assert!(summary.contains('A') && summary.contains('B'), "{summary}");
511    }
512}