1use crate::canon::hash_canonical;
25use crate::error::Result;
26use crate::graph::{EdgeKind, Graph, NodeKind};
27use serde::{Deserialize, Serialize};
28use std::collections::BTreeMap;
29
30#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
36pub struct ArchitectureFingerprint {
37 pub digest: String,
39 #[serde(default)]
43 pub nodes: BTreeMap<String, String>,
44 #[serde(default)]
46 pub edges: Vec<EdgeRef>,
47 pub n_nodes: usize,
49 pub n_edges: usize,
51 #[serde(default)]
55 pub node_config: BTreeMap<String, String>,
56}
57
58#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
60pub struct EdgeRef {
61 pub source: String,
63 pub target: String,
65 pub kind: String,
67}
68
69impl ArchitectureFingerprint {
70 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 pub fn with_node_config(mut self, node_config: BTreeMap<String, String>) -> Self {
95 self.node_config = node_config;
96 self
97 }
98
99 pub fn short(&self) -> &str {
101 let end = self.digest.len().min(12);
102 &self.digest[..end]
103 }
104
105 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 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
133pub 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
148pub 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 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#[derive(Debug, Serialize)]
203struct CanonicalGraph {
204 nodes: Vec<(String, String)>,
206 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
232fn 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
253fn 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 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 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 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 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}