Skip to main content

optirs_nas/
architecture_knowledge_graph.rs

1//! Architecture Knowledge Graph for transfer learning across NAS searches.
2//!
3//! This module provides a graph data structure where each node represents a
4//! previously-evaluated neural architecture and edges encode relationships
5//! between them (similarity, derivation, dominance, knowledge transfer).
6//!
7//! The graph enables similarity queries against learned embeddings and
8//! random-walk-with-restart for propagating knowledge through the graph,
9//! both of which are useful for warm-starting new NAS searches by reusing
10//! information from prior evaluations.
11//!
12//! # Examples
13//!
14//! ```
15//! use optirs_nas::architecture_knowledge_graph::{
16//!     ArchitectureKnowledgeGraph, PerformanceRecord, RelationType,
17//! };
18//!
19//! let mut graph = ArchitectureKnowledgeGraph::new();
20//! let a = graph.add_architecture("arch-a", vec![1.0, 0.0, 0.0], "vision");
21//! let b = graph.add_architecture("arch-b", vec![0.9, 0.1, 0.0], "vision");
22//! graph.add_edge(a, b, RelationType::Similar, 0.95).unwrap();
23//!
24//! let similar = graph.find_similar(&[1.0, 0.0, 0.0], 1);
25//! assert_eq!(similar[0].0, a);
26//! ```
27//!
28//! # Design notes
29//!
30//! - Nodes are stored in a `Vec` so `NodeId` can be a stable index. The
31//!   `next_id` counter equals `nodes.len()` and is preserved across
32//!   serialisation rounds.
33//! - The adjacency map is rebuilt on demand from edges after deserialisation
34//!   (a `Vec<ArchKnowledgeEdge>` is the canonical source of truth).
35//! - Cosine similarity treats zero-norm vectors as orthogonal (similarity 0)
36//!   to avoid producing NaN.
37//! - Random-walk-with-restart normalises by the absolute weight sum to
38//!   tolerate negative similarity weights, with dangling and zero-weight
39//!   nodes handled explicitly.
40
41use crate::error::{OptimError, Result};
42use serde::{Deserialize, Serialize};
43use std::collections::HashMap;
44
45/// Stable handle to a node in the knowledge graph.
46///
47/// Node ids are assigned monotonically when architectures are added and are
48/// never reused, so existing handles remain valid for the lifetime of the
49/// graph.
50pub type NodeId = usize;
51
52/// Type of relationship that can exist between two architectures.
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
54pub enum RelationType {
55    /// Architectures whose embeddings have cosine similarity above a
56    /// configured threshold.
57    Similar,
58    /// The target architecture was produced by mutating or crossing-over the
59    /// source architecture.
60    Derived,
61    /// The target architecture outperforms the source on the same task.
62    Outperforms,
63    /// Knowledge can transfer between architectures, typically across
64    /// different domains.
65    Transfers,
66}
67
68/// Recorded evaluation result for an architecture on a particular task.
69#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
70pub struct PerformanceRecord {
71    /// Identifier of the task on which the architecture was evaluated.
72    pub task_id: String,
73    /// Top-line accuracy / quality metric in `[0, 1]` (caller decides scale).
74    pub accuracy: f64,
75    /// Inference latency in milliseconds.
76    pub latency_ms: f64,
77    /// Peak memory usage in megabytes.
78    pub memory_mb: f64,
79    /// Number of training epochs used to produce this record.
80    pub training_epochs: usize,
81}
82
83/// A single architecture entry in the knowledge graph.
84#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct ArchKnowledgeNode {
86    /// Stable graph-local identifier.
87    pub id: NodeId,
88    /// External / human-readable architecture identifier.
89    pub arch_id: String,
90    /// Dense embedding used for similarity search.
91    pub embedding: Vec<f64>,
92    /// Optional performance record (set after evaluation completes).
93    pub performance: Option<PerformanceRecord>,
94    /// Application domain the architecture targets (e.g. `"vision"`).
95    pub domain: String,
96    /// Free-form metadata attached by the caller.
97    pub metadata: HashMap<String, String>,
98}
99
100/// A directed weighted edge between two architectures.
101#[derive(Debug, Clone, Serialize, Deserialize)]
102pub struct ArchKnowledgeEdge {
103    /// Source node identifier.
104    pub from: NodeId,
105    /// Target node identifier.
106    pub to: NodeId,
107    /// Semantic relation the edge encodes.
108    pub relation: RelationType,
109    /// Edge weight. For `Similar` edges this is a cosine similarity in
110    /// `[-1, 1]`; for other relations it is an arbitrary finite scalar.
111    pub weight: f64,
112}
113
114/// A graph of architectures and their relationships.
115///
116/// Stores nodes contiguously in insertion order and indexes outgoing edges
117/// via an `adjacency` map for O(deg) neighbour lookups. The map is
118/// reconstructed from the edge list whenever the graph is loaded from JSON.
119#[derive(Debug, Clone, Serialize, Deserialize)]
120pub struct ArchitectureKnowledgeGraph {
121    /// All nodes, indexed by `NodeId`.
122    nodes: Vec<ArchKnowledgeNode>,
123    /// All edges in insertion order.
124    edges: Vec<ArchKnowledgeEdge>,
125    /// Outgoing-edge index: maps `NodeId` to the indices into `edges` for
126    /// edges whose `from` field equals the key.
127    #[serde(skip)]
128    adjacency: HashMap<NodeId, Vec<usize>>,
129    /// Next `NodeId` to assign.
130    next_id: NodeId,
131}
132
133impl Default for ArchitectureKnowledgeGraph {
134    fn default() -> Self {
135        Self::new()
136    }
137}
138
139impl ArchitectureKnowledgeGraph {
140    /// Construct an empty graph.
141    pub fn new() -> Self {
142        Self {
143            nodes: Vec::new(),
144            edges: Vec::new(),
145            adjacency: HashMap::new(),
146            next_id: 0,
147        }
148    }
149
150    /// Construct an empty graph pre-allocating storage for `n` nodes.
151    pub fn with_capacity(n: usize) -> Self {
152        Self {
153            nodes: Vec::with_capacity(n),
154            edges: Vec::with_capacity(n),
155            adjacency: HashMap::with_capacity(n),
156            next_id: 0,
157        }
158    }
159
160    /// Number of nodes in the graph.
161    pub fn len(&self) -> usize {
162        self.nodes.len()
163    }
164
165    /// Returns `true` when the graph has no nodes.
166    pub fn is_empty(&self) -> bool {
167        self.nodes.is_empty()
168    }
169
170    /// Number of edges in the graph.
171    pub fn edge_count(&self) -> usize {
172        self.edges.len()
173    }
174
175    /// Rebuild the adjacency index from the edge list.
176    ///
177    /// Required after deserialisation, since `adjacency` is `#[serde(skip)]`.
178    fn rebuild_adjacency(&mut self) {
179        self.adjacency.clear();
180        for (idx, edge) in self.edges.iter().enumerate() {
181            self.adjacency.entry(edge.from).or_default().push(idx);
182        }
183    }
184
185    /// Insert a new architecture into the graph and return its `NodeId`.
186    pub fn add_architecture(
187        &mut self,
188        arch_id: impl Into<String>,
189        embedding: Vec<f64>,
190        domain: impl Into<String>,
191    ) -> NodeId {
192        let id = self.next_id;
193        self.next_id += 1;
194        let node = ArchKnowledgeNode {
195            id,
196            arch_id: arch_id.into(),
197            embedding,
198            performance: None,
199            domain: domain.into(),
200            metadata: HashMap::new(),
201        };
202        self.nodes.push(node);
203        id
204    }
205
206    /// Attach a performance record to an existing node.
207    pub fn set_performance(&mut self, node: NodeId, record: PerformanceRecord) -> Result<()> {
208        let target = self.nodes.get_mut(node).ok_or_else(|| {
209            OptimError::InvalidParameter(format!(
210                "set_performance: node id {} does not exist",
211                node
212            ))
213        })?;
214        target.performance = Some(record);
215        Ok(())
216    }
217
218    /// Add a typed weighted edge between two existing nodes.
219    ///
220    /// Rejects edges that reference unknown nodes, that carry a non-finite
221    /// weight, or whose weight is outside `[-1, 1]` for `Similar` edges.
222    pub fn add_edge(
223        &mut self,
224        from: NodeId,
225        to: NodeId,
226        relation: RelationType,
227        weight: f64,
228    ) -> Result<()> {
229        if from >= self.nodes.len() {
230            return Err(OptimError::InvalidParameter(format!(
231                "add_edge: source node {} does not exist",
232                from
233            )));
234        }
235        if to >= self.nodes.len() {
236            return Err(OptimError::InvalidParameter(format!(
237                "add_edge: target node {} does not exist",
238                to
239            )));
240        }
241        if !weight.is_finite() {
242            return Err(OptimError::InvalidParameter(format!(
243                "add_edge: weight must be finite, got {}",
244                weight
245            )));
246        }
247        if matches!(relation, RelationType::Similar) && !(-1.0..=1.0).contains(&weight) {
248            return Err(OptimError::InvalidParameter(format!(
249                "add_edge: Similar weight must be in [-1, 1], got {}",
250                weight
251            )));
252        }
253
254        let edge_index = self.edges.len();
255        self.edges.push(ArchKnowledgeEdge {
256            from,
257            to,
258            relation,
259            weight,
260        });
261        self.adjacency.entry(from).or_default().push(edge_index);
262        Ok(())
263    }
264
265    /// Borrow a node by id (returns `None` if it does not exist).
266    pub fn node(&self, id: NodeId) -> Option<&ArchKnowledgeNode> {
267        self.nodes.get(id)
268    }
269
270    /// Borrow all nodes in insertion order.
271    pub fn nodes(&self) -> &[ArchKnowledgeNode] {
272        &self.nodes
273    }
274
275    /// Outgoing neighbours of `id`, returned as `(target, relation, weight)`.
276    ///
277    /// Returns an empty vector for nodes with no outgoing edges or for
278    /// unknown ids.
279    pub fn neighbors(&self, id: NodeId) -> Vec<(NodeId, RelationType, f64)> {
280        match self.adjacency.get(&id) {
281            None => Vec::new(),
282            Some(indices) => indices
283                .iter()
284                .filter_map(|&i| self.edges.get(i))
285                .map(|e| (e.to, e.relation, e.weight))
286                .collect(),
287        }
288    }
289
290    /// Cosine similarity between two vectors.
291    ///
292    /// Returns `0.0` when either vector is the zero vector, when the lengths
293    /// differ, or when any element is non-finite. This is safer than
294    /// returning `NaN` for downstream ranking code.
295    pub fn cosine_similarity(a: &[f64], b: &[f64]) -> f64 {
296        if a.len() != b.len() || a.is_empty() {
297            return 0.0;
298        }
299        let mut dot = 0.0;
300        let mut norm_a = 0.0;
301        let mut norm_b = 0.0;
302        for (x, y) in a.iter().zip(b.iter()) {
303            if !x.is_finite() || !y.is_finite() {
304                return 0.0;
305            }
306            dot += x * y;
307            norm_a += x * x;
308            norm_b += y * y;
309        }
310        if norm_a <= 0.0 || norm_b <= 0.0 {
311            return 0.0;
312        }
313        let denom = norm_a.sqrt() * norm_b.sqrt();
314        if denom <= 0.0 {
315            return 0.0;
316        }
317        dot / denom
318    }
319
320    /// Top-`k` nodes by cosine similarity to `query_embedding`, descending.
321    ///
322    /// Nodes whose embeddings are not comparable (mismatched length or
323    /// zero-norm) are silently skipped. If `k` is zero or the graph is
324    /// empty, returns an empty vector.
325    pub fn find_similar(&self, query_embedding: &[f64], k: usize) -> Vec<(NodeId, f64)> {
326        if k == 0 || self.nodes.is_empty() {
327            return Vec::new();
328        }
329        let mut scored: Vec<(NodeId, f64)> = self
330            .nodes
331            .iter()
332            .map(|n| (n.id, Self::cosine_similarity(query_embedding, &n.embedding)))
333            .collect();
334        scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
335        scored.truncate(k);
336        scored
337    }
338
339    /// Add `Similar` edges between every distinct pair of nodes whose
340    /// cosine similarity meets `threshold`. Returns the number of edges
341    /// added.
342    ///
343    /// Only one directed edge `(i -> j)` with `i < j` is added per pair to
344    /// avoid duplicates and self-loops. Edges are skipped when an identical
345    /// `(from, to, Similar)` triple already exists.
346    pub fn auto_add_similar_edges(&mut self, threshold: f64) -> usize {
347        if self.nodes.len() < 2 {
348            return 0;
349        }
350        // Pre-compute similarities into a vector of (i, j, sim) so we can
351        // borrow self immutably for the scan and then mutate when adding.
352        let mut to_add: Vec<(NodeId, NodeId, f64)> = Vec::new();
353        for i in 0..self.nodes.len() {
354            for j in (i + 1)..self.nodes.len() {
355                let sim =
356                    Self::cosine_similarity(&self.nodes[i].embedding, &self.nodes[j].embedding);
357                if sim >= threshold && sim.is_finite() {
358                    to_add.push((self.nodes[i].id, self.nodes[j].id, sim));
359                }
360            }
361        }
362
363        let mut added = 0usize;
364        for (from, to, sim) in to_add {
365            // Skip if a Similar edge already exists for this directed pair.
366            let exists = self
367                .adjacency
368                .get(&from)
369                .map(|indices| {
370                    indices.iter().any(|&idx| {
371                        let edge = &self.edges[idx];
372                        edge.to == to && edge.relation == RelationType::Similar
373                    })
374                })
375                .unwrap_or(false);
376            if exists {
377                continue;
378            }
379            // Clamp similarity into [-1, 1] to satisfy the add_edge guard
380            // against floating-point drift slightly outside the interval.
381            let clamped = sim.clamp(-1.0, 1.0);
382            if self
383                .add_edge(from, to, RelationType::Similar, clamped)
384                .is_ok()
385            {
386                added += 1;
387            }
388        }
389        added
390    }
391
392    /// Random-walk-with-restart from `start`.
393    ///
394    /// At each of `depth` iterations every node redistributes `decay × score`
395    /// to its outgoing neighbours (weighted by `|weight|` and normalised to
396    /// sum to one). The remaining `1 - decay` mass restarts at `start`.
397    /// Dangling nodes — those without outgoing edges — return all their
398    /// mass to `start`, and groups of neighbours whose weights sum to zero
399    /// share the mass uniformly.
400    pub fn propagate_knowledge(
401        &self,
402        start: NodeId,
403        decay: f64,
404        depth: usize,
405    ) -> Result<HashMap<NodeId, f64>> {
406        if !decay.is_finite() || decay <= 0.0 || decay >= 1.0 {
407            return Err(OptimError::InvalidParameter(format!(
408                "propagate_knowledge: decay must be in (0, 1), got {}",
409                decay
410            )));
411        }
412        if depth == 0 {
413            return Err(OptimError::InvalidParameter(
414                "propagate_knowledge: depth must be > 0".to_string(),
415            ));
416        }
417        if start >= self.nodes.len() {
418            return Err(OptimError::InvalidParameter(format!(
419                "propagate_knowledge: start node {} does not exist",
420                start
421            )));
422        }
423
424        let mut scores: HashMap<NodeId, f64> = HashMap::new();
425        scores.insert(start, 1.0);
426
427        for _ in 0..depth {
428            // Seed the iteration with the full restart mass at `start` so
429            // the seed score never drops below its initial value of 1.0.
430            // Outgoing flow from `start` contributes additional probability
431            // mass to its neighbours each step, encoding the random walk.
432            let mut new_scores: HashMap<NodeId, f64> = HashMap::new();
433            new_scores.insert(start, 1.0);
434
435            for (&node, &score) in scores.iter() {
436                if score == 0.0 {
437                    continue;
438                }
439                let neighbours = self.neighbors(node);
440                if neighbours.is_empty() {
441                    // Dangling node: redistribute its weighted mass to the
442                    // restart node so probability remains anchored.
443                    *new_scores.entry(start).or_insert(0.0) += decay * score;
444                    continue;
445                }
446                let weight_sum: f64 = neighbours.iter().map(|(_, _, w)| w.abs()).sum();
447                if weight_sum > 0.0 {
448                    for (neigh, _, w) in &neighbours {
449                        let contribution = decay * score * w.abs() / weight_sum;
450                        *new_scores.entry(*neigh).or_insert(0.0) += contribution;
451                    }
452                } else {
453                    // All neighbours have zero weight: split mass uniformly.
454                    let share = decay * score / neighbours.len() as f64;
455                    for (neigh, _, _) in &neighbours {
456                        *new_scores.entry(*neigh).or_insert(0.0) += share;
457                    }
458                }
459            }
460            scores = new_scores;
461        }
462        Ok(scores)
463    }
464
465    /// Find up to `k` transferable architectures for `target_domain`.
466    ///
467    /// A node is considered transferable if its own `domain` matches
468    /// `target_domain`, or if any of its outgoing `Transfers` edges points
469    /// to a node whose domain matches, or if it is itself the target of a
470    /// `Transfers` edge from a node whose domain matches. Among the
471    /// transferable nodes, the top-`k` by cosine similarity to
472    /// `query_embedding` are returned (descending).
473    pub fn extract_top_k_transferable(
474        &self,
475        target_domain: &str,
476        query_embedding: &[f64],
477        k: usize,
478    ) -> Vec<(NodeId, f64)> {
479        if k == 0 || self.nodes.is_empty() {
480            return Vec::new();
481        }
482        // Build a set of node ids that are reachable from `target_domain`
483        // via `Transfers` edges, in either direction.
484        let mut transferable: std::collections::HashSet<NodeId> = std::collections::HashSet::new();
485        for node in &self.nodes {
486            if node.domain == target_domain {
487                transferable.insert(node.id);
488            }
489        }
490        for edge in &self.edges {
491            if edge.relation != RelationType::Transfers {
492                continue;
493            }
494            let from_domain = self.nodes.get(edge.from).map(|n| n.domain.as_str());
495            let to_domain = self.nodes.get(edge.to).map(|n| n.domain.as_str());
496            if from_domain == Some(target_domain) {
497                transferable.insert(edge.to);
498            }
499            if to_domain == Some(target_domain) {
500                transferable.insert(edge.from);
501            }
502        }
503
504        let mut scored: Vec<(NodeId, f64)> = self
505            .nodes
506            .iter()
507            .filter(|n| transferable.contains(&n.id))
508            .map(|n| (n.id, Self::cosine_similarity(query_embedding, &n.embedding)))
509            .collect();
510        scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
511        scored.truncate(k);
512        scored
513    }
514
515    /// Serialise the graph to a pretty-printed JSON string.
516    pub fn to_json(&self) -> Result<String> {
517        serde_json::to_string_pretty(self).map_err(|e| {
518            OptimError::ArchitectureError(format!("Failed to serialise knowledge graph: {}", e))
519        })
520    }
521
522    /// Parse a graph from a JSON string previously produced by `to_json`.
523    pub fn from_json(s: &str) -> Result<Self> {
524        let mut graph: Self = serde_json::from_str(s).map_err(|e| {
525            OptimError::ArchitectureError(format!("Failed to deserialise knowledge graph: {}", e))
526        })?;
527        graph.rebuild_adjacency();
528        // Repair next_id in case the saved value is stale.
529        if !graph.nodes.is_empty() {
530            let max = graph.nodes.iter().map(|n| n.id).max().unwrap_or(0);
531            if graph.next_id <= max {
532                graph.next_id = max + 1;
533            }
534        }
535        Ok(graph)
536    }
537}
538
539#[cfg(test)]
540mod tests {
541    use super::*;
542
543    fn make_record(task: &str, accuracy: f64) -> PerformanceRecord {
544        PerformanceRecord {
545            task_id: task.to_string(),
546            accuracy,
547            latency_ms: 10.0,
548            memory_mb: 64.0,
549            training_epochs: 5,
550        }
551    }
552
553    fn make_linear_graph(n: usize) -> (ArchitectureKnowledgeGraph, Vec<NodeId>) {
554        let mut graph = ArchitectureKnowledgeGraph::with_capacity(n);
555        let mut ids = Vec::new();
556        for i in 0..n {
557            let emb = vec![i as f64 + 1.0, 0.0, 0.0];
558            ids.push(graph.add_architecture(format!("arch-{}", i), emb, "vision"));
559        }
560        for i in 0..(n - 1) {
561            graph
562                .add_edge(ids[i], ids[i + 1], RelationType::Derived, 1.0)
563                .expect("linear edge");
564        }
565        (graph, ids)
566    }
567
568    #[test]
569    fn test_add_architecture_returns_increasing_ids() {
570        let mut graph = ArchitectureKnowledgeGraph::new();
571        let a = graph.add_architecture("a", vec![1.0, 2.0], "vision");
572        let b = graph.add_architecture("b", vec![3.0, 4.0], "vision");
573        let c = graph.add_architecture("c", vec![5.0, 6.0], "nlp");
574        assert_eq!(a, 0);
575        assert_eq!(b, 1);
576        assert_eq!(c, 2);
577        assert_eq!(graph.len(), 3);
578        assert!(!graph.is_empty());
579        assert_eq!(graph.edge_count(), 0);
580        assert_eq!(graph.node(a).expect("present").arch_id, "a");
581        assert!(graph.node(99).is_none());
582    }
583
584    #[test]
585    fn test_add_edge_validates_endpoints() {
586        let mut graph = ArchitectureKnowledgeGraph::new();
587        let a = graph.add_architecture("a", vec![1.0, 0.0], "vision");
588        let b = graph.add_architecture("b", vec![0.0, 1.0], "vision");
589        graph
590            .add_edge(a, b, RelationType::Outperforms, 0.5)
591            .expect("valid edge");
592        assert_eq!(graph.edge_count(), 1);
593        assert!(graph.add_edge(a, 42, RelationType::Derived, 1.0).is_err());
594        assert!(graph.add_edge(99, b, RelationType::Derived, 1.0).is_err());
595        // Non-finite weight rejected.
596        assert!(graph
597            .add_edge(a, b, RelationType::Derived, f64::NAN)
598            .is_err());
599        // Similar weight outside [-1, 1] rejected.
600        assert!(graph.add_edge(a, b, RelationType::Similar, 1.5).is_err());
601        // Similar weight inside [-1, 1] accepted.
602        graph
603            .add_edge(a, b, RelationType::Similar, 0.9)
604            .expect("similar in range");
605    }
606
607    #[test]
608    fn test_cosine_similarity_identical_is_one() {
609        let v = vec![1.0, 2.0, 3.0, -4.0];
610        let sim = ArchitectureKnowledgeGraph::cosine_similarity(&v, &v);
611        assert!((sim - 1.0).abs() < 1e-12, "expected 1.0, got {}", sim);
612    }
613
614    #[test]
615    fn test_cosine_similarity_orthogonal_is_zero() {
616        let a = [1.0, 0.0, 0.0];
617        let b = [0.0, 1.0, 0.0];
618        let sim = ArchitectureKnowledgeGraph::cosine_similarity(&a, &b);
619        assert!(sim.abs() < 1e-12, "expected 0.0, got {}", sim);
620    }
621
622    #[test]
623    fn test_cosine_similarity_zero_norm_returns_zero() {
624        let zero = [0.0, 0.0, 0.0];
625        let other = [1.0, 2.0, 3.0];
626        assert_eq!(
627            ArchitectureKnowledgeGraph::cosine_similarity(&zero, &other),
628            0.0
629        );
630        assert_eq!(
631            ArchitectureKnowledgeGraph::cosine_similarity(&other, &zero),
632            0.0
633        );
634        assert_eq!(
635            ArchitectureKnowledgeGraph::cosine_similarity(&zero, &zero),
636            0.0
637        );
638        // Mismatched length is also treated as zero.
639        assert_eq!(
640            ArchitectureKnowledgeGraph::cosine_similarity(&[1.0, 0.0], &[1.0, 0.0, 0.0]),
641            0.0
642        );
643        // Empty vectors return zero.
644        assert_eq!(
645            ArchitectureKnowledgeGraph::cosine_similarity(&[] as &[f64], &[] as &[f64]),
646            0.0
647        );
648    }
649
650    #[test]
651    fn test_find_similar_returns_top_k_sorted_descending() {
652        let mut graph = ArchitectureKnowledgeGraph::new();
653        graph.add_architecture("a", vec![1.0, 0.0, 0.0], "vision");
654        graph.add_architecture("b", vec![0.9, 0.1, 0.0], "vision");
655        graph.add_architecture("c", vec![0.0, 1.0, 0.0], "vision");
656        graph.add_architecture("d", vec![-1.0, 0.0, 0.0], "vision");
657
658        let top = graph.find_similar(&[1.0, 0.0, 0.0], 3);
659        assert_eq!(top.len(), 3);
660        // Descending order by similarity.
661        assert!(top[0].1 >= top[1].1);
662        assert!(top[1].1 >= top[2].1);
663        // Most similar is the identity match.
664        assert_eq!(top[0].0, 0);
665        // k=0 returns nothing.
666        assert!(graph.find_similar(&[1.0, 0.0, 0.0], 0).is_empty());
667        // Empty graph returns nothing.
668        let empty = ArchitectureKnowledgeGraph::new();
669        assert!(empty.find_similar(&[1.0, 0.0, 0.0], 5).is_empty());
670    }
671
672    #[test]
673    fn test_auto_add_similar_edges_threshold_respected() {
674        let mut graph = ArchitectureKnowledgeGraph::new();
675        graph.add_architecture("a", vec![1.0, 0.0, 0.0], "vision");
676        graph.add_architecture("b", vec![0.99, 0.01, 0.0], "vision"); // very similar to a
677        graph.add_architecture("c", vec![0.0, 1.0, 0.0], "vision"); // orthogonal
678
679        let added = graph.auto_add_similar_edges(0.95);
680        assert_eq!(added, 1, "only the a-b pair should pass 0.95 threshold");
681        assert_eq!(graph.edge_count(), 1);
682        let edge = &graph.edges[0];
683        assert_eq!(edge.relation, RelationType::Similar);
684        assert!(edge.weight >= 0.95);
685
686        // Calling again with the same threshold should not duplicate.
687        let added_again = graph.auto_add_similar_edges(0.95);
688        assert_eq!(added_again, 0);
689        assert_eq!(graph.edge_count(), 1);
690
691        // A lower threshold pulls in another edge (a-c is orthogonal but
692        // b-c is also orthogonal; only one edge can satisfy 0.0 so 2 more
693        // are added: a-c and b-c when threshold is small enough).
694        let added_low = graph.auto_add_similar_edges(-1.0);
695        assert!(added_low >= 1);
696    }
697
698    #[test]
699    fn test_propagate_knowledge_start_is_one() {
700        let (graph, ids) = make_linear_graph(3);
701        let scores = graph
702            .propagate_knowledge(ids[0], 0.85, 5)
703            .expect("propagation");
704        let start_score = scores.get(&ids[0]).copied().unwrap_or(0.0);
705        assert!(
706            start_score >= 1.0 - 1e-9,
707            "start score should be >= 1.0, got {}",
708            start_score
709        );
710    }
711
712    #[test]
713    fn test_propagate_knowledge_closer_higher_score() {
714        let (graph, ids) = make_linear_graph(4);
715        let scores = graph
716            .propagate_knowledge(ids[0], 0.85, 8)
717            .expect("propagation");
718        let s1 = scores.get(&ids[1]).copied().unwrap_or(0.0);
719        let s2 = scores.get(&ids[2]).copied().unwrap_or(0.0);
720        let s3 = scores.get(&ids[3]).copied().unwrap_or(0.0);
721        assert!(
722            s1 > s2,
723            "direct neighbour score ({}) should exceed 2-hop ({})",
724            s1,
725            s2
726        );
727        assert!(s2 > s3, "2-hop ({}) should exceed 3-hop ({})", s2, s3);
728    }
729
730    #[test]
731    fn test_propagate_knowledge_invalid_decay_errors() {
732        let (graph, ids) = make_linear_graph(2);
733        assert!(graph.propagate_knowledge(ids[0], 0.0, 3).is_err());
734        assert!(graph.propagate_knowledge(ids[0], 1.0, 3).is_err());
735        assert!(graph.propagate_knowledge(ids[0], -0.1, 3).is_err());
736        assert!(graph.propagate_knowledge(ids[0], 1.1, 3).is_err());
737        assert!(graph.propagate_knowledge(ids[0], f64::NAN, 3).is_err());
738        // depth = 0 also errors.
739        assert!(graph.propagate_knowledge(ids[0], 0.5, 0).is_err());
740        // unknown start node errors.
741        assert!(graph.propagate_knowledge(99, 0.5, 3).is_err());
742    }
743
744    #[test]
745    fn test_propagate_knowledge_dangling_returns_to_start() {
746        // Single-node graph: start has no neighbours so all decayed mass
747        // returns to it each iteration. The score must therefore remain at
748        // or above the initial restart value of 1.0.
749        let mut graph = ArchitectureKnowledgeGraph::new();
750        let s = graph.add_architecture("solo", vec![1.0, 0.0], "vision");
751        let scores = graph
752            .propagate_knowledge(s, 0.5, 3)
753            .expect("propagation on dangling");
754        let start_score = scores.get(&s).copied().unwrap_or(0.0);
755        // Only the start node ever receives mass when it has no outgoing
756        // edges, so the steady-state score is bounded below by 1.0.
757        assert!(
758            start_score >= 1.0 - 1e-9,
759            "expected dangling start to be >= 1.0, got {}",
760            start_score
761        );
762        // Only one entry in the score map.
763        assert_eq!(scores.len(), 1);
764    }
765
766    #[test]
767    fn test_json_roundtrip_preserves_graph() {
768        let mut graph = ArchitectureKnowledgeGraph::new();
769        let a = graph.add_architecture("a", vec![1.0, 0.0, 0.0], "vision");
770        let b = graph.add_architecture("b", vec![0.9, 0.1, 0.0], "vision");
771        let c = graph.add_architecture("c", vec![0.0, 1.0, 0.0], "nlp");
772        graph
773            .add_edge(a, b, RelationType::Similar, 0.99)
774            .expect("similar");
775        graph
776            .add_edge(b, c, RelationType::Transfers, 0.5)
777            .expect("transfers");
778        graph
779            .set_performance(a, make_record("imagenet", 0.78))
780            .expect("perf");
781
782        let json = graph.to_json().expect("to_json");
783        let restored = ArchitectureKnowledgeGraph::from_json(&json).expect("from_json");
784
785        assert_eq!(restored.len(), graph.len());
786        assert_eq!(restored.edge_count(), graph.edge_count());
787        for (orig, back) in graph.nodes().iter().zip(restored.nodes().iter()) {
788            assert_eq!(orig.id, back.id);
789            assert_eq!(orig.arch_id, back.arch_id);
790            assert_eq!(orig.domain, back.domain);
791            assert_eq!(orig.embedding, back.embedding);
792            assert_eq!(orig.performance, back.performance);
793        }
794        // Adjacency must have been rebuilt so neighbours work.
795        let neigh = restored.neighbors(a);
796        assert_eq!(neigh.len(), 1);
797        assert_eq!(neigh[0].0, b);
798        assert_eq!(neigh[0].1, RelationType::Similar);
799    }
800
801    #[test]
802    fn test_extract_top_k_transferable_filters_by_domain() {
803        let mut graph = ArchitectureKnowledgeGraph::new();
804        let v1 = graph.add_architecture("v1", vec![1.0, 0.0, 0.0], "vision");
805        let v2 = graph.add_architecture("v2", vec![0.9, 0.1, 0.0], "vision");
806        let n1 = graph.add_architecture("n1", vec![0.95, 0.0, 0.05], "nlp");
807        let n2 = graph.add_architecture("n2", vec![0.0, 0.0, 1.0], "nlp");
808        // n1 transfers knowledge to vision domain via v1.
809        graph
810            .add_edge(n1, v1, RelationType::Transfers, 0.7)
811            .expect("transfers");
812
813        let top = graph.extract_top_k_transferable("vision", &[1.0, 0.0, 0.0], 5);
814        let ids: Vec<NodeId> = top.iter().map(|t| t.0).collect();
815        // Both vision nodes and n1 (linked via Transfers) are eligible.
816        assert!(ids.contains(&v1));
817        assert!(ids.contains(&v2));
818        assert!(ids.contains(&n1));
819        // Unrelated nlp node n2 must be excluded.
820        assert!(!ids.contains(&n2));
821        // Sorted descending by similarity to [1, 0, 0].
822        for window in top.windows(2) {
823            assert!(window[0].1 >= window[1].1);
824        }
825        // k = 0 returns empty.
826        assert!(graph
827            .extract_top_k_transferable("vision", &[1.0, 0.0, 0.0], 0)
828            .is_empty());
829    }
830
831    #[test]
832    fn test_set_performance_on_valid_node() {
833        let mut graph = ArchitectureKnowledgeGraph::new();
834        let a = graph.add_architecture("a", vec![1.0, 0.0], "vision");
835        let record = make_record("cifar10", 0.91);
836        graph
837            .set_performance(a, record.clone())
838            .expect("set_performance");
839        let node = graph.node(a).expect("present");
840        let attached = node.performance.as_ref().expect("performance");
841        assert_eq!(attached.task_id, "cifar10");
842        assert!((attached.accuracy - 0.91).abs() < 1e-12);
843        assert_eq!(attached.training_epochs, 5);
844    }
845
846    #[test]
847    fn test_set_performance_on_invalid_node_errors() {
848        let mut graph = ArchitectureKnowledgeGraph::new();
849        graph.add_architecture("a", vec![1.0, 0.0], "vision");
850        let err = graph.set_performance(42, make_record("cifar10", 0.5));
851        assert!(err.is_err(), "expected error for missing node");
852    }
853
854    #[test]
855    fn test_neighbors_on_unknown_node_is_empty() {
856        let graph = ArchitectureKnowledgeGraph::new();
857        assert!(graph.neighbors(0).is_empty());
858        let mut graph = ArchitectureKnowledgeGraph::new();
859        graph.add_architecture("a", vec![1.0, 0.0], "vision");
860        assert!(graph.neighbors(0).is_empty()); // no outgoing edges yet
861        assert!(graph.neighbors(99).is_empty()); // unknown id
862    }
863}