Skip to main content

optirs_nas/
few_shot_architecture.rs

1//! Few-shot architecture optimization for Neural Architecture Search.
2//!
3//! When a new target task is encountered, full retraining or thousands of
4//! architecture evaluations are typically prohibitive. *Few-shot* methods
5//! sidestep that cost by transferring knowledge from a small *support set*
6//! of `(embedding, performance)` pairs — typically drawn from related tasks
7//! through [`ArchitectureKnowledgeGraph`] — to a *query* architecture whose
8//! performance we wish to predict.
9//!
10//! This module implements four widely-used few-shot algorithms specialised
11//! for the architecture-search regression setting:
12//!
13//! 1. **Prototypical Networks** — partitions the support set into
14//!    performance-quantile buckets, computes the mean embedding of each
15//!    bucket as a *prototype*, and predicts the bucket whose prototype is
16//!    closest to the query. The bucket's mean performance is returned as
17//!    the prediction and the softmax over negative distances provides the
18//!    confidence.
19//! 2. **Matching Networks** — attention-weighted average of *every* support
20//!    label, where the weights come from a temperature-scaled softmax over
21//!    negative distances. Entropy of the weight distribution yields a
22//!    normalised confidence in `[0, 1]`.
23//! 3. **MAML-style linear adapter** — a one-layer linear regressor is
24//!    fitted by a handful of inner-loop gradient descent steps on the
25//!    support set. Predictions are simply `w · embedding + b`.
26//! 4. **Distance-weighted KNN** — classical inverse-distance regression
27//!    over the `k_shot` closest support points.
28//!
29//! All four share the same configuration struct, the same predict / batch
30//! / `recommend_top_k` interface, and the same `(arch_id, distance)` style
31//! diagnostic output, so callers can swap algorithms without touching the
32//! surrounding code.
33//!
34//! # Examples
35//!
36//! ```
37//! use optirs_nas::few_shot_architecture::{
38//!     ArchitectureExample, FewShotAlgorithm, FewShotArchitectureOptimizer,
39//! };
40//!
41//! let support = vec![
42//!     ArchitectureExample {
43//!         arch_id: "resnet".into(),
44//!         embedding: vec![1.0, 0.0, 0.0],
45//!         performance: 0.92,
46//!         domain: "vision".into(),
47//!     },
48//!     ArchitectureExample {
49//!         arch_id: "mobilenet".into(),
50//!         embedding: vec![0.0, 1.0, 0.0],
51//!         performance: 0.81,
52//!         domain: "vision".into(),
53//!     },
54//! ];
55//!
56//! let mut optimizer = FewShotArchitectureOptimizer::new()
57//!     .with_algorithm(FewShotAlgorithm::Matching)
58//!     .with_k_shot(2);
59//! optimizer.fit_from_examples(support).expect("fit");
60//! let prediction = optimizer.predict(&[0.9, 0.1, 0.0]).expect("predict");
61//! ```
62//!
63//! # Mathematical notes
64//!
65//! - **Euclidean**: `d(a, b) = sqrt(Σ_i (a_i - b_i)^2)`
66//! - **Cosine**: `d(a, b) = 1 - (a · b) / (‖a‖‖b‖)` clipped to `[0, 2]`
67//! - **Manhattan**: `d(a, b) = Σ_i |a_i - b_i|`
68//! - **Softmax(temperature T)**: `a_i = exp(-d_i / T) / Σ_j exp(-d_j / T)`
69//! - **Entropy**: `H(a) = -Σ_i a_i log(a_i)` with the convention `0 log 0 = 0`
70//! - **Confidence from entropy**: `1 - H(a) / log(n)` so that a uniform
71//!   distribution gives `0` (no information) and a one-hot gives `1`.
72
73use crate::architecture_knowledge_graph::{ArchitectureKnowledgeGraph, NodeId, PerformanceRecord};
74use crate::error::{OptimError, Result};
75use scirs2_core::ndarray::{Array1, Array2};
76use scirs2_core::random::Random;
77use serde::{Deserialize, Serialize};
78
79/// A single labelled architecture example used as part of the support set.
80///
81/// Each example pairs a dense embedding with a scalar performance score.
82/// The `domain` field is informational — it lets the optimizer carry
83/// provenance information into [`FewShotPrediction::nearest_support_arch`]
84/// so callers can audit which domain a recommendation came from.
85#[derive(Debug, Clone, Serialize, Deserialize)]
86pub struct ArchitectureExample {
87    /// External / human-readable architecture identifier.
88    pub arch_id: String,
89    /// Dense embedding vector. Must have the same length across the entire
90    /// support set; mismatched lengths cause [`FewShotArchitectureOptimizer::fit_from_examples`]
91    /// to reject the input.
92    pub embedding: Vec<f64>,
93    /// Scalar performance target (typically accuracy in `[0, 1]`, but any
94    /// finite real value is accepted).
95    pub performance: f64,
96    /// Free-form domain tag (e.g. `"vision"`, `"nlp"`).
97    pub domain: String,
98}
99
100/// Few-shot learning algorithm to apply when predicting a query.
101///
102/// All four variants share the same `fit`/`predict` surface so callers can
103/// swap algorithms without touching surrounding code. See the module-level
104/// documentation for a brief overview of each algorithm's semantics.
105#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
106pub enum FewShotAlgorithm {
107    /// Prototypical Networks — class prototypes via the mean embedding of
108    /// each performance-quantile bucket.
109    Prototypical,
110    /// Matching Networks — attention-weighted nearest neighbours over the
111    /// entire support set.
112    Matching,
113    /// MAML-style fast adaptation on a linear adapter (one weight per
114    /// embedding dimension plus a bias).
115    MamlAdaptation,
116    /// Distance-weighted KNN regression over the `k_shot` closest support
117    /// points.
118    DistanceWeightedKnn,
119}
120
121/// Distance metric used when comparing embeddings.
122///
123/// All metrics return non-negative scalars: `Euclidean` and `Manhattan`
124/// are unbounded, while `Cosine` is bounded above by `2` (1 minus a
125/// correlation of `-1`). The optimizer uses negative-distance softmax
126/// to convert these into attention weights, so any metric can be combined
127/// with any algorithm.
128#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
129pub enum DistanceMetric {
130    /// `sqrt(Σ_i (a_i - b_i)^2)`.
131    Euclidean,
132    /// `1 - cos(a, b)`, clipped to `[0, 2]`.
133    Cosine,
134    /// `Σ_i |a_i - b_i|`.
135    Manhattan,
136}
137
138/// Configuration controlling every aspect of [`FewShotArchitectureOptimizer`].
139///
140/// The defaults — Prototypical Networks with `k_shot = 5`, `n_way = 2`,
141/// temperature `1.0`, MAML inner-loop learning rate `0.01`, and Euclidean
142/// distance — give sensible behaviour on most small support sets. The
143/// `seed` field is honoured by the rare stochastic paths (currently only
144/// the unit-test scaffolding) so that runs are reproducible.
145#[derive(Debug, Clone)]
146pub struct FewShotConfig {
147    /// Few-shot algorithm to run when predicting.
148    pub algorithm: FewShotAlgorithm,
149    /// Number of support examples per query. Acts as the *k* in KNN and
150    /// the minimum support-set size accepted by `fit_from_examples`.
151    pub k_shot: usize,
152    /// Number of performance buckets used by Prototypical Networks. Must
153    /// be at least 1.
154    pub n_way: usize,
155    /// Softmax temperature for Matching Networks. Lower temperatures
156    /// produce sharper distributions; higher temperatures produce
157    /// closer-to-uniform distributions.
158    pub temperature: f64,
159    /// Inner-loop learning rate for the MAML linear adapter.
160    pub adapter_lr: f64,
161    /// Number of inner-loop gradient steps for the MAML adapter.
162    pub adapter_steps: usize,
163    /// Distance metric used by every algorithm that needs one.
164    pub distance_metric: DistanceMetric,
165    /// Deterministic seed for stochastic components.
166    pub seed: u64,
167}
168
169impl Default for FewShotConfig {
170    fn default() -> Self {
171        Self {
172            algorithm: FewShotAlgorithm::Prototypical,
173            k_shot: 5,
174            n_way: 2,
175            temperature: 1.0,
176            adapter_lr: 0.01,
177            adapter_steps: 5,
178            distance_metric: DistanceMetric::Euclidean,
179            seed: 42,
180        }
181    }
182}
183
184/// Single-query prediction returned by [`FewShotArchitectureOptimizer::predict`].
185///
186/// The `confidence` field is in `[0, 1]` and is algorithm-specific — see
187/// the algorithm-by-algorithm notes in the module-level documentation. The
188/// `nearest_support_arch` field is always set unless the support set is
189/// empty (which can never happen on a fitted optimizer).
190#[derive(Debug, Clone, Serialize, Deserialize)]
191pub struct FewShotPrediction {
192    /// Predicted scalar performance.
193    pub predicted_performance: f64,
194    /// Algorithm-specific confidence in `[0, 1]`.
195    pub confidence: f64,
196    /// Identifier of the closest support example, if any.
197    pub nearest_support_arch: Option<String>,
198    /// Distance from the query to that closest support example, under the
199    /// configured metric.
200    pub nearest_distance: f64,
201}
202
203/// The few-shot architecture optimizer.
204///
205/// `FewShotArchitectureOptimizer` owns the support set, the configuration,
206/// and (for the `MamlAdaptation` algorithm) the fitted adapter weights.
207/// Calling [`reset`](Self::reset) wipes all of that state and returns the
208/// optimizer to a freshly-constructed configuration.
209#[derive(Debug, Clone)]
210pub struct FewShotArchitectureOptimizer {
211    config: FewShotConfig,
212    support_set: Vec<ArchitectureExample>,
213    is_fitted: bool,
214    /// MAML linear adapter weights, one per embedding dimension. Populated
215    /// only when `config.algorithm == FewShotAlgorithm::MamlAdaptation`.
216    adapter_weights: Option<Vec<f64>>,
217    /// MAML adapter scalar bias.
218    adapter_bias: Option<f64>,
219}
220
221impl Default for FewShotArchitectureOptimizer {
222    fn default() -> Self {
223        Self::new()
224    }
225}
226
227impl FewShotArchitectureOptimizer {
228    /// Construct a new optimizer using [`FewShotConfig::default`].
229    pub fn new() -> Self {
230        Self::with_config(FewShotConfig::default())
231    }
232
233    /// Construct a new optimizer with an explicit configuration.
234    pub fn with_config(config: FewShotConfig) -> Self {
235        Self {
236            config,
237            support_set: Vec::new(),
238            is_fitted: false,
239            adapter_weights: None,
240            adapter_bias: None,
241        }
242    }
243
244    /// Replace the few-shot algorithm.
245    pub fn with_algorithm(mut self, algorithm: FewShotAlgorithm) -> Self {
246        self.config.algorithm = algorithm;
247        self
248    }
249
250    /// Replace the `k_shot` (minimum support-set size).
251    pub fn with_k_shot(mut self, k: usize) -> Self {
252        self.config.k_shot = k;
253        self
254    }
255
256    /// Replace the distance metric.
257    pub fn with_distance_metric(mut self, metric: DistanceMetric) -> Self {
258        self.config.distance_metric = metric;
259        self
260    }
261
262    /// Replace the softmax temperature used by Matching Networks.
263    pub fn with_temperature(mut self, t: f64) -> Self {
264        self.config.temperature = t;
265        self
266    }
267
268    /// Replace the deterministic seed used for stochastic components.
269    pub fn with_seed(mut self, seed: u64) -> Self {
270        self.config.seed = seed;
271        self
272    }
273
274    /// Borrow the current support set.
275    pub fn support_set(&self) -> &[ArchitectureExample] {
276        &self.support_set
277    }
278
279    /// Whether [`fit_from_examples`](Self::fit_from_examples) (or one of its
280    /// shortcuts) has been called successfully.
281    pub fn is_fitted(&self) -> bool {
282        self.is_fitted
283    }
284
285    /// Borrow the current configuration.
286    pub fn config(&self) -> &FewShotConfig {
287        &self.config
288    }
289
290    /// Clear the support set, adapter weights, and "fitted" flag.
291    ///
292    /// The configuration is preserved, so this is equivalent to constructing
293    /// a fresh optimizer with the same configuration.
294    pub fn reset(&mut self) {
295        self.support_set.clear();
296        self.is_fitted = false;
297        self.adapter_weights = None;
298        self.adapter_bias = None;
299    }
300
301    /// Fit the optimizer to a concrete support set.
302    ///
303    /// Validates that:
304    /// - the support set contains at least `config.k_shot` examples, and
305    /// - all embeddings share the same dimensionality, and
306    /// - all performance and embedding values are finite.
307    ///
308    /// For `MamlAdaptation` the inner-loop training is run here so that
309    /// subsequent `predict` calls are inexpensive.
310    pub fn fit_from_examples(&mut self, examples: Vec<ArchitectureExample>) -> Result<()> {
311        if examples.len() < self.config.k_shot {
312            return Err(OptimError::InvalidParameter(format!(
313                "fit_from_examples: need at least k_shot = {} examples, got {}",
314                self.config.k_shot,
315                examples.len()
316            )));
317        }
318        if examples.is_empty() {
319            return Err(OptimError::InvalidParameter(
320                "fit_from_examples: support set is empty".to_string(),
321            ));
322        }
323
324        // Check that every embedding has the same dimensionality and finite
325        // values. We reject NaN/inf eagerly to prevent silent corruption of
326        // downstream predictions.
327        let embedding_dim = examples[0].embedding.len();
328        if embedding_dim == 0 {
329            return Err(OptimError::InvalidParameter(
330                "fit_from_examples: embeddings must be non-empty".to_string(),
331            ));
332        }
333        for (i, ex) in examples.iter().enumerate() {
334            if ex.embedding.len() != embedding_dim {
335                return Err(OptimError::InvalidParameter(format!(
336                    "fit_from_examples: example {} has embedding dim {}, expected {}",
337                    i,
338                    ex.embedding.len(),
339                    embedding_dim
340                )));
341            }
342            if !ex.performance.is_finite() {
343                return Err(OptimError::InvalidParameter(format!(
344                    "fit_from_examples: example {} performance is not finite: {}",
345                    i, ex.performance
346                )));
347            }
348            if ex.embedding.iter().any(|v| !v.is_finite()) {
349                return Err(OptimError::InvalidParameter(format!(
350                    "fit_from_examples: example {} has non-finite embedding entries",
351                    i
352                )));
353            }
354        }
355
356        if self.config.n_way == 0 {
357            return Err(OptimError::InvalidParameter(
358                "fit_from_examples: n_way must be at least 1".to_string(),
359            ));
360        }
361
362        self.support_set = examples;
363
364        // Train the MAML adapter eagerly. For every other algorithm the
365        // adapter is left unset, so a stale adapter from a previous fit
366        // does not bleed into the new predictions.
367        if matches!(self.config.algorithm, FewShotAlgorithm::MamlAdaptation) {
368            self.train_maml_adapter()?;
369        } else {
370            self.adapter_weights = None;
371            self.adapter_bias = None;
372        }
373
374        self.is_fitted = true;
375        Ok(())
376    }
377
378    /// Fit from the top-`k` architectures of `source_domain` inside `graph`.
379    ///
380    /// Architectures without a [`PerformanceRecord`] are skipped. The
381    /// remaining nodes are sorted descending by `accuracy` and the top
382    /// `k` are used as the support set. If `k` is smaller than
383    /// `config.k_shot`, fitting still fails because the underlying
384    /// `fit_from_examples` call enforces the lower bound.
385    pub fn fit_from_graph(
386        &mut self,
387        graph: &ArchitectureKnowledgeGraph,
388        source_domain: &str,
389        k: usize,
390    ) -> Result<()> {
391        if k == 0 {
392            return Err(OptimError::InvalidParameter(
393                "fit_from_graph: k must be greater than 0".to_string(),
394            ));
395        }
396
397        // Gather all (node, performance) candidates from the source domain
398        // that have a recorded performance. Cloning a `PerformanceRecord`
399        // is cheap (small struct).
400        let mut candidates: Vec<(NodeId, PerformanceRecord)> = graph
401            .nodes()
402            .iter()
403            .filter(|n| n.domain == source_domain)
404            .filter_map(|n| n.performance.as_ref().map(|p| (n.id, p.clone())))
405            .collect();
406
407        if candidates.is_empty() {
408            return Err(OptimError::InvalidParameter(format!(
409                "fit_from_graph: no nodes with performance records in domain '{}'",
410                source_domain
411            )));
412        }
413
414        candidates.sort_by(|a, b| {
415            b.1.accuracy
416                .partial_cmp(&a.1.accuracy)
417                .unwrap_or(std::cmp::Ordering::Equal)
418        });
419        candidates.truncate(k);
420
421        // Build `ArchitectureExample`s by copying the node embeddings out
422        // of the graph. We use `node(...)` to look up by `NodeId` rather
423        // than `nodes()[idx]` so that the API survives any future graph
424        // re-indexing.
425        let mut examples: Vec<ArchitectureExample> = Vec::with_capacity(candidates.len());
426        for (node_id, record) in candidates {
427            let node = graph.node(node_id).ok_or_else(|| {
428                OptimError::ArchitectureError(format!(
429                    "fit_from_graph: node {} disappeared while iterating",
430                    node_id
431                ))
432            })?;
433            examples.push(ArchitectureExample {
434                arch_id: node.arch_id.clone(),
435                embedding: node.embedding.clone(),
436                performance: record.accuracy,
437                domain: node.domain.clone(),
438            });
439        }
440
441        self.fit_from_examples(examples)
442    }
443
444    /// Predict the scalar performance of `query_embedding`.
445    ///
446    /// Dispatches to the algorithm selected in [`FewShotConfig::algorithm`].
447    pub fn predict(&self, query_embedding: &[f64]) -> Result<FewShotPrediction> {
448        self.check_fitted()?;
449        if query_embedding.is_empty() {
450            return Err(OptimError::InvalidParameter(
451                "predict: query embedding must be non-empty".to_string(),
452            ));
453        }
454        let expected_dim = self.support_set[0].embedding.len();
455        if query_embedding.len() != expected_dim {
456            return Err(OptimError::InvalidParameter(format!(
457                "predict: query has {} dims, support uses {}",
458                query_embedding.len(),
459                expected_dim
460            )));
461        }
462        if query_embedding.iter().any(|v| !v.is_finite()) {
463            return Err(OptimError::InvalidParameter(
464                "predict: query embedding contains non-finite entries".to_string(),
465            ));
466        }
467
468        match self.config.algorithm {
469            FewShotAlgorithm::Prototypical => self.predict_prototypical(query_embedding),
470            FewShotAlgorithm::Matching => self.predict_matching(query_embedding),
471            FewShotAlgorithm::MamlAdaptation => self.predict_maml(query_embedding),
472            FewShotAlgorithm::DistanceWeightedKnn => self.predict_knn(query_embedding),
473        }
474    }
475
476    /// Batch wrapper that calls [`predict`](Self::predict) on each query.
477    ///
478    /// The errors propagated by individual queries are returned as soon as
479    /// they occur — there is no partial-result mode.
480    pub fn predict_batch(&self, queries: &[Vec<f64>]) -> Result<Vec<FewShotPrediction>> {
481        let mut out = Vec::with_capacity(queries.len());
482        for q in queries {
483            out.push(self.predict(q)?);
484        }
485        Ok(out)
486    }
487
488    /// Return the `top_k` candidates by predicted performance.
489    ///
490    /// Each candidate is supplied as a `(arch_id, embedding)` pair. The
491    /// result is sorted descending by `predicted_performance`. Candidates
492    /// whose predictions error out cause the whole call to fail.
493    pub fn recommend_top_k(
494        &self,
495        candidates: &[(&str, Vec<f64>)],
496        top_k: usize,
497    ) -> Result<Vec<(String, FewShotPrediction)>> {
498        self.check_fitted()?;
499        if top_k == 0 {
500            return Ok(Vec::new());
501        }
502        let mut scored: Vec<(String, FewShotPrediction)> = Vec::with_capacity(candidates.len());
503        for (arch_id, emb) in candidates {
504            let prediction = self.predict(emb)?;
505            scored.push(((*arch_id).to_string(), prediction));
506        }
507        scored.sort_by(|a, b| {
508            b.1.predicted_performance
509                .partial_cmp(&a.1.predicted_performance)
510                .unwrap_or(std::cmp::Ordering::Equal)
511        });
512        scored.truncate(top_k);
513        Ok(scored)
514    }
515
516    // ---- internal helpers -------------------------------------------------
517
518    /// Ensure the optimizer has been fitted; otherwise return
519    /// [`OptimError::InvalidParameter`] (used in lieu of `InvalidState`,
520    /// which optirs-nas does not have).
521    fn check_fitted(&self) -> Result<()> {
522        if !self.is_fitted || self.support_set.is_empty() {
523            return Err(OptimError::InvalidParameter(
524                "not fitted: call fit_from_examples or fit_from_graph first".to_string(),
525            ));
526        }
527        Ok(())
528    }
529
530    /// Compute the configured distance between two equally-sized embeddings.
531    ///
532    /// Returns [`OptimError::InvalidParameter`] for mismatched lengths and
533    /// non-finite distances. Cosine distance is clipped to `[0, 2]` to
534    /// match the mathematical definition; zero-norm vectors return the
535    /// neutral distance `1.0` (orthogonal).
536    fn distance(&self, a: &[f64], b: &[f64]) -> Result<f64> {
537        if a.len() != b.len() {
538            return Err(OptimError::InvalidParameter(format!(
539                "distance: length mismatch {} vs {}",
540                a.len(),
541                b.len()
542            )));
543        }
544        let d = match self.config.distance_metric {
545            DistanceMetric::Euclidean => {
546                let mut s = 0.0;
547                for (x, y) in a.iter().zip(b.iter()) {
548                    let diff = x - y;
549                    s += diff * diff;
550                }
551                s.sqrt()
552            }
553            DistanceMetric::Manhattan => {
554                let mut s = 0.0;
555                for (x, y) in a.iter().zip(b.iter()) {
556                    s += (x - y).abs();
557                }
558                s
559            }
560            DistanceMetric::Cosine => {
561                let mut dot = 0.0;
562                let mut norm_a = 0.0;
563                let mut norm_b = 0.0;
564                for (x, y) in a.iter().zip(b.iter()) {
565                    dot += x * y;
566                    norm_a += x * x;
567                    norm_b += y * y;
568                }
569                if norm_a <= 0.0 || norm_b <= 0.0 {
570                    // Treat zero-norm as orthogonal to avoid NaN downstream.
571                    1.0
572                } else {
573                    let cos = (dot / (norm_a.sqrt() * norm_b.sqrt())).clamp(-1.0, 1.0);
574                    (1.0 - cos).clamp(0.0, 2.0)
575                }
576            }
577        };
578        if !d.is_finite() {
579            return Err(OptimError::InvalidParameter(format!(
580                "distance: produced non-finite result {}",
581                d
582            )));
583        }
584        Ok(d)
585    }
586
587    /// Find the (index, distance) of the support example closest to `query`.
588    fn nearest_support(&self, query: &[f64]) -> Result<(usize, f64)> {
589        let mut best_idx = 0usize;
590        let mut best_d = f64::INFINITY;
591        for (i, ex) in self.support_set.iter().enumerate() {
592            let d = self.distance(query, &ex.embedding)?;
593            if d < best_d {
594                best_d = d;
595                best_idx = i;
596            }
597        }
598        Ok((best_idx, best_d))
599    }
600
601    /// Numerically stable softmax over `-logits / temperature`.
602    ///
603    /// The standard `softmax = exp(x_i) / Σ exp(x_j)` formula overflows for
604    /// large negative `-d / T`. Subtracting the maximum logit before the
605    /// exponentiation gives the same answer without overflow, and the
606    /// constant `T` is folded into the scaling step.
607    fn softmax_neg(distances: &[f64], temperature: f64) -> Vec<f64> {
608        if distances.is_empty() {
609            return Vec::new();
610        }
611        let t = if temperature.is_finite() && temperature > 0.0 {
612            temperature
613        } else {
614            1.0
615        };
616        // logits_i = -d_i / t — find max for stability.
617        let mut max_logit = f64::NEG_INFINITY;
618        for d in distances {
619            let l = -d / t;
620            if l > max_logit {
621                max_logit = l;
622            }
623        }
624        if !max_logit.is_finite() {
625            // All logits were -inf (shouldn't happen with finite distances,
626            // but defensively return a uniform distribution).
627            let u = 1.0 / distances.len() as f64;
628            return vec![u; distances.len()];
629        }
630        let exps: Vec<f64> = distances
631            .iter()
632            .map(|d| ((-d / t) - max_logit).exp())
633            .collect();
634        let sum: f64 = exps.iter().sum();
635        if sum <= 0.0 || !sum.is_finite() {
636            let u = 1.0 / distances.len() as f64;
637            return vec![u; distances.len()];
638        }
639        exps.into_iter().map(|e| e / sum).collect()
640    }
641
642    /// Entropy `H(p) = -Σ p_i log(p_i)` with the convention `0 log 0 = 0`.
643    fn entropy(p: &[f64]) -> f64 {
644        let mut h = 0.0;
645        for &pi in p {
646            if pi > 0.0 {
647                h -= pi * pi.ln();
648            }
649        }
650        h
651    }
652
653    // ---- Prototypical Networks ------------------------------------------
654
655    /// Bucket the support set into `n_way` equal-width performance buckets
656    /// and return, for each non-empty bucket, the mean embedding (as the
657    /// prototype) and the mean performance (as the prediction). Buckets
658    /// are derived by *equal-width binning of values*: we find `[min, max]`
659    /// over performances and split the range into `n_way` chunks. The
660    /// alternative (equal-frequency binning) often collapses repeated
661    /// scores into the same bin, which is undesirable when the support set
662    /// is small.
663    fn prototype_buckets(&self) -> Vec<(Vec<f64>, f64)> {
664        let n_way = self.config.n_way.max(1);
665        let min_perf = self
666            .support_set
667            .iter()
668            .map(|e| e.performance)
669            .fold(f64::INFINITY, f64::min);
670        let max_perf = self
671            .support_set
672            .iter()
673            .map(|e| e.performance)
674            .fold(f64::NEG_INFINITY, f64::max);
675        let embedding_dim = self.support_set[0].embedding.len();
676
677        // Degenerate case: all performances equal. Collapse to a single
678        // bucket containing every example.
679        if (max_perf - min_perf).abs() < 1e-12 || n_way == 1 {
680            let mut proto = vec![0.0; embedding_dim];
681            for ex in &self.support_set {
682                for (slot, v) in proto.iter_mut().zip(ex.embedding.iter()) {
683                    *slot += v;
684                }
685            }
686            for slot in proto.iter_mut() {
687                *slot /= self.support_set.len() as f64;
688            }
689            return vec![(proto, min_perf)];
690        }
691
692        // Bin width over the closed [min, max] range, with a tiny epsilon
693        // pushed into the max bin so that `max_perf` itself lands in the
694        // last bucket rather than `n_way` (which would be out of range).
695        let width = (max_perf - min_perf) / n_way as f64;
696        let mut sums: Vec<Vec<f64>> = vec![vec![0.0; embedding_dim]; n_way];
697        let mut perf_sums: Vec<f64> = vec![0.0; n_way];
698        let mut counts: Vec<usize> = vec![0; n_way];
699
700        for ex in &self.support_set {
701            let mut bin = ((ex.performance - min_perf) / width).floor() as isize;
702            if bin < 0 {
703                bin = 0;
704            }
705            if bin >= n_way as isize {
706                bin = (n_way - 1) as isize;
707            }
708            let b = bin as usize;
709            for (slot, v) in sums[b].iter_mut().zip(ex.embedding.iter()) {
710                *slot += v;
711            }
712            perf_sums[b] += ex.performance;
713            counts[b] += 1;
714        }
715
716        let mut buckets = Vec::new();
717        for b in 0..n_way {
718            if counts[b] == 0 {
719                continue;
720            }
721            let c = counts[b] as f64;
722            let proto: Vec<f64> = sums[b].iter().map(|v| v / c).collect();
723            buckets.push((proto, perf_sums[b] / c));
724        }
725        buckets
726    }
727
728    fn predict_prototypical(&self, query: &[f64]) -> Result<FewShotPrediction> {
729        let buckets = self.prototype_buckets();
730        if buckets.is_empty() {
731            return Err(OptimError::ConvergenceFailure(
732                "predict_prototypical: no non-empty prototype buckets".to_string(),
733            ));
734        }
735        let distances: Vec<f64> = buckets
736            .iter()
737            .map(|(proto, _)| self.distance(query, proto))
738            .collect::<Result<Vec<_>>>()?;
739        // Pick the nearest bucket as the prediction.
740        let mut best_idx = 0;
741        let mut best_d = f64::INFINITY;
742        for (i, &d) in distances.iter().enumerate() {
743            if d < best_d {
744                best_d = d;
745                best_idx = i;
746            }
747        }
748        let weights = Self::softmax_neg(&distances, self.config.temperature);
749        let confidence = weights
750            .get(best_idx)
751            .copied()
752            .unwrap_or(0.0)
753            .clamp(0.0, 1.0);
754
755        let (nearest_idx, nearest_d) = self.nearest_support(query)?;
756        let nearest = &self.support_set[nearest_idx];
757
758        Ok(FewShotPrediction {
759            predicted_performance: buckets[best_idx].1,
760            confidence,
761            nearest_support_arch: Some(nearest.arch_id.clone()),
762            nearest_distance: nearest_d,
763        })
764    }
765
766    // ---- Matching Networks ----------------------------------------------
767
768    fn predict_matching(&self, query: &[f64]) -> Result<FewShotPrediction> {
769        let distances: Vec<f64> = self
770            .support_set
771            .iter()
772            .map(|ex| self.distance(query, &ex.embedding))
773            .collect::<Result<Vec<_>>>()?;
774        let weights = Self::softmax_neg(&distances, self.config.temperature);
775        let predicted: f64 = weights
776            .iter()
777            .zip(self.support_set.iter())
778            .map(|(w, ex)| w * ex.performance)
779            .sum();
780
781        // Confidence: `1 - H(p) / log(n)` so a uniform distribution gives 0
782        // (no information) and a one-hot distribution gives 1 (full
783        // information). With n = 1 we are by definition fully confident.
784        let n = weights.len();
785        let confidence = if n <= 1 {
786            1.0
787        } else {
788            let h = Self::entropy(&weights);
789            let log_n = (n as f64).ln();
790            if log_n > 0.0 {
791                (1.0 - h / log_n).clamp(0.0, 1.0)
792            } else {
793                1.0
794            }
795        };
796
797        let (nearest_idx, nearest_d) = self.nearest_support(query)?;
798        let nearest = &self.support_set[nearest_idx];
799
800        Ok(FewShotPrediction {
801            predicted_performance: predicted,
802            confidence,
803            nearest_support_arch: Some(nearest.arch_id.clone()),
804            nearest_distance: nearest_d,
805        })
806    }
807
808    // ---- MAML linear adapter --------------------------------------------
809
810    /// Fit a one-layer linear regressor `y_hat = w · x + b` on the support
811    /// set using `config.adapter_steps` of full-batch gradient descent.
812    ///
813    /// The loss is the mean squared error
814    /// `L = (1/n) Σ_i (w · x_i + b - y_i)^2` whose gradient with respect
815    /// to `w` is `(2/n) Σ_i (w · x_i + b - y_i) · x_i` and with respect to
816    /// `b` is `(2/n) Σ_i (w · x_i + b - y_i)`. We initialise to zero so
817    /// the first iteration's prediction is purely a constant.
818    fn train_maml_adapter(&mut self) -> Result<()> {
819        let dim = self.support_set[0].embedding.len();
820        let n = self.support_set.len() as f64;
821        let lr = if self.config.adapter_lr.is_finite() && self.config.adapter_lr > 0.0 {
822            self.config.adapter_lr
823        } else {
824            1e-2
825        };
826        let steps = self.config.adapter_steps.max(1);
827
828        // Seed the random generator for any future stochastic extensions.
829        // Currently the inner loop is deterministic; the `_rng` binding is
830        // retained so that mini-batch variants can drop in later without
831        // changing the signature.
832        let _rng = Random::seed(self.config.seed);
833
834        let mut w: Array1<f64> = Array1::zeros(dim);
835        let mut b = 0.0_f64;
836
837        for _ in 0..steps {
838            let mut grad_w: Array1<f64> = Array1::zeros(dim);
839            let mut grad_b = 0.0_f64;
840            for ex in &self.support_set {
841                let x = Array1::from(ex.embedding.clone());
842                let y_hat = w.dot(&x) + b;
843                let err = y_hat - ex.performance;
844                // grad_w += 2 * err * x (averaged outside the loop).
845                for i in 0..dim {
846                    grad_w[i] += 2.0 * err * x[i];
847                }
848                grad_b += 2.0 * err;
849            }
850            for i in 0..dim {
851                grad_w[i] /= n;
852            }
853            grad_b /= n;
854            for i in 0..dim {
855                w[i] -= lr * grad_w[i];
856            }
857            b -= lr * grad_b;
858        }
859
860        self.adapter_weights = Some(w.to_vec());
861        self.adapter_bias = Some(b);
862        Ok(())
863    }
864
865    fn predict_maml(&self, query: &[f64]) -> Result<FewShotPrediction> {
866        let w = self.adapter_weights.as_ref().ok_or_else(|| {
867            OptimError::InvalidParameter(
868                "predict_maml: adapter not trained — refit with FewShotAlgorithm::MamlAdaptation"
869                    .to_string(),
870            )
871        })?;
872        let b = self.adapter_bias.unwrap_or(0.0);
873        if w.len() != query.len() {
874            return Err(OptimError::InvalidParameter(format!(
875                "predict_maml: adapter dim {} vs query dim {}",
876                w.len(),
877                query.len()
878            )));
879        }
880        let predicted: f64 = w
881            .iter()
882            .zip(query.iter())
883            .map(|(wi, xi)| wi * xi)
884            .sum::<f64>()
885            + b;
886
887        // Confidence: scale by distance to the nearest support example.
888        // We map `d -> exp(-d / mean_d)` so a query that exactly coincides
889        // with a support example gets confidence ≈ 1, and far-away queries
890        // approach 0.
891        let (nearest_idx, nearest_d) = self.nearest_support(query)?;
892        let all_d: Vec<f64> = self
893            .support_set
894            .iter()
895            .map(|ex| self.distance(query, &ex.embedding))
896            .collect::<Result<Vec<_>>>()?;
897        let mean_d = if all_d.is_empty() {
898            1.0
899        } else {
900            let s: f64 = all_d.iter().sum();
901            (s / all_d.len() as f64).max(1e-12)
902        };
903        let confidence = ((-nearest_d / mean_d).exp()).clamp(0.0, 1.0);
904
905        let nearest = &self.support_set[nearest_idx];
906        Ok(FewShotPrediction {
907            predicted_performance: predicted,
908            confidence,
909            nearest_support_arch: Some(nearest.arch_id.clone()),
910            nearest_distance: nearest_d,
911        })
912    }
913
914    // ---- Distance-weighted KNN ------------------------------------------
915
916    fn predict_knn(&self, query: &[f64]) -> Result<FewShotPrediction> {
917        let mut distances: Vec<(usize, f64)> = Vec::with_capacity(self.support_set.len());
918        for (i, ex) in self.support_set.iter().enumerate() {
919            let d = self.distance(query, &ex.embedding)?;
920            distances.push((i, d));
921        }
922        distances.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
923
924        let k = self.config.k_shot.min(distances.len()).max(1);
925        let neighbours = &distances[..k];
926
927        // If the closest neighbour is essentially identical, just return
928        // its performance directly. This avoids `1 / (d + eps)` numerical
929        // weirdness from amplifying tiny-distance noise into the result.
930        if neighbours[0].1 < 1e-12 {
931            let nearest = &self.support_set[neighbours[0].0];
932            return Ok(FewShotPrediction {
933                predicted_performance: nearest.performance,
934                confidence: 1.0,
935                nearest_support_arch: Some(nearest.arch_id.clone()),
936                nearest_distance: neighbours[0].1,
937            });
938        }
939
940        let eps = 1e-9;
941        let weights: Vec<f64> = neighbours.iter().map(|(_, d)| 1.0 / (d + eps)).collect();
942        let weight_sum: f64 = weights.iter().sum();
943        let predicted: f64 = if weight_sum > 0.0 {
944            neighbours
945                .iter()
946                .zip(weights.iter())
947                .map(|((idx, _), w)| w * self.support_set[*idx].performance)
948                .sum::<f64>()
949                / weight_sum
950        } else {
951            // Fall back to mean if all weights collapsed to zero.
952            let mean = neighbours
953                .iter()
954                .map(|(idx, _)| self.support_set[*idx].performance)
955                .sum::<f64>()
956                / neighbours.len() as f64;
957            mean
958        };
959
960        // Confidence: `1 - mean(neighbour_d) / max_d_across_support`. When
961        // the support is tightly packed around the query we get a high
962        // confidence; when neighbours are at the far end of the support
963        // range we get a low confidence.
964        let mean_neighbour_d: f64 =
965            neighbours.iter().map(|(_, d)| d).sum::<f64>() / neighbours.len() as f64;
966        let max_support_d = distances
967            .iter()
968            .map(|(_, d)| *d)
969            .fold(0.0_f64, f64::max)
970            .max(1e-12);
971        let confidence = (1.0 - mean_neighbour_d / max_support_d).clamp(0.0, 1.0);
972
973        let nearest_idx = neighbours[0].0;
974        let nearest_d = neighbours[0].1;
975        let nearest = &self.support_set[nearest_idx];
976
977        Ok(FewShotPrediction {
978            predicted_performance: predicted,
979            confidence,
980            nearest_support_arch: Some(nearest.arch_id.clone()),
981            nearest_distance: nearest_d,
982        })
983    }
984}
985
986// Public helper: build a similarity matrix for diagnostics. Not strictly
987// required by any algorithm but useful for callers inspecting the support
988// set's geometry. Kept here (rather than `architecture_knowledge_graph`)
989// because it depends on the per-optimizer distance metric configuration.
990impl FewShotArchitectureOptimizer {
991    /// Pairwise distance matrix `D[i][j]` over the current support set
992    /// under the configured metric.
993    ///
994    /// Returns [`OptimError::InvalidParameter`] when the optimizer has not
995    /// been fitted yet, mirroring `predict`'s contract.
996    pub fn support_distance_matrix(&self) -> Result<Array2<f64>> {
997        self.check_fitted()?;
998        let n = self.support_set.len();
999        let mut m: Array2<f64> = Array2::zeros((n, n));
1000        for i in 0..n {
1001            for j in (i + 1)..n {
1002                let d = self.distance(
1003                    &self.support_set[i].embedding,
1004                    &self.support_set[j].embedding,
1005                )?;
1006                m[[i, j]] = d;
1007                m[[j, i]] = d;
1008            }
1009        }
1010        Ok(m)
1011    }
1012}
1013
1014#[cfg(test)]
1015mod tests {
1016    use super::*;
1017    use crate::architecture_knowledge_graph::{ArchitectureKnowledgeGraph, RelationType};
1018
1019    fn example(arch_id: &str, embedding: Vec<f64>, performance: f64) -> ArchitectureExample {
1020        ArchitectureExample {
1021            arch_id: arch_id.to_string(),
1022            embedding,
1023            performance,
1024            domain: "vision".to_string(),
1025        }
1026    }
1027
1028    fn make_support_set() -> Vec<ArchitectureExample> {
1029        vec![
1030            example("a", vec![1.0, 0.0, 0.0], 0.90),
1031            example("b", vec![0.9, 0.1, 0.0], 0.85),
1032            example("c", vec![0.0, 1.0, 0.0], 0.50),
1033            example("d", vec![0.0, 0.9, 0.1], 0.45),
1034            example("e", vec![0.1, 0.1, 0.9], 0.20),
1035        ]
1036    }
1037
1038    #[test]
1039    fn test_default_config_values() {
1040        let c = FewShotConfig::default();
1041        assert_eq!(c.algorithm, FewShotAlgorithm::Prototypical);
1042        assert_eq!(c.k_shot, 5);
1043        assert_eq!(c.n_way, 2);
1044        assert!((c.temperature - 1.0).abs() < 1e-12);
1045        assert!((c.adapter_lr - 0.01).abs() < 1e-12);
1046        assert_eq!(c.adapter_steps, 5);
1047        assert_eq!(c.distance_metric, DistanceMetric::Euclidean);
1048        assert_eq!(c.seed, 42);
1049    }
1050
1051    #[test]
1052    fn test_builder_pattern_chains() {
1053        let opt = FewShotArchitectureOptimizer::new()
1054            .with_algorithm(FewShotAlgorithm::Matching)
1055            .with_k_shot(3)
1056            .with_distance_metric(DistanceMetric::Cosine)
1057            .with_temperature(0.5)
1058            .with_seed(7);
1059        assert_eq!(opt.config().algorithm, FewShotAlgorithm::Matching);
1060        assert_eq!(opt.config().k_shot, 3);
1061        assert_eq!(opt.config().distance_metric, DistanceMetric::Cosine);
1062        assert!((opt.config().temperature - 0.5).abs() < 1e-12);
1063        assert_eq!(opt.config().seed, 7);
1064        // Initial state.
1065        assert!(!opt.is_fitted());
1066        assert!(opt.support_set().is_empty());
1067    }
1068
1069    #[test]
1070    fn test_fit_from_examples_validates_min_size() {
1071        let mut opt = FewShotArchitectureOptimizer::new().with_k_shot(5);
1072        let examples = vec![example("a", vec![1.0, 0.0], 0.5)];
1073        let err = opt.fit_from_examples(examples);
1074        assert!(err.is_err(), "expected InvalidParameter for too-small set");
1075        match err {
1076            Err(OptimError::InvalidParameter(_)) => {}
1077            other => panic!("expected InvalidParameter, got {:?}", other),
1078        }
1079        assert!(!opt.is_fitted());
1080    }
1081
1082    #[test]
1083    fn test_fit_from_examples_rejects_dim_mismatch() {
1084        let mut opt = FewShotArchitectureOptimizer::new().with_k_shot(2);
1085        let examples = vec![
1086            example("a", vec![1.0, 0.0], 0.5),
1087            example("b", vec![1.0, 0.0, 0.0], 0.6),
1088        ];
1089        assert!(opt.fit_from_examples(examples).is_err());
1090    }
1091
1092    #[test]
1093    fn test_fit_from_examples_rejects_non_finite() {
1094        let mut opt = FewShotArchitectureOptimizer::new().with_k_shot(2);
1095        let examples = vec![
1096            example("a", vec![1.0, f64::NAN], 0.5),
1097            example("b", vec![1.0, 0.0], 0.6),
1098        ];
1099        assert!(opt.fit_from_examples(examples).is_err());
1100    }
1101
1102    #[test]
1103    fn test_prototypical_predicts_high_for_similar_to_high_performers() {
1104        let mut opt = FewShotArchitectureOptimizer::new()
1105            .with_algorithm(FewShotAlgorithm::Prototypical)
1106            .with_k_shot(5);
1107        opt.fit_from_examples(make_support_set()).expect("fit");
1108
1109        // Query closer to high-performance bucket ([1, 0, 0]) should give
1110        // a higher predicted performance than a query closer to the
1111        // low-performance bucket ([0.1, 0.1, 0.9]).
1112        let p_high = opt.predict(&[0.95, 0.05, 0.0]).expect("predict high");
1113        let p_low = opt.predict(&[0.1, 0.1, 0.9]).expect("predict low");
1114        assert!(
1115            p_high.predicted_performance > p_low.predicted_performance,
1116            "high query should outscore low: {} vs {}",
1117            p_high.predicted_performance,
1118            p_low.predicted_performance
1119        );
1120        assert!(p_high.confidence >= 0.0 && p_high.confidence <= 1.0);
1121        assert!(p_high.nearest_support_arch.is_some());
1122    }
1123
1124    #[test]
1125    fn test_matching_networks_temperature_affects_sharpness() {
1126        // Lower temperature -> sharper distribution -> higher confidence.
1127        let support = make_support_set();
1128        let mut opt_low = FewShotArchitectureOptimizer::new()
1129            .with_algorithm(FewShotAlgorithm::Matching)
1130            .with_temperature(0.1)
1131            .with_k_shot(5);
1132        opt_low.fit_from_examples(support.clone()).expect("fit low");
1133        let mut opt_high = FewShotArchitectureOptimizer::new()
1134            .with_algorithm(FewShotAlgorithm::Matching)
1135            .with_temperature(10.0)
1136            .with_k_shot(5);
1137        opt_high.fit_from_examples(support).expect("fit high");
1138
1139        let q = vec![1.0, 0.0, 0.0];
1140        let p_low = opt_low.predict(&q).expect("predict low T");
1141        let p_high = opt_high.predict(&q).expect("predict high T");
1142        assert!(
1143            p_low.confidence > p_high.confidence,
1144            "low temperature should be sharper: {} vs {}",
1145            p_low.confidence,
1146            p_high.confidence
1147        );
1148    }
1149
1150    #[test]
1151    fn test_maml_adapter_decreases_training_loss() {
1152        let mut opt = FewShotArchitectureOptimizer::new()
1153            .with_algorithm(FewShotAlgorithm::MamlAdaptation)
1154            .with_k_shot(5);
1155        let support = make_support_set();
1156        // MSE of the "always predict zero" baseline.
1157        let baseline_mse: f64 =
1158            support.iter().map(|ex| ex.performance.powi(2)).sum::<f64>() / support.len() as f64;
1159
1160        opt.fit_from_examples(support.clone()).expect("fit");
1161        // MSE of MAML predictions on the training set.
1162        let trained_mse: f64 = support
1163            .iter()
1164            .map(|ex| {
1165                let pred = opt
1166                    .predict(&ex.embedding)
1167                    .expect("predict")
1168                    .predicted_performance;
1169                (pred - ex.performance).powi(2)
1170            })
1171            .sum::<f64>()
1172            / support.len() as f64;
1173        assert!(
1174            trained_mse < baseline_mse,
1175            "MAML should drop MSE below zero-baseline: {} vs {}",
1176            trained_mse,
1177            baseline_mse
1178        );
1179    }
1180
1181    #[test]
1182    fn test_maml_adapter_state_present_after_fit() {
1183        let mut opt = FewShotArchitectureOptimizer::new()
1184            .with_algorithm(FewShotAlgorithm::MamlAdaptation)
1185            .with_k_shot(5);
1186        opt.fit_from_examples(make_support_set()).expect("fit");
1187        assert!(opt.adapter_weights.is_some());
1188        assert!(opt.adapter_bias.is_some());
1189        assert_eq!(
1190            opt.adapter_weights.as_ref().expect("weights").len(),
1191            3,
1192            "adapter dim must match embedding dim"
1193        );
1194    }
1195
1196    #[test]
1197    fn test_knn_predict_with_exact_match_returns_exact_performance() {
1198        let mut opt = FewShotArchitectureOptimizer::new()
1199            .with_algorithm(FewShotAlgorithm::DistanceWeightedKnn)
1200            .with_k_shot(3);
1201        let support = make_support_set();
1202        opt.fit_from_examples(support.clone()).expect("fit");
1203
1204        // Query exactly matches the "a" example whose performance is 0.90.
1205        let pred = opt.predict(&[1.0, 0.0, 0.0]).expect("predict");
1206        assert!(
1207            (pred.predicted_performance - 0.90).abs() < 1e-9,
1208            "exact match should return exact performance, got {}",
1209            pred.predicted_performance
1210        );
1211        assert!(
1212            (pred.confidence - 1.0).abs() < 1e-9,
1213            "confidence on exact match should be 1.0, got {}",
1214            pred.confidence
1215        );
1216        assert_eq!(pred.nearest_support_arch.as_deref(), Some("a"));
1217    }
1218
1219    #[test]
1220    fn test_recommend_top_k_returns_sorted_descending() {
1221        let mut opt = FewShotArchitectureOptimizer::new()
1222            .with_algorithm(FewShotAlgorithm::Matching)
1223            .with_k_shot(5);
1224        opt.fit_from_examples(make_support_set()).expect("fit");
1225
1226        let candidates: Vec<(&str, Vec<f64>)> = vec![
1227            ("low-perf", vec![0.1, 0.1, 0.9]),
1228            ("mid-perf", vec![0.5, 0.5, 0.0]),
1229            ("high-perf", vec![1.0, 0.0, 0.0]),
1230            ("med-low", vec![0.0, 0.9, 0.1]),
1231        ];
1232        let ranked = opt.recommend_top_k(&candidates, 4).expect("top_k");
1233        assert_eq!(ranked.len(), 4);
1234        for window in ranked.windows(2) {
1235            assert!(
1236                window[0].1.predicted_performance >= window[1].1.predicted_performance,
1237                "ranking not descending: {} then {}",
1238                window[0].1.predicted_performance,
1239                window[1].1.predicted_performance
1240            );
1241        }
1242        // The highest-ranked candidate should be the one closest to the
1243        // high-performance bucket.
1244        assert_eq!(ranked[0].0, "high-perf");
1245    }
1246
1247    #[test]
1248    fn test_recommend_top_k_truncates_to_top_k() {
1249        let mut opt = FewShotArchitectureOptimizer::new()
1250            .with_algorithm(FewShotAlgorithm::Matching)
1251            .with_k_shot(5);
1252        opt.fit_from_examples(make_support_set()).expect("fit");
1253
1254        let candidates: Vec<(&str, Vec<f64>)> = vec![
1255            ("a", vec![1.0, 0.0, 0.0]),
1256            ("b", vec![0.9, 0.1, 0.0]),
1257            ("c", vec![0.0, 1.0, 0.0]),
1258            ("d", vec![0.5, 0.5, 0.0]),
1259            ("e", vec![0.0, 0.0, 1.0]),
1260        ];
1261        let top2 = opt.recommend_top_k(&candidates, 2).expect("top_k=2");
1262        assert_eq!(top2.len(), 2);
1263        // top_k=0 returns empty.
1264        let empty = opt.recommend_top_k(&candidates, 0).expect("top_k=0");
1265        assert!(empty.is_empty());
1266        // top_k bigger than candidate count keeps all.
1267        let all = opt.recommend_top_k(&candidates, 100).expect("top_k=100");
1268        assert_eq!(all.len(), candidates.len());
1269    }
1270
1271    #[test]
1272    fn test_fit_from_graph_selects_top_performers() {
1273        let mut graph = ArchitectureKnowledgeGraph::new();
1274        // Insert 10 architectures with monotonically increasing accuracy.
1275        let mut ids = Vec::new();
1276        for i in 0..10 {
1277            let id = graph.add_architecture(
1278                format!("arch-{}", i),
1279                vec![i as f64 / 10.0, 0.0, 0.0],
1280                "vision",
1281            );
1282            graph
1283                .set_performance(
1284                    id,
1285                    PerformanceRecord {
1286                        task_id: "task".into(),
1287                        accuracy: 0.10 * (i + 1) as f64,
1288                        latency_ms: 1.0,
1289                        memory_mb: 1.0,
1290                        training_epochs: 1,
1291                    },
1292                )
1293                .expect("set_performance");
1294            ids.push(id);
1295        }
1296        // Also add an unrelated nlp node that must not leak in.
1297        graph.add_architecture("nlp-a", vec![1.0, 1.0, 1.0], "nlp");
1298
1299        let mut opt = FewShotArchitectureOptimizer::new()
1300            .with_algorithm(FewShotAlgorithm::DistanceWeightedKnn)
1301            .with_k_shot(3);
1302        opt.fit_from_graph(&graph, "vision", 3)
1303            .expect("fit_from_graph");
1304        assert!(opt.is_fitted());
1305        let support = opt.support_set();
1306        assert_eq!(support.len(), 3);
1307        // Top 3 architectures by accuracy are arch-9, arch-8, arch-7 (in
1308        // descending order). Each must be present.
1309        let arch_ids: Vec<&str> = support.iter().map(|e| e.arch_id.as_str()).collect();
1310        assert!(arch_ids.contains(&"arch-9"));
1311        assert!(arch_ids.contains(&"arch-8"));
1312        assert!(arch_ids.contains(&"arch-7"));
1313        // No nlp architecture leaked in.
1314        assert!(arch_ids.iter().all(|id| !id.starts_with("nlp")));
1315    }
1316
1317    #[test]
1318    fn test_fit_from_graph_errors_on_empty_domain() {
1319        let graph = ArchitectureKnowledgeGraph::new();
1320        let mut opt = FewShotArchitectureOptimizer::new().with_k_shot(1);
1321        assert!(opt.fit_from_graph(&graph, "vision", 5).is_err());
1322    }
1323
1324    #[test]
1325    fn test_predict_before_fit_errors() {
1326        let opt = FewShotArchitectureOptimizer::new();
1327        let r = opt.predict(&[1.0, 0.0, 0.0]);
1328        assert!(r.is_err(), "predict before fit must error");
1329        match r {
1330            Err(OptimError::InvalidParameter(msg)) => assert!(msg.contains("not fitted")),
1331            other => panic!("expected InvalidParameter, got {:?}", other),
1332        }
1333    }
1334
1335    #[test]
1336    fn test_reset_clears_state() {
1337        let mut opt = FewShotArchitectureOptimizer::new()
1338            .with_algorithm(FewShotAlgorithm::MamlAdaptation)
1339            .with_k_shot(5);
1340        opt.fit_from_examples(make_support_set()).expect("fit");
1341        assert!(opt.is_fitted());
1342        assert!(!opt.support_set().is_empty());
1343        assert!(opt.adapter_weights.is_some());
1344
1345        opt.reset();
1346        assert!(!opt.is_fitted());
1347        assert!(opt.support_set().is_empty());
1348        assert!(opt.adapter_weights.is_none());
1349        assert!(opt.adapter_bias.is_none());
1350    }
1351
1352    #[test]
1353    fn test_distance_metrics_produce_different_predictions() {
1354        let support = make_support_set();
1355        let mut opt_euc = FewShotArchitectureOptimizer::new()
1356            .with_algorithm(FewShotAlgorithm::Matching)
1357            .with_distance_metric(DistanceMetric::Euclidean)
1358            .with_k_shot(5);
1359        opt_euc.fit_from_examples(support.clone()).expect("fit euc");
1360
1361        let mut opt_cos = FewShotArchitectureOptimizer::new()
1362            .with_algorithm(FewShotAlgorithm::Matching)
1363            .with_distance_metric(DistanceMetric::Cosine)
1364            .with_k_shot(5);
1365        opt_cos.fit_from_examples(support.clone()).expect("fit cos");
1366
1367        let mut opt_man = FewShotArchitectureOptimizer::new()
1368            .with_algorithm(FewShotAlgorithm::Matching)
1369            .with_distance_metric(DistanceMetric::Manhattan)
1370            .with_k_shot(5);
1371        opt_man.fit_from_examples(support).expect("fit man");
1372
1373        let q = vec![0.5, 0.5, 0.5];
1374        let p_euc = opt_euc.predict(&q).expect("euc predict");
1375        let p_cos = opt_cos.predict(&q).expect("cos predict");
1376        let p_man = opt_man.predict(&q).expect("man predict");
1377
1378        // Euclidean and Cosine should yield different numbers on a non-
1379        // axis-aligned query because one is scale-sensitive and the other
1380        // is not.
1381        assert!(
1382            (p_euc.predicted_performance - p_cos.predicted_performance).abs() > 1e-9,
1383            "Euclidean ({}) and Cosine ({}) must differ",
1384            p_euc.predicted_performance,
1385            p_cos.predicted_performance
1386        );
1387        // Manhattan and Euclidean on a non-axis query also produce
1388        // different shapes, so the resulting weights diverge.
1389        assert!(
1390            (p_euc.predicted_performance - p_man.predicted_performance).abs() > 1e-12
1391                || (p_euc.confidence - p_man.confidence).abs() > 1e-12,
1392            "Euclidean and Manhattan should differ in at least one summary"
1393        );
1394    }
1395
1396    #[test]
1397    fn test_fewshot_prediction_serde_roundtrip() {
1398        let pred = FewShotPrediction {
1399            predicted_performance: 0.87,
1400            confidence: 0.93,
1401            nearest_support_arch: Some("resnet50".to_string()),
1402            nearest_distance: 0.12,
1403        };
1404        let json = serde_json::to_string(&pred).expect("serialise");
1405        let back: FewShotPrediction = serde_json::from_str(&json).expect("deserialise");
1406        assert!((back.predicted_performance - pred.predicted_performance).abs() < 1e-12);
1407        assert!((back.confidence - pred.confidence).abs() < 1e-12);
1408        assert_eq!(back.nearest_support_arch, pred.nearest_support_arch);
1409        assert!((back.nearest_distance - pred.nearest_distance).abs() < 1e-12);
1410    }
1411
1412    #[test]
1413    fn test_architecture_example_serde_roundtrip() {
1414        let ex = example("resnet", vec![1.0, 2.0, 3.0], 0.91);
1415        let json = serde_json::to_string(&ex).expect("serialise");
1416        let back: ArchitectureExample = serde_json::from_str(&json).expect("deserialise");
1417        assert_eq!(back.arch_id, ex.arch_id);
1418        assert_eq!(back.embedding, ex.embedding);
1419        assert!((back.performance - ex.performance).abs() < 1e-12);
1420        assert_eq!(back.domain, ex.domain);
1421    }
1422
1423    #[test]
1424    fn test_support_distance_matrix_is_symmetric() {
1425        let mut opt = FewShotArchitectureOptimizer::new().with_k_shot(5);
1426        opt.fit_from_examples(make_support_set()).expect("fit");
1427        let m = opt.support_distance_matrix().expect("matrix");
1428        assert_eq!(m.shape(), &[5, 5]);
1429        for i in 0..5 {
1430            assert!(m[[i, i]].abs() < 1e-12, "diagonal {} must be 0", i);
1431            for j in 0..5 {
1432                assert!(
1433                    (m[[i, j]] - m[[j, i]]).abs() < 1e-12,
1434                    "matrix not symmetric at ({}, {}): {} vs {}",
1435                    i,
1436                    j,
1437                    m[[i, j]],
1438                    m[[j, i]]
1439                );
1440            }
1441        }
1442    }
1443
1444    #[test]
1445    fn test_predict_batch_returns_one_per_query() {
1446        let mut opt = FewShotArchitectureOptimizer::new()
1447            .with_algorithm(FewShotAlgorithm::Matching)
1448            .with_k_shot(5);
1449        opt.fit_from_examples(make_support_set()).expect("fit");
1450
1451        let queries = vec![
1452            vec![1.0, 0.0, 0.0],
1453            vec![0.0, 1.0, 0.0],
1454            vec![0.0, 0.0, 1.0],
1455        ];
1456        let predictions = opt.predict_batch(&queries).expect("batch");
1457        assert_eq!(predictions.len(), 3);
1458        for p in &predictions {
1459            assert!(p.confidence >= 0.0 && p.confidence <= 1.0);
1460            assert!(p.predicted_performance.is_finite());
1461        }
1462    }
1463
1464    #[test]
1465    fn test_softmax_neg_uniform_for_equal_distances() {
1466        let weights = FewShotArchitectureOptimizer::softmax_neg(&[1.0, 1.0, 1.0, 1.0], 1.0);
1467        for w in &weights {
1468            assert!((w - 0.25).abs() < 1e-12, "expected 0.25 got {}", w);
1469        }
1470    }
1471
1472    #[test]
1473    fn test_softmax_neg_temperature_affects_sharpness() {
1474        let cold = FewShotArchitectureOptimizer::softmax_neg(&[0.0, 1.0, 2.0], 0.1);
1475        let warm = FewShotArchitectureOptimizer::softmax_neg(&[0.0, 1.0, 2.0], 10.0);
1476        // Cold should be near one-hot on the closest (lowest-d) point.
1477        assert!(cold[0] > 0.95, "expected near-1 max in cold: {:?}", cold);
1478        // Warm should be close to uniform.
1479        for w in &warm {
1480            assert!(
1481                (w - 1.0 / 3.0).abs() < 0.1,
1482                "warm should be near-uniform: {:?}",
1483                warm
1484            );
1485        }
1486    }
1487
1488    #[test]
1489    fn test_entropy_handles_zero_probability() {
1490        // 0 log 0 = 0 by convention. The function must not panic.
1491        let h = FewShotArchitectureOptimizer::entropy(&[0.0, 1.0]);
1492        assert!(h.abs() < 1e-12);
1493        let h_uniform = FewShotArchitectureOptimizer::entropy(&[0.5, 0.5]);
1494        assert!((h_uniform - (2.0_f64).ln()).abs() < 1e-12);
1495    }
1496
1497    #[test]
1498    fn test_graph_with_transfer_edges_does_not_leak() {
1499        // Even when Transfer edges exist between domains, fit_from_graph
1500        // must respect the source_domain filter.
1501        let mut graph = ArchitectureKnowledgeGraph::new();
1502        let v1 = graph.add_architecture("v1", vec![1.0, 0.0, 0.0], "vision");
1503        let n1 = graph.add_architecture("n1", vec![1.0, 0.0, 0.0], "nlp");
1504        graph
1505            .set_performance(
1506                v1,
1507                PerformanceRecord {
1508                    task_id: "t".into(),
1509                    accuracy: 0.9,
1510                    latency_ms: 1.0,
1511                    memory_mb: 1.0,
1512                    training_epochs: 1,
1513                },
1514            )
1515            .expect("perf v1");
1516        graph
1517            .set_performance(
1518                n1,
1519                PerformanceRecord {
1520                    task_id: "t".into(),
1521                    accuracy: 0.95,
1522                    latency_ms: 1.0,
1523                    memory_mb: 1.0,
1524                    training_epochs: 1,
1525                },
1526            )
1527            .expect("perf n1");
1528        graph
1529            .add_edge(n1, v1, RelationType::Transfers, 0.7)
1530            .expect("transfers");
1531
1532        let mut opt = FewShotArchitectureOptimizer::new()
1533            .with_algorithm(FewShotAlgorithm::DistanceWeightedKnn)
1534            .with_k_shot(1);
1535        opt.fit_from_graph(&graph, "vision", 1)
1536            .expect("fit_from_graph");
1537        let support = opt.support_set();
1538        assert_eq!(support.len(), 1);
1539        // Even though n1 has higher accuracy, the domain filter must
1540        // restrict the support set to vision-domain nodes.
1541        assert_eq!(support[0].arch_id, "v1");
1542        assert!((support[0].performance - 0.9).abs() < 1e-12);
1543    }
1544
1545    #[test]
1546    fn test_predict_query_dim_mismatch_errors() {
1547        let mut opt = FewShotArchitectureOptimizer::new().with_k_shot(5);
1548        opt.fit_from_examples(make_support_set()).expect("fit");
1549        let bad = opt.predict(&[1.0, 0.0]);
1550        assert!(bad.is_err(), "expected dim mismatch error");
1551    }
1552}