Skip to main content

optirs_learned/
darts_optimizer_search.rs

1//! # DARTS over Optimizer Update Rules
2//!
3//! This module applies **Differentiable Architecture Search (DARTS)** to the space of
4//! *optimizer update rules* rather than to neural-network cells. Instead of searching
5//! over convolutions and skip-connections, the search operates over a set of primitive
6//! per-parameter update operations (plain gradient, momentum, RMSProp, sign, an
7//! Adam-like rule and weight decay). A continuous relaxation over these primitives is
8//! optimized with a bilevel-style alternating procedure, and the result is discretized
9//! into a concrete optimizer that implements [`AdvancedOptimizer`].
10//!
11//! ## 1. Continuous relaxation
12//!
13//! Each primitive `i` is a real map `op_i(grad, params, state_i) -> direction_i`. We
14//! attach one architecture logit `α_i` to every primitive and define the **mixed
15//! update** as a softmax convex combination of the primitive directions:
16//!
17//! ```text
18//! w_i      = softmax(α)_i                       (architecture weights, w_i > 0, Σ w_i = 1)
19//! mixed    = Σ_i w_i · op_i(grad, params, state_i)
20//! params' := params - lr · mixed
21//! ```
22//!
23//! Because the architecture weights come from a softmax they are strictly positive and
24//! sum to one, so `mixed` is a genuine convex blend of the candidate directions.
25//!
26//! ## 2. Bilevel-style alternating optimization
27//!
28//! The search alternates an **inner (training)** parameter step with an **outer
29//! (architecture)** step that reduces a held-out **validation** loss. Within one inner
30//! step we move the candidate parameters `w` with the mixed update derived from the
31//! *training* gradient, then we update `α` to reduce the validation loss evaluated at
32//! the *stepped* point.
33//!
34//! The architecture gradient is a genuine closed-form derivative (no random walk). For a
35//! single inner step, define the look-ahead validation objective
36//!
37//! ```text
38//! φ(α) = L_val( w - lr · mixed(α) ),   mixed(α) = Σ_i softmax(α)_i · u_i
39//! ```
40//!
41//! where `u_i = op_i(grad, params, state_i)` do **not** depend on `α` (they only depend
42//! on the training gradient and each op's own state). The exact total derivative is
43//!
44//! ```text
45//! ∂φ/∂α_j = ∇L_val(w_new) · ( -lr · ∂mixed/∂α_j )
46//! ∂mixed/∂α_j = Σ_i softmax_i (δ_ij - softmax_j) u_i = softmax_j ( u_j - mixed )
47//! ⇒ ∂φ/∂α_j = -lr · softmax_j · ⟨ g_val , u_j - mixed ⟩ ,   g_val = ∇L_val(w_new).
48//! ```
49//!
50//! Evaluated at `w_new = w - lr · mixed(α)` this is the *exact* gradient of `φ` (the
51//! Jacobian of the softmax is folded in analytically). When more than one inner step is
52//! taken per epoch we accumulate the per-step look-ahead gradients and average them — a
53//! first-order meta-gradient (the validation-gradient readout `g_val` is treated as the
54//! straight-through term, exactly as in first-order DARTS), which remains a sound descent
55//! direction on the validation loss. The architecture logits are then updated by gradient
56//! descent: `α ← α - arch_lr · ∂φ/∂α`.
57//!
58//! ## 3. Discretization
59//!
60//! After the search, the architecture is discretized by taking the top-`k` primitives by
61//! softmax weight (`k = 1` recovers the usual `argmax`). Their weights are renormalized to
62//! sum to one and packaged into a [`DiscoveredOptimizer`] that applies only the winning
63//! op(s) and implements the crate's [`AdvancedOptimizer`] trait.
64//!
65//! ## Determinism
66//!
67//! Given a fixed [`DartsConfig::seed`], the only stochastic element — the initial
68//! candidate parameters — is drawn from a seeded generator, and every other computation is
69//! deterministic floating-point arithmetic. Two searches with identical configuration and
70//! seed therefore produce identical results.
71
72use crate::domain_optimizers::{l2_norm, AdvancedOptimizer, OptimizerStateInfo};
73use crate::error::{OptimError, Result};
74use scirs2_core::ndarray::Array1;
75use scirs2_core::numeric::Float;
76use scirs2_core::random::Random;
77use std::fmt::Debug;
78use std::sync::Arc;
79
80/// Infallible numeric cast for internal magnitudes that always represent in the target
81/// float type (counts, fixed constants). Falls back to zero only for exotic `T` that
82/// cannot represent the value, which never occurs for `f32`/`f64`.
83fn cast<T: Float>(value: f64) -> T {
84    T::from(value).unwrap_or_else(T::zero)
85}
86
87/// Checked numeric cast used at API boundaries (hyper-parameter conversion). Returns an
88/// error instead of silently substituting a value if `T` cannot represent `value`.
89fn to_scalar<T: Float>(value: f64) -> Result<T> {
90    T::from(value).ok_or_else(|| {
91        OptimError::ComputationError(format!(
92            "cannot represent {value} in the target floating-point type"
93        ))
94    })
95}
96
97/// Numerically-stable softmax of an architecture-logit vector.
98///
99/// The maximum logit is subtracted before exponentiation for stability. The returned
100/// vector is guaranteed to be strictly positive (every exponential is positive) and to
101/// sum to one (the maximum element contributes `exp(0) = 1`, so the denominator is at
102/// least one and never zero).
103pub(crate) fn softmax<T: Float>(logits: &Array1<T>) -> Array1<T> {
104    if logits.is_empty() {
105        return Array1::zeros(0);
106    }
107    let max_logit = logits
108        .iter()
109        .fold(T::neg_infinity(), |acc, &x| if x > acc { x } else { acc });
110    let exps = logits.mapv(|x| (x - max_logit).exp());
111    let sum = exps.iter().fold(T::zero(), |acc, &x| acc + x);
112    exps.mapv(|x| x / sum)
113}
114
115/// Convex combination `Σ_i weights_i · ops_i`.
116fn mixed_update<T: Float>(weights: &Array1<T>, ops: &[Array1<T>]) -> Array1<T> {
117    let dim = ops.first().map_or(0, Array1::len);
118    let mut acc = Array1::<T>::zeros(dim);
119    for (&weight, op) in weights.iter().zip(ops.iter()) {
120        for (slot, &value) in acc.iter_mut().zip(op.iter()) {
121            *slot = *slot + value * weight;
122        }
123    }
124    acc
125}
126
127/// Closed-form architecture gradient `∂φ/∂α_j = -lr · softmax_j · ⟨g_val, u_j - mixed⟩`.
128///
129/// This is the analytic derivative of the one-step look-ahead validation objective with
130/// respect to the architecture logits (see the module documentation).
131fn architecture_gradient<T: Float>(
132    weights: &Array1<T>,
133    ops: &[Array1<T>],
134    mixed: &Array1<T>,
135    g_val: &Array1<T>,
136    lr: T,
137) -> Array1<T> {
138    let grads: Vec<T> = ops
139        .iter()
140        .zip(weights.iter())
141        .map(|(op, &weight)| {
142            let directional = op
143                .iter()
144                .zip(mixed.iter())
145                .zip(g_val.iter())
146                .fold(T::zero(), |acc, ((&u, &m), &g)| acc + g * (u - m));
147            -lr * weight * directional
148        })
149        .collect();
150    Array1::from_vec(grads)
151}
152
153/// A candidate primitive update operation that the search mixes over.
154///
155/// Every variant is a real per-parameter map `(grad, params, state) -> direction`.
156#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
157pub enum UpdatePrimitive {
158    /// Plain gradient descent direction: `direction = grad`.
159    Grad,
160    /// Exponential moving average of the gradient (classical momentum):
161    /// `m ← β m + (1-β) grad`, `direction = m`.
162    Momentum,
163    /// Root-mean-square propagation: `v ← γ v + (1-γ) grad²`,
164    /// `direction = grad / (sqrt(v) + ε)`.
165    RmsProp,
166    /// Sign of the gradient: `direction = sign(grad)`.
167    Sign,
168    /// Adam-like rule with bias correction: `direction = m̂ / (sqrt(v̂) + ε)`.
169    AdamLike,
170    /// Decoupled weight decay: `direction = λ · params` (shrinks parameters toward zero).
171    WeightDecay,
172}
173
174impl UpdatePrimitive {
175    /// Stable human-readable name of the primitive.
176    pub fn name(&self) -> &'static str {
177        match self {
178            UpdatePrimitive::Grad => "Grad",
179            UpdatePrimitive::Momentum => "Momentum",
180            UpdatePrimitive::RmsProp => "RmsProp",
181            UpdatePrimitive::Sign => "Sign",
182            UpdatePrimitive::AdamLike => "AdamLike",
183            UpdatePrimitive::WeightDecay => "WeightDecay",
184        }
185    }
186
187    /// The full default search set, in canonical order.
188    pub fn default_set() -> Vec<UpdatePrimitive> {
189        vec![
190            UpdatePrimitive::Grad,
191            UpdatePrimitive::Momentum,
192            UpdatePrimitive::RmsProp,
193            UpdatePrimitive::Sign,
194            UpdatePrimitive::AdamLike,
195            UpdatePrimitive::WeightDecay,
196        ]
197    }
198
199    /// Whether this primitive maintains internal state across steps.
200    pub fn is_stateful(&self) -> bool {
201        matches!(
202            self,
203            UpdatePrimitive::Momentum | UpdatePrimitive::RmsProp | UpdatePrimitive::AdamLike
204        )
205    }
206
207    /// Apply the primitive, returning its proposed update direction.
208    ///
209    /// `state` is mutated in place for the stateful primitives (momentum / second-moment
210    /// accumulators and the Adam step counter); stateless primitives leave it untouched.
211    pub fn apply<T>(
212        &self,
213        grad: &Array1<T>,
214        params: &Array1<T>,
215        state: &mut PrimitiveState<T>,
216        hp: &PrimitiveHyperparams<T>,
217    ) -> Array1<T>
218    where
219        T: Float + Debug + Send + Sync + 'static,
220    {
221        match self {
222            UpdatePrimitive::Grad => grad.clone(),
223            UpdatePrimitive::Sign => grad.mapv(|g| {
224                // True sign: a zero gradient yields no update (unlike `f64::signum`,
225                // which returns +1 for +0.0).
226                if g > T::zero() {
227                    T::one()
228                } else if g < T::zero() {
229                    -T::one()
230                } else {
231                    T::zero()
232                }
233            }),
234            UpdatePrimitive::Momentum => {
235                let beta = hp.momentum_beta;
236                let one_minus = T::one() - beta;
237                state
238                    .momentum
239                    .zip_mut_with(grad, |m, &g| *m = beta * *m + one_minus * g);
240                state.momentum.clone()
241            }
242            UpdatePrimitive::RmsProp => {
243                let decay = hp.rms_decay;
244                let one_minus = T::one() - decay;
245                let eps = hp.epsilon;
246                state
247                    .second_moment
248                    .zip_mut_with(grad, |v, &g| *v = decay * *v + one_minus * g * g);
249                let mut out = grad.clone();
250                out.zip_mut_with(&state.second_moment, |o, &v| *o = *o / (v.sqrt() + eps));
251                out
252            }
253            UpdatePrimitive::AdamLike => {
254                state.step += 1;
255                let beta1 = hp.adam_beta1;
256                let beta2 = hp.adam_beta2;
257                let eps = hp.epsilon;
258                let one_minus1 = T::one() - beta1;
259                let one_minus2 = T::one() - beta2;
260                state
261                    .momentum
262                    .zip_mut_with(grad, |m, &g| *m = beta1 * *m + one_minus1 * g);
263                state
264                    .second_moment
265                    .zip_mut_with(grad, |v, &g| *v = beta2 * *v + one_minus2 * g * g);
266                let step_exp = i32::try_from(state.step).unwrap_or(i32::MAX);
267                let bias1 = T::one() - beta1.powi(step_exp);
268                let bias2 = T::one() - beta2.powi(step_exp);
269                let mut out = Array1::<T>::zeros(grad.len());
270                out.zip_mut_with(&state.momentum, |o, &m| *o = m / bias1);
271                out.zip_mut_with(&state.second_moment, |o, &v| {
272                    let v_hat = (v / bias2).sqrt();
273                    *o = *o / (v_hat + eps);
274                });
275                out
276            }
277            UpdatePrimitive::WeightDecay => params.mapv(|p| p * hp.weight_decay_strength),
278        }
279    }
280}
281
282/// Per-operation state shared by the stateful primitives.
283///
284/// A separate instance is held for every primitive in the search so that, for example,
285/// the momentum accumulator of [`UpdatePrimitive::Momentum`] is independent of the
286/// first-moment accumulator of [`UpdatePrimitive::AdamLike`].
287#[derive(Debug, Clone)]
288pub struct PrimitiveState<T: Float + Debug + Send + Sync + 'static> {
289    /// First-moment (momentum) accumulator.
290    momentum: Array1<T>,
291    /// Second-moment (squared-gradient) accumulator.
292    second_moment: Array1<T>,
293    /// Step counter (used for Adam bias correction).
294    step: usize,
295}
296
297impl<T: Float + Debug + Send + Sync + 'static> PrimitiveState<T> {
298    /// Create a fresh zero-initialized state for a `dim`-dimensional parameter vector.
299    pub fn new(dim: usize) -> Self {
300        Self {
301            momentum: Array1::zeros(dim),
302            second_moment: Array1::zeros(dim),
303            step: 0,
304        }
305    }
306}
307
308/// Hyper-parameters shared by the primitive operations, converted once to the target
309/// float type.
310#[derive(Debug, Clone)]
311pub struct PrimitiveHyperparams<T: Float + Debug + Send + Sync + 'static> {
312    /// Momentum EMA coefficient `β` for [`UpdatePrimitive::Momentum`].
313    pub momentum_beta: T,
314    /// First-moment coefficient `β₁` for [`UpdatePrimitive::AdamLike`].
315    pub adam_beta1: T,
316    /// Second-moment coefficient `β₂` for [`UpdatePrimitive::AdamLike`].
317    pub adam_beta2: T,
318    /// Squared-gradient EMA coefficient `γ` for [`UpdatePrimitive::RmsProp`].
319    pub rms_decay: T,
320    /// Numerical stabilizer added before division.
321    pub epsilon: T,
322    /// Decoupled weight-decay strength `λ` for [`UpdatePrimitive::WeightDecay`].
323    pub weight_decay_strength: T,
324}
325
326impl<T: Float + Debug + Send + Sync + 'static> PrimitiveHyperparams<T> {
327    /// Build the hyper-parameter bundle from a configuration, converting each value to `T`.
328    fn from_config(config: &DartsConfig) -> Result<Self> {
329        Ok(Self {
330            momentum_beta: to_scalar(config.momentum_beta)?,
331            adam_beta1: to_scalar(config.adam_beta1)?,
332            adam_beta2: to_scalar(config.adam_beta2)?,
333            rms_decay: to_scalar(config.rms_decay)?,
334            epsilon: to_scalar(config.epsilon)?,
335            weight_decay_strength: to_scalar(config.weight_decay_strength)?,
336        })
337    }
338}
339
340/// Boxed loss-and-gradient closure used by [`ClosureObjective`].
341type LossGradFn<T> = Arc<dyn Fn(&Array1<T>) -> Result<(T, Array1<T>)> + Send + Sync>;
342
343/// A differentiable scalar objective over the candidate parameters.
344///
345/// The optimizer search needs both a *training* signal (used to take the inner parameter
346/// step) and a *validation* signal (used to drive the architecture step). Implementors
347/// return `(loss, gradient)` for each. The two may coincide.
348pub trait DifferentiableObjective<T: Float + Debug + Send + Sync + 'static> {
349    /// Training loss and its gradient at `params`.
350    fn train_loss_grad(&self, params: &Array1<T>) -> Result<(T, Array1<T>)>;
351    /// Validation loss and its gradient at `params`.
352    fn validation_loss_grad(&self, params: &Array1<T>) -> Result<(T, Array1<T>)>;
353    /// Dimensionality of the parameter space.
354    fn dim(&self) -> usize;
355}
356
357/// A separable quadratic bowl `L(w) = ½ Σ c_i (w_i - center_i)²`.
358///
359/// Training and validation halves may use different curvatures / centers, which makes the
360/// bilevel search non-trivial; by default they coincide and the bowl is centered at the
361/// origin.
362#[derive(Debug, Clone)]
363pub struct QuadraticBowl<T: Float + Debug + Send + Sync + 'static> {
364    train_curvature: Array1<T>,
365    train_center: Array1<T>,
366    val_curvature: Array1<T>,
367    val_center: Array1<T>,
368}
369
370impl<T: Float + Debug + Send + Sync + 'static> QuadraticBowl<T> {
371    /// Build a bowl with the given per-coordinate `curvature`, centered at the origin,
372    /// using the same function for training and validation.
373    pub fn new(curvature: Array1<T>) -> Self {
374        let center = Array1::zeros(curvature.len());
375        Self {
376            train_curvature: curvature.clone(),
377            train_center: center.clone(),
378            val_curvature: curvature,
379            val_center: center,
380        }
381    }
382
383    /// Build an isotropic `dim`-dimensional bowl with a constant `curvature`.
384    pub fn isotropic(dim: usize, curvature: T) -> Result<Self> {
385        if dim == 0 {
386            return Err(OptimError::InvalidConfig(
387                "QuadraticBowl dimension must be positive".to_string(),
388            ));
389        }
390        Ok(Self::new(Array1::from_elem(dim, curvature)))
391    }
392
393    /// Override the validation half with a separate curvature and center.
394    pub fn with_validation(mut self, curvature: Array1<T>, center: Array1<T>) -> Result<Self> {
395        if curvature.len() != self.train_curvature.len() || center.len() != self.train_center.len()
396        {
397            return Err(OptimError::InvalidConfig(
398                "validation curvature/center dimension must match the training half".to_string(),
399            ));
400        }
401        self.val_curvature = curvature;
402        self.val_center = center;
403        Ok(self)
404    }
405
406    fn loss_grad(
407        curvature: &Array1<T>,
408        center: &Array1<T>,
409        params: &Array1<T>,
410    ) -> Result<(T, Array1<T>)> {
411        if params.len() != curvature.len() {
412            return Err(OptimError::InvalidConfig(format!(
413                "parameter length {} != objective dimension {}",
414                params.len(),
415                curvature.len()
416            )));
417        }
418        let half = cast::<T>(0.5);
419        let mut loss = T::zero();
420        let mut grad = Array1::<T>::zeros(params.len());
421        for (slot, ((&c, &p), &ctr)) in grad
422            .iter_mut()
423            .zip(curvature.iter().zip(params.iter()).zip(center.iter()))
424        {
425            let diff = p - ctr;
426            *slot = c * diff;
427            loss = loss + half * c * diff * diff;
428        }
429        Ok((loss, grad))
430    }
431}
432
433impl<T: Float + Debug + Send + Sync + 'static> DifferentiableObjective<T> for QuadraticBowl<T> {
434    fn train_loss_grad(&self, params: &Array1<T>) -> Result<(T, Array1<T>)> {
435        Self::loss_grad(&self.train_curvature, &self.train_center, params)
436    }
437
438    fn validation_loss_grad(&self, params: &Array1<T>) -> Result<(T, Array1<T>)> {
439        Self::loss_grad(&self.val_curvature, &self.val_center, params)
440    }
441
442    fn dim(&self) -> usize {
443        self.train_curvature.len()
444    }
445}
446
447/// The classical Rosenbrock valley
448/// `L(w) = Σ_i [ b (w_{i+1} - w_i²)² + (a - w_i)² ]`.
449///
450/// A non-convex, ill-conditioned stress test for optimizer search. Training and validation
451/// use the same function.
452#[derive(Debug, Clone)]
453pub struct Rosenbrock<T: Float + Debug + Send + Sync + 'static> {
454    dim: usize,
455    a: T,
456    b: T,
457}
458
459impl<T: Float + Debug + Send + Sync + 'static> Rosenbrock<T> {
460    /// Build a `dim`-dimensional Rosenbrock function with the standard constants
461    /// `a = 1`, `b = 100`. Requires `dim >= 2`.
462    pub fn new(dim: usize) -> Result<Self> {
463        if dim < 2 {
464            return Err(OptimError::InvalidConfig(
465                "Rosenbrock requires at least two dimensions".to_string(),
466            ));
467        }
468        Ok(Self {
469            dim,
470            a: T::one(),
471            b: cast::<T>(100.0),
472        })
473    }
474
475    /// Override the `a` and `b` constants.
476    pub fn with_constants(mut self, a: T, b: T) -> Self {
477        self.a = a;
478        self.b = b;
479        self
480    }
481
482    fn loss_grad(&self, params: &Array1<T>) -> Result<(T, Array1<T>)> {
483        if params.len() != self.dim {
484            return Err(OptimError::InvalidConfig(format!(
485                "parameter length {} != objective dimension {}",
486                params.len(),
487                self.dim
488            )));
489        }
490        let two = cast::<T>(2.0);
491        let mut loss = T::zero();
492        let mut grad = Array1::<T>::zeros(self.dim);
493        for i in 0..self.dim - 1 {
494            let xi = params[i];
495            let xi1 = params[i + 1];
496            let residual = xi1 - xi * xi;
497            let offset = self.a - xi;
498            loss = loss + self.b * residual * residual + offset * offset;
499            grad[i] = grad[i] + self.b * two * residual * (-two * xi) - two * offset;
500            grad[i + 1] = grad[i + 1] + self.b * two * residual;
501        }
502        Ok((loss, grad))
503    }
504}
505
506impl<T: Float + Debug + Send + Sync + 'static> DifferentiableObjective<T> for Rosenbrock<T> {
507    fn train_loss_grad(&self, params: &Array1<T>) -> Result<(T, Array1<T>)> {
508        self.loss_grad(params)
509    }
510
511    fn validation_loss_grad(&self, params: &Array1<T>) -> Result<(T, Array1<T>)> {
512        self.loss_grad(params)
513    }
514
515    fn dim(&self) -> usize {
516        self.dim
517    }
518}
519
520/// A pluggable objective backed by user-provided closures.
521#[derive(Clone)]
522pub struct ClosureObjective<T: Float + Debug + Send + Sync + 'static> {
523    dim: usize,
524    train: LossGradFn<T>,
525    validation: LossGradFn<T>,
526}
527
528impl<T: Float + Debug + Send + Sync + 'static> ClosureObjective<T> {
529    /// Build an objective from independent training and validation closures.
530    pub fn new<F, G>(dim: usize, train: F, validation: G) -> Result<Self>
531    where
532        F: Fn(&Array1<T>) -> Result<(T, Array1<T>)> + Send + Sync + 'static,
533        G: Fn(&Array1<T>) -> Result<(T, Array1<T>)> + Send + Sync + 'static,
534    {
535        if dim == 0 {
536            return Err(OptimError::InvalidConfig(
537                "ClosureObjective dimension must be positive".to_string(),
538            ));
539        }
540        Ok(Self {
541            dim,
542            train: Arc::new(train),
543            validation: Arc::new(validation),
544        })
545    }
546
547    /// Build an objective where the same closure is used for training and validation.
548    pub fn from_single<F>(dim: usize, objective: F) -> Result<Self>
549    where
550        F: Fn(&Array1<T>) -> Result<(T, Array1<T>)> + Send + Sync + 'static,
551    {
552        if dim == 0 {
553            return Err(OptimError::InvalidConfig(
554                "ClosureObjective dimension must be positive".to_string(),
555            ));
556        }
557        let shared: LossGradFn<T> = Arc::new(objective);
558        Ok(Self {
559            dim,
560            train: Arc::clone(&shared),
561            validation: shared,
562        })
563    }
564}
565
566impl<T: Float + Debug + Send + Sync + 'static> DifferentiableObjective<T> for ClosureObjective<T> {
567    fn train_loss_grad(&self, params: &Array1<T>) -> Result<(T, Array1<T>)> {
568        (self.train)(params)
569    }
570
571    fn validation_loss_grad(&self, params: &Array1<T>) -> Result<(T, Array1<T>)> {
572        (self.validation)(params)
573    }
574
575    fn dim(&self) -> usize {
576        self.dim
577    }
578}
579
580/// Configuration for [`DartsOptimizerSearch`].
581#[derive(Debug, Clone)]
582pub struct DartsConfig {
583    /// The candidate set of primitive update operations to search over.
584    pub primitives: Vec<UpdatePrimitive>,
585    /// Inner (parameter) learning rate `lr`.
586    pub learning_rate: f64,
587    /// Outer (architecture) learning rate used to update the logits `α`.
588    pub arch_learning_rate: f64,
589    /// Number of outer search epochs.
590    pub epochs: usize,
591    /// Number of inner parameter steps taken per epoch.
592    pub inner_steps: usize,
593    /// Number of primitives retained when discretizing (`1` = `argmax`).
594    pub top_k: usize,
595    /// If true, the candidate parameters and per-op state are reset to the initial point
596    /// at the start of every epoch (fixed-budget search). If false, they persist across
597    /// epochs (continuous descent).
598    pub reset_each_epoch: bool,
599    /// Half-width of the uniform range used to draw the initial candidate parameters.
600    pub init_params_scale: f64,
601    /// Momentum coefficient `β` for [`UpdatePrimitive::Momentum`].
602    pub momentum_beta: f64,
603    /// First-moment coefficient `β₁` for [`UpdatePrimitive::AdamLike`].
604    pub adam_beta1: f64,
605    /// Second-moment coefficient `β₂` for [`UpdatePrimitive::AdamLike`].
606    pub adam_beta2: f64,
607    /// Squared-gradient EMA coefficient `γ` for [`UpdatePrimitive::RmsProp`].
608    pub rms_decay: f64,
609    /// Numerical stabilizer added before division.
610    pub epsilon: f64,
611    /// Decoupled weight-decay strength `λ` for [`UpdatePrimitive::WeightDecay`].
612    pub weight_decay_strength: f64,
613    /// Seed for the deterministic initial-parameter generator.
614    pub seed: u64,
615}
616
617impl Default for DartsConfig {
618    fn default() -> Self {
619        Self {
620            primitives: UpdatePrimitive::default_set(),
621            learning_rate: 0.1,
622            arch_learning_rate: 0.3,
623            epochs: 60,
624            inner_steps: 5,
625            top_k: 1,
626            reset_each_epoch: true,
627            init_params_scale: 2.0,
628            momentum_beta: 0.9,
629            adam_beta1: 0.9,
630            adam_beta2: 0.999,
631            rms_decay: 0.9,
632            epsilon: 1e-8,
633            weight_decay_strength: 0.01,
634            seed: 0x0DA2_750F_C0FF_EE11,
635        }
636    }
637}
638
639impl DartsConfig {
640    /// Validate the configuration, returning an error describing the first problem found.
641    pub fn validate(&self) -> Result<()> {
642        if self.primitives.is_empty() {
643            return Err(OptimError::InvalidConfig(
644                "the primitive search set must not be empty".to_string(),
645            ));
646        }
647        if self.top_k == 0 || self.top_k > self.primitives.len() {
648            return Err(OptimError::InvalidConfig(format!(
649                "top_k must be in 1..={}, got {}",
650                self.primitives.len(),
651                self.top_k
652            )));
653        }
654        if self.epochs == 0 {
655            return Err(OptimError::InvalidConfig(
656                "epochs must be at least 1".to_string(),
657            ));
658        }
659        if self.inner_steps == 0 {
660            return Err(OptimError::InvalidConfig(
661                "inner_steps must be at least 1".to_string(),
662            ));
663        }
664        Self::require_positive_finite("learning_rate", self.learning_rate)?;
665        Self::require_positive_finite("arch_learning_rate", self.arch_learning_rate)?;
666        Self::require_positive_finite("init_params_scale", self.init_params_scale)?;
667        Self::require_positive_finite("epsilon", self.epsilon)?;
668        Self::require_unit_open("momentum_beta", self.momentum_beta)?;
669        Self::require_unit_open("adam_beta1", self.adam_beta1)?;
670        Self::require_unit_open("adam_beta2", self.adam_beta2)?;
671        Self::require_unit_open("rms_decay", self.rms_decay)?;
672        if !self.weight_decay_strength.is_finite() || self.weight_decay_strength < 0.0 {
673            return Err(OptimError::InvalidConfig(
674                "weight_decay_strength must be finite and non-negative".to_string(),
675            ));
676        }
677        Ok(())
678    }
679
680    fn require_positive_finite(field: &str, value: f64) -> Result<()> {
681        if !value.is_finite() || value <= 0.0 {
682            return Err(OptimError::InvalidConfig(format!(
683                "{field} must be finite and positive, got {value}"
684            )));
685        }
686        Ok(())
687    }
688
689    fn require_unit_open(field: &str, value: f64) -> Result<()> {
690        if !value.is_finite() || value <= 0.0 || value >= 1.0 {
691            return Err(OptimError::InvalidConfig(format!(
692                "{field} must lie in the open interval (0, 1), got {value}"
693            )));
694        }
695        Ok(())
696    }
697}
698
699/// The result of a completed architecture search.
700#[derive(Debug, Clone)]
701pub struct SearchOutcome<T: Float + Debug + Send + Sync + 'static> {
702    /// Learned architecture logits `α`.
703    pub alpha: Array1<T>,
704    /// Architecture weights `softmax(α)` (strictly positive, sum to one).
705    pub softmax_weights: Array1<T>,
706    /// The primitive set, aligned with `alpha` / `softmax_weights`.
707    pub primitives: Vec<UpdatePrimitive>,
708    /// Validation loss recorded at the end of each epoch.
709    pub validation_trajectory: Vec<T>,
710    /// The discretized optimizer applying the winning primitive(s).
711    pub discovered: DiscoveredOptimizer<T>,
712}
713
714impl<T: Float + Debug + Send + Sync + 'static> SearchOutcome<T> {
715    /// The primitive with the largest architecture weight.
716    pub fn best_primitive(&self) -> UpdatePrimitive {
717        let mut best_index = 0usize;
718        let mut best_weight = T::neg_infinity();
719        for (index, &weight) in self.softmax_weights.iter().enumerate() {
720            if weight > best_weight {
721                best_weight = weight;
722                best_index = index;
723            }
724        }
725        self.primitives[best_index]
726    }
727
728    /// The largest architecture weight.
729    pub fn best_weight(&self) -> T {
730        self.softmax_weights
731            .iter()
732            .fold(T::neg_infinity(), |acc, &w| if w > acc { w } else { acc })
733    }
734}
735
736/// Differentiable architecture search over optimizer update rules.
737pub struct DartsOptimizerSearch<T: Float + Debug + Send + Sync + 'static> {
738    config: DartsConfig,
739    primitives: Vec<UpdatePrimitive>,
740    hyperparams: PrimitiveHyperparams<T>,
741    learning_rate: T,
742    arch_learning_rate: T,
743    alpha: Array1<T>,
744}
745
746impl<T: Float + Debug + Send + Sync + 'static> DartsOptimizerSearch<T> {
747    /// Create a new search from a validated configuration.
748    ///
749    /// The architecture logits are initialized to zero, i.e. a uniform mixture over the
750    /// primitive set.
751    pub fn new(config: DartsConfig) -> Result<Self> {
752        config.validate()?;
753        let hyperparams = PrimitiveHyperparams::from_config(&config)?;
754        let learning_rate = to_scalar(config.learning_rate)?;
755        let arch_learning_rate = to_scalar(config.arch_learning_rate)?;
756        let primitives = config.primitives.clone();
757        let alpha = Array1::zeros(primitives.len());
758        Ok(Self {
759            config,
760            primitives,
761            hyperparams,
762            learning_rate,
763            arch_learning_rate,
764            alpha,
765        })
766    }
767
768    /// The current architecture logits.
769    pub fn alpha(&self) -> &Array1<T> {
770        &self.alpha
771    }
772
773    /// The current architecture weights `softmax(α)`.
774    pub fn architecture_weights(&self) -> Array1<T> {
775        softmax(&self.alpha)
776    }
777
778    /// The primitive search set.
779    pub fn primitives(&self) -> &[UpdatePrimitive] {
780        &self.primitives
781    }
782
783    /// Allocate one fresh per-primitive state of dimension `dim`.
784    fn fresh_states(&self, dim: usize) -> Vec<PrimitiveState<T>> {
785        self.primitives
786            .iter()
787            .map(|_| PrimitiveState::new(dim))
788            .collect()
789    }
790
791    /// Run the full bilevel architecture search and discretize the result.
792    pub fn search<O: DifferentiableObjective<T>>(
793        &mut self,
794        objective: &O,
795    ) -> Result<SearchOutcome<T>> {
796        let dim = objective.dim();
797        if dim == 0 {
798            return Err(OptimError::InsufficientData(
799                "objective dimensionality must be positive".to_string(),
800            ));
801        }
802
803        // Reset architecture logits so repeated searches are independent and deterministic.
804        self.alpha = Array1::zeros(self.primitives.len());
805
806        // Draw the (single) initial candidate parameter vector from a seeded generator.
807        let mut rng = Random::seed(self.config.seed);
808        let scale = self.config.init_params_scale;
809        let initial_params = Array1::from_shape_fn(dim, |_| {
810            let value: f64 = rng.random_range(-scale..scale);
811            cast::<T>(value)
812        });
813
814        let lr = self.learning_rate;
815        let arch_lr = self.arch_learning_rate;
816        let num_primitives = self.primitives.len();
817        let inner_steps = self.config.inner_steps;
818        let inner_scale = cast::<T>(1.0 / inner_steps as f64);
819
820        let mut params = initial_params.clone();
821        let mut states = self.fresh_states(dim);
822        let mut trajectory = Vec::with_capacity(self.config.epochs);
823
824        for _epoch in 0..self.config.epochs {
825            if self.config.reset_each_epoch {
826                params = initial_params.clone();
827                states = self.fresh_states(dim);
828            }
829
830            let mut alpha_grad = Array1::<T>::zeros(num_primitives);
831            let mut epoch_val_loss = T::zero();
832
833            for _inner in 0..inner_steps {
834                let weights = softmax(&self.alpha);
835                let (_, grad_train) = objective.train_loss_grad(&params)?;
836
837                // Evaluate every primitive (advancing its own state) and form the mix.
838                let ops: Vec<Array1<T>> = self
839                    .primitives
840                    .iter()
841                    .zip(states.iter_mut())
842                    .map(|(primitive, state)| {
843                        primitive.apply(&grad_train, &params, state, &self.hyperparams)
844                    })
845                    .collect();
846                let mixed = mixed_update(&weights, &ops);
847
848                // Inner step: descend the parameters with the mixed update.
849                let stepped = &params - &mixed.mapv(|m| m * lr);
850
851                // Outer signal: exact look-ahead architecture gradient at the stepped point.
852                let (val_loss, grad_val) = objective.validation_loss_grad(&stepped)?;
853                let step_grad = architecture_gradient(&weights, &ops, &mixed, &grad_val, lr);
854                alpha_grad = alpha_grad + &step_grad;
855
856                params = stepped;
857                epoch_val_loss = val_loss;
858            }
859
860            // Architecture (outer) step: gradient descent on the averaged meta-gradient.
861            let scaled = alpha_grad.mapv(|g| g * inner_scale * arch_lr);
862            self.alpha = &self.alpha - &scaled;
863
864            trajectory.push(epoch_val_loss);
865        }
866
867        let discovered = self.discretize()?;
868        Ok(SearchOutcome {
869            alpha: self.alpha.clone(),
870            softmax_weights: softmax(&self.alpha),
871            primitives: self.primitives.clone(),
872            validation_trajectory: trajectory,
873            discovered,
874        })
875    }
876
877    /// Discretize the relaxed architecture by selecting the top-`k` primitives.
878    fn discretize(&self) -> Result<DiscoveredOptimizer<T>> {
879        let weights = softmax(&self.alpha);
880        let mut order: Vec<usize> = (0..weights.len()).collect();
881        order.sort_by(|&a, &b| {
882            weights[b]
883                .partial_cmp(&weights[a])
884                .unwrap_or(std::cmp::Ordering::Equal)
885        });
886
887        let k = self.config.top_k.clamp(1, weights.len());
888        let selected_indices = &order[..k];
889
890        let selected_primitives: Vec<UpdatePrimitive> = selected_indices
891            .iter()
892            .map(|&i| self.primitives[i])
893            .collect();
894        let raw_weights: Vec<T> = selected_indices.iter().map(|&i| weights[i]).collect();
895        let weight_sum = raw_weights.iter().fold(T::zero(), |acc, &w| acc + w);
896
897        // Renormalize the retained weights to sum to one. The maximum weight is positive,
898        // so the sum is strictly positive and the division is well-defined.
899        let normalized: Array1<T> = if weight_sum > T::zero() {
900            Array1::from_vec(raw_weights).mapv(|w| w / weight_sum)
901        } else {
902            let uniform = cast::<T>(1.0 / k as f64);
903            Array1::from_elem(k, uniform)
904        };
905
906        DiscoveredOptimizer::new(
907            selected_primitives,
908            normalized,
909            self.hyperparams.clone(),
910            lr_of(&self.config)?,
911        )
912    }
913
914    /// Look-ahead validation loss `φ(α) = L_val(w - lr · mixed(softmax(α)))` for a fixed
915    /// set of primitive directions.
916    ///
917    /// Test-only: the search itself never needs φ evaluated at a perturbed α —
918    /// this exists so
919    /// `test_analytic_alpha_gradient_matches_finite_difference` can central-
920    /// difference it against [`Self::analytic_architecture_gradient`].
921    #[cfg(test)]
922    fn lookahead_validation_loss<O: DifferentiableObjective<T>>(
923        &self,
924        alpha: &Array1<T>,
925        ops: &[Array1<T>],
926        params: &Array1<T>,
927        objective: &O,
928    ) -> Result<T> {
929        let weights = softmax(alpha);
930        let mixed = mixed_update(&weights, ops);
931        let stepped = params - &mixed.mapv(|m| m * self.learning_rate);
932        let (loss, _) = objective.validation_loss_grad(&stepped)?;
933        Ok(loss)
934    }
935
936    /// Analytic architecture gradient `∂φ/∂α` for a fixed set of primitive directions.
937    ///
938    /// Test-only: the search computes this inline inside its alternation loop; the
939    /// standalone form exists purely so the finite-difference check has a
940    /// side-effect-free entry point.
941    #[cfg(test)]
942    fn analytic_architecture_gradient<O: DifferentiableObjective<T>>(
943        &self,
944        alpha: &Array1<T>,
945        ops: &[Array1<T>],
946        params: &Array1<T>,
947        objective: &O,
948    ) -> Result<Array1<T>> {
949        let weights = softmax(alpha);
950        let mixed = mixed_update(&weights, ops);
951        let stepped = params - &mixed.mapv(|m| m * self.learning_rate);
952        let (_, grad_val) = objective.validation_loss_grad(&stepped)?;
953        Ok(architecture_gradient(
954            &weights,
955            ops,
956            &mixed,
957            &grad_val,
958            self.learning_rate,
959        ))
960    }
961}
962
963/// Convert the inner learning rate of a configuration into the target float type.
964fn lr_of<T: Float>(config: &DartsConfig) -> Result<T> {
965    to_scalar(config.learning_rate)
966}
967
968/// A concrete optimizer discovered by [`DartsOptimizerSearch`].
969///
970/// It applies the winning primitive(s) with weights renormalized to sum to one and
971/// implements the crate's [`AdvancedOptimizer`] trait.
972#[derive(Debug, Clone)]
973pub struct DiscoveredOptimizer<T: Float + Debug + Send + Sync + 'static> {
974    primitives: Vec<UpdatePrimitive>,
975    weights: Array1<T>,
976    hyperparams: PrimitiveHyperparams<T>,
977    states: Vec<PrimitiveState<T>>,
978    learning_rate: T,
979    param_len: usize,
980    step_count: usize,
981    grad_norm_ema: T,
982    norm_ema_decay: T,
983}
984
985impl<T: Float + Debug + Send + Sync + 'static> DiscoveredOptimizer<T> {
986    /// Build a discovered optimizer from the selected primitives and (already
987    /// renormalized) weights.
988    fn new(
989        primitives: Vec<UpdatePrimitive>,
990        weights: Array1<T>,
991        hyperparams: PrimitiveHyperparams<T>,
992        learning_rate: T,
993    ) -> Result<Self> {
994        if primitives.is_empty() || primitives.len() != weights.len() {
995            return Err(OptimError::InvalidConfig(
996                "discovered optimizer requires a non-empty, weight-aligned primitive set"
997                    .to_string(),
998            ));
999        }
1000        Ok(Self {
1001            primitives,
1002            weights,
1003            hyperparams,
1004            states: Vec::new(),
1005            learning_rate,
1006            param_len: 0,
1007            step_count: 0,
1008            grad_norm_ema: T::zero(),
1009            norm_ema_decay: cast::<T>(0.999),
1010        })
1011    }
1012
1013    /// The primitive(s) retained by the search, ordered by descending weight.
1014    pub fn selected_primitives(&self) -> &[UpdatePrimitive] {
1015        &self.primitives
1016    }
1017
1018    /// The renormalized weights aligned with [`Self::selected_primitives`].
1019    pub fn selected_weights(&self) -> &Array1<T> {
1020        &self.weights
1021    }
1022
1023    /// Number of optimization steps performed so far.
1024    pub fn step_count(&self) -> usize {
1025        self.step_count
1026    }
1027}
1028
1029impl<T: Float + Debug + Send + Sync + 'static> AdvancedOptimizer<T> for DiscoveredOptimizer<T> {
1030    fn step(&mut self, params: &Array1<T>, gradients: &Array1<T>) -> Result<Array1<T>> {
1031        if params.len() != gradients.len() {
1032            return Err(OptimError::InvalidConfig(format!(
1033                "Parameter length {} != gradient length {}",
1034                params.len(),
1035                gradients.len()
1036            )));
1037        }
1038        if params.is_empty() {
1039            return Err(OptimError::InsufficientData(
1040                "Empty parameter array".to_string(),
1041            ));
1042        }
1043
1044        if self.param_len != params.len() {
1045            self.states = self
1046                .primitives
1047                .iter()
1048                .map(|_| PrimitiveState::new(params.len()))
1049                .collect();
1050            self.param_len = params.len();
1051        }
1052
1053        let ops: Vec<Array1<T>> = self
1054            .primitives
1055            .iter()
1056            .zip(self.states.iter_mut())
1057            .map(|(primitive, state)| primitive.apply(gradients, params, state, &self.hyperparams))
1058            .collect();
1059        let mixed = mixed_update(&self.weights, &ops);
1060        let updated = params - &mixed.mapv(|m| m * self.learning_rate);
1061
1062        let grad_norm = l2_norm(gradients);
1063        self.grad_norm_ema =
1064            self.norm_ema_decay * self.grad_norm_ema + (T::one() - self.norm_ema_decay) * grad_norm;
1065        self.step_count += 1;
1066
1067        Ok(updated)
1068    }
1069
1070    fn get_learning_rate(&self) -> T {
1071        self.learning_rate
1072    }
1073
1074    fn set_learning_rate(&mut self, lr: T) {
1075        self.learning_rate = lr;
1076    }
1077
1078    fn name(&self) -> &str {
1079        "DartsDiscovered"
1080    }
1081
1082    fn get_state(&self) -> OptimizerStateInfo<T> {
1083        OptimizerStateInfo {
1084            step_count: self.step_count,
1085            current_lr: self.learning_rate,
1086            grad_norm_ema: self.grad_norm_ema,
1087        }
1088    }
1089}
1090
1091#[cfg(test)]
1092mod tests {
1093    use super::*;
1094
1095    fn l2(arr: &Array1<f64>) -> f64 {
1096        arr.iter().fold(0.0, |acc, &x| acc + x * x).sqrt()
1097    }
1098
1099    #[test]
1100    fn test_softmax_positive_and_sums_to_one() {
1101        let logits = Array1::from_vec(vec![-2.0_f64, 0.5, 3.0, -0.1, 1.2]);
1102        let weights = softmax(&logits);
1103        let sum: f64 = weights.iter().sum();
1104        assert!(
1105            (sum - 1.0).abs() < 1e-12,
1106            "softmax must sum to 1, got {sum}"
1107        );
1108        for &w in weights.iter() {
1109            assert!(
1110                w > 0.0,
1111                "softmax weights must be strictly positive, got {w}"
1112            );
1113        }
1114        // The largest logit must receive the largest weight.
1115        let argmax = weights
1116            .iter()
1117            .enumerate()
1118            .fold((0usize, f64::NEG_INFINITY), |best, (i, &w)| {
1119                if w > best.1 {
1120                    (i, w)
1121                } else {
1122                    best
1123                }
1124            })
1125            .0;
1126        assert_eq!(argmax, 2, "largest logit should map to largest weight");
1127    }
1128
1129    #[test]
1130    fn test_softmax_numerically_stable_large_logits() {
1131        let logits = Array1::from_vec(vec![1000.0_f64, 1001.0, 999.0]);
1132        let weights = softmax(&logits);
1133        let sum: f64 = weights.iter().sum();
1134        assert!((sum - 1.0).abs() < 1e-12);
1135        for &w in weights.iter() {
1136            assert!(w.is_finite() && w > 0.0);
1137        }
1138    }
1139
1140    #[test]
1141    fn test_update_primitive_apply_directions() {
1142        let hp = PrimitiveHyperparams::<f64> {
1143            momentum_beta: 0.9,
1144            adam_beta1: 0.9,
1145            adam_beta2: 0.999,
1146            rms_decay: 0.9,
1147            epsilon: 1e-8,
1148            weight_decay_strength: 0.5,
1149        };
1150        let grad = Array1::from_vec(vec![2.0, -4.0, 0.0]);
1151        let params = Array1::from_vec(vec![1.0, -1.0, 3.0]);
1152
1153        let mut state = PrimitiveState::new(3);
1154        let grad_dir = UpdatePrimitive::Grad.apply(&grad, &params, &mut state, &hp);
1155        assert_eq!(grad_dir, grad, "Grad must return the gradient unchanged");
1156
1157        let mut state = PrimitiveState::new(3);
1158        let sign_dir = UpdatePrimitive::Sign.apply(&grad, &params, &mut state, &hp);
1159        assert_eq!(sign_dir, Array1::from_vec(vec![1.0, -1.0, 0.0]));
1160
1161        let mut state = PrimitiveState::new(3);
1162        let mom_dir = UpdatePrimitive::Momentum.apply(&grad, &params, &mut state, &hp);
1163        // First momentum step is (1-β)·grad.
1164        for (m, g) in mom_dir.iter().zip(grad.iter()) {
1165            assert!((m - 0.1 * g).abs() < 1e-12);
1166        }
1167
1168        let mut state = PrimitiveState::new(3);
1169        let wd_dir = UpdatePrimitive::WeightDecay.apply(&grad, &params, &mut state, &hp);
1170        for (w, p) in wd_dir.iter().zip(params.iter()) {
1171            assert!((w - 0.5 * p).abs() < 1e-12);
1172        }
1173
1174        // RmsProp and AdamLike must produce finite directions.
1175        let mut state = PrimitiveState::new(3);
1176        let rms_dir = UpdatePrimitive::RmsProp.apply(&grad, &params, &mut state, &hp);
1177        assert!(rms_dir.iter().all(|x| x.is_finite()));
1178        let mut state = PrimitiveState::new(3);
1179        let adam_dir = UpdatePrimitive::AdamLike.apply(&grad, &params, &mut state, &hp);
1180        assert!(adam_dir.iter().all(|x| x.is_finite()));
1181    }
1182
1183    #[test]
1184    fn test_config_validation_rejects_bad_inputs() {
1185        let bad_top_k = DartsConfig {
1186            top_k: 99,
1187            ..DartsConfig::default()
1188        };
1189        assert!(bad_top_k.validate().is_err(), "top_k > num primitives");
1190
1191        let bad_epochs = DartsConfig {
1192            epochs: 0,
1193            ..DartsConfig::default()
1194        };
1195        assert!(bad_epochs.validate().is_err(), "zero epochs");
1196
1197        let bad_lr = DartsConfig {
1198            learning_rate: -1.0,
1199            ..DartsConfig::default()
1200        };
1201        assert!(bad_lr.validate().is_err(), "negative lr");
1202
1203        let empty_set = DartsConfig {
1204            primitives: Vec::new(),
1205            ..DartsConfig::default()
1206        };
1207        assert!(empty_set.validate().is_err(), "empty primitive set");
1208
1209        let bad_beta = DartsConfig {
1210            adam_beta2: 1.0,
1211            ..DartsConfig::default()
1212        };
1213        assert!(bad_beta.validate().is_err(), "beta out of (0,1)");
1214
1215        assert!(DartsConfig::default().validate().is_ok());
1216    }
1217
1218    #[test]
1219    fn test_search_drives_validation_loss_down() {
1220        let objective = QuadraticBowl::<f64>::isotropic(6, 1.0).expect("bowl");
1221        let config = DartsConfig {
1222            epochs: 60,
1223            inner_steps: 5,
1224            learning_rate: 0.15,
1225            arch_learning_rate: 0.4,
1226            seed: 11,
1227            ..DartsConfig::default()
1228        };
1229        let mut search = DartsOptimizerSearch::<f64>::new(config).expect("search");
1230        let outcome = search.search(&objective).expect("search runs");
1231
1232        assert_eq!(outcome.validation_trajectory.len(), 60);
1233        let initial = outcome.validation_trajectory[0];
1234        let final_loss = *outcome
1235            .validation_trajectory
1236            .last()
1237            .expect("non-empty trajectory");
1238        assert!(
1239            final_loss < initial,
1240            "validation loss should fall: initial={initial}, final={final_loss}"
1241        );
1242        assert!(final_loss.is_finite());
1243    }
1244
1245    #[test]
1246    fn test_softmax_weights_in_outcome_valid() {
1247        let objective = QuadraticBowl::<f64>::isotropic(4, 1.0).expect("bowl");
1248        let config = DartsConfig {
1249            epochs: 20,
1250            seed: 5,
1251            ..DartsConfig::default()
1252        };
1253        let mut search = DartsOptimizerSearch::<f64>::new(config).expect("search");
1254        let outcome = search.search(&objective).expect("search runs");
1255
1256        let sum: f64 = outcome.softmax_weights.iter().sum();
1257        assert!((sum - 1.0).abs() < 1e-12);
1258        assert!(outcome.softmax_weights.iter().all(|&w| w > 0.0));
1259        assert_eq!(outcome.softmax_weights.len(), outcome.primitives.len());
1260    }
1261
1262    #[test]
1263    fn test_grad_primitive_wins_on_well_conditioned_quadratic() {
1264        // On a well-conditioned (isotropic) but steep quadratic, the plain gradient is the
1265        // unambiguously best descent direction: its alignment with the validation gradient
1266        // scales with the curvature `c²`, whereas magnitude-normalizing primitives such as
1267        // sign/RMSProp scale only with `c`. The search should therefore up-weight Grad most.
1268        let objective = QuadraticBowl::<f64>::isotropic(5, 6.0).expect("bowl");
1269        let config = DartsConfig {
1270            primitives: UpdatePrimitive::default_set(),
1271            epochs: 120,
1272            inner_steps: 10,
1273            learning_rate: 0.06,
1274            arch_learning_rate: 0.6,
1275            init_params_scale: 2.0,
1276            seed: 7,
1277            ..DartsConfig::default()
1278        };
1279        let mut search = DartsOptimizerSearch::<f64>::new(config).expect("search");
1280        let outcome = search.search(&objective).expect("search runs");
1281
1282        assert_eq!(
1283            outcome.best_primitive(),
1284            UpdatePrimitive::Grad,
1285            "Grad should win on a well-conditioned quadratic; weights = {:?}",
1286            outcome.softmax_weights
1287        );
1288        assert_eq!(
1289            outcome.discovered.selected_primitives()[0],
1290            UpdatePrimitive::Grad
1291        );
1292    }
1293
1294    #[test]
1295    fn test_discovered_optimizer_converges_on_holdout() {
1296        // Search on one quadratic, then run the discovered optimizer on a held-out one.
1297        let train_objective = QuadraticBowl::<f64>::isotropic(5, 1.0).expect("bowl");
1298        let config = DartsConfig {
1299            epochs: 100,
1300            inner_steps: 12,
1301            learning_rate: 0.25,
1302            arch_learning_rate: 0.5,
1303            seed: 7,
1304            ..DartsConfig::default()
1305        };
1306        let mut search = DartsOptimizerSearch::<f64>::new(config).expect("search");
1307        let outcome = search.search(&train_objective).expect("search runs");
1308
1309        let mut optimizer = outcome.discovered;
1310        // Held-out quadratic with different curvature and a non-zero starting point.
1311        let curvature = Array1::from_vec(vec![1.0, 0.8, 1.2, 0.9, 1.1]);
1312        let holdout = QuadraticBowl::<f64>::new(curvature);
1313        let mut params = Array1::from_vec(vec![3.0, -2.5, 4.0, -3.5, 2.0]);
1314        let initial_norm = l2(&params);
1315
1316        for _ in 0..200 {
1317            let (_, grad) = holdout.train_loss_grad(&params).expect("grad");
1318            params = optimizer.step(&params, &grad).expect("step");
1319        }
1320        let final_norm = l2(&params);
1321        assert!(
1322            final_norm < initial_norm,
1323            "discovered optimizer should converge: initial={initial_norm}, final={final_norm}"
1324        );
1325        assert!(
1326            final_norm < 0.5 * initial_norm,
1327            "should make substantial progress"
1328        );
1329        assert_eq!(optimizer.step_count(), 200);
1330    }
1331
1332    #[test]
1333    fn test_deterministic_given_seed() {
1334        let objective = QuadraticBowl::<f64>::isotropic(5, 1.0).expect("bowl");
1335        let config = DartsConfig {
1336            epochs: 30,
1337            inner_steps: 4,
1338            seed: 4242,
1339            ..DartsConfig::default()
1340        };
1341
1342        let mut search_a =
1343            DartsOptimizerSearch::<f64>::new(config.clone()).expect("first search constructs");
1344        let outcome_a = search_a.search(&objective).expect("a runs");
1345        let mut search_b =
1346            DartsOptimizerSearch::<f64>::new(config).expect("second search constructs");
1347        let outcome_b = search_b.search(&objective).expect("b runs");
1348
1349        for (a, b) in outcome_a.alpha.iter().zip(outcome_b.alpha.iter()) {
1350            assert!((a - b).abs() < 1e-15, "alpha must be identical: {a} vs {b}");
1351        }
1352        for (a, b) in outcome_a
1353            .validation_trajectory
1354            .iter()
1355            .zip(outcome_b.validation_trajectory.iter())
1356        {
1357            assert!((a - b).abs() < 1e-15, "trajectory must be identical");
1358        }
1359    }
1360
1361    #[test]
1362    fn test_analytic_alpha_gradient_matches_finite_difference() {
1363        let objective = QuadraticBowl::<f64>::isotropic(4, 1.3).expect("bowl");
1364        let config = DartsConfig {
1365            learning_rate: 0.2,
1366            seed: 1,
1367            ..DartsConfig::default()
1368        };
1369        let search = DartsOptimizerSearch::<f64>::new(config).expect("search");
1370
1371        // Fix a parameter point, an arbitrary alpha, and a fixed set of primitive
1372        // directions; then compare the analytic gradient of φ(α) against central FD.
1373        let params = Array1::from_vec(vec![0.7, -1.1, 0.3, 0.9]);
1374        let alpha = Array1::from_vec(vec![0.2, -0.5, 0.8, 0.1, -0.3, 0.4]);
1375        let (_, grad_train) = objective.train_loss_grad(&params).expect("grad");
1376        let mut states = search.fresh_states(4);
1377        let ops: Vec<Array1<f64>> = search
1378            .primitives
1379            .iter()
1380            .zip(states.iter_mut())
1381            .map(|(p, s)| p.apply(&grad_train, &params, s, &search.hyperparams))
1382            .collect();
1383
1384        let analytic = search
1385            .analytic_architecture_gradient(&alpha, &ops, &params, &objective)
1386            .expect("analytic");
1387
1388        let eps = 1e-6;
1389        for j in 0..alpha.len() {
1390            let mut plus = alpha.clone();
1391            let mut minus = alpha.clone();
1392            plus[j] += eps;
1393            minus[j] -= eps;
1394            let loss_plus = search
1395                .lookahead_validation_loss(&plus, &ops, &params, &objective)
1396                .expect("loss+");
1397            let loss_minus = search
1398                .lookahead_validation_loss(&minus, &ops, &params, &objective)
1399                .expect("loss-");
1400            let numeric = (loss_plus - loss_minus) / (2.0 * eps);
1401            assert!(
1402                (analytic[j] - numeric).abs() < 1e-5,
1403                "analytic vs FD mismatch at {j}: {} vs {numeric}",
1404                analytic[j]
1405            );
1406        }
1407    }
1408
1409    #[test]
1410    fn test_discovered_optimizer_trait_surface() {
1411        let objective = QuadraticBowl::<f64>::isotropic(3, 1.0).expect("bowl");
1412        let mut search = DartsOptimizerSearch::<f64>::new(DartsConfig::default()).expect("search");
1413        let outcome = search.search(&objective).expect("search runs");
1414        let mut optimizer = outcome.discovered;
1415
1416        assert_eq!(optimizer.name(), "DartsDiscovered");
1417        let lr0 = optimizer.get_learning_rate();
1418        assert!(lr0 > 0.0);
1419        optimizer.set_learning_rate(0.05);
1420        assert!((optimizer.get_learning_rate() - 0.05).abs() < 1e-12);
1421
1422        let params = Array1::from_vec(vec![1.0, 2.0, 3.0]);
1423        let grad = Array1::from_vec(vec![0.1, 0.2, 0.3]);
1424        let _ = optimizer.step(&params, &grad).expect("step");
1425        let state = optimizer.get_state();
1426        assert_eq!(state.step_count, 1);
1427        assert!((state.current_lr - 0.05).abs() < 1e-12);
1428        assert!(state.grad_norm_ema > 0.0);
1429
1430        // Selected weights sum to one.
1431        let weight_sum: f64 = optimizer.selected_weights().iter().sum();
1432        assert!((weight_sum - 1.0).abs() < 1e-12);
1433    }
1434
1435    #[test]
1436    fn test_top_k_discretization_keeps_two() {
1437        let objective = QuadraticBowl::<f64>::isotropic(4, 1.0).expect("bowl");
1438        let config = DartsConfig {
1439            top_k: 2,
1440            epochs: 25,
1441            seed: 99,
1442            ..DartsConfig::default()
1443        };
1444        let mut search = DartsOptimizerSearch::<f64>::new(config).expect("search");
1445        let outcome = search.search(&objective).expect("search runs");
1446        assert_eq!(outcome.discovered.selected_primitives().len(), 2);
1447        let weight_sum: f64 = outcome.discovered.selected_weights().iter().sum();
1448        assert!((weight_sum - 1.0).abs() < 1e-12);
1449    }
1450
1451    #[test]
1452    fn test_closure_objective_search_runs() {
1453        // A simple separable quadratic supplied as a closure.
1454        let objective = ClosureObjective::<f64>::from_single(3, |p| {
1455            let loss = 0.5 * p.iter().fold(0.0, |acc, &x| acc + x * x);
1456            let grad = p.clone();
1457            Ok((loss, grad))
1458        })
1459        .expect("closure objective");
1460        let config = DartsConfig {
1461            epochs: 30,
1462            inner_steps: 6,
1463            learning_rate: 0.2,
1464            seed: 3,
1465            ..DartsConfig::default()
1466        };
1467        let mut search = DartsOptimizerSearch::<f64>::new(config).expect("search");
1468        let outcome = search.search(&objective).expect("search runs");
1469        let initial = outcome.validation_trajectory[0];
1470        let final_loss = *outcome.validation_trajectory.last().expect("trajectory");
1471        assert!(final_loss < initial);
1472    }
1473
1474    #[test]
1475    fn test_rosenbrock_objective_evaluates() {
1476        let objective = Rosenbrock::<f64>::new(3).expect("rosenbrock");
1477        // Gradient at the global minimum (1,1,1) must vanish.
1478        let minimum = Array1::from_vec(vec![1.0, 1.0, 1.0]);
1479        let (loss, grad) = objective.train_loss_grad(&minimum).expect("grad");
1480        assert!(loss.abs() < 1e-12);
1481        assert!(grad.iter().all(|&g| g.abs() < 1e-9));
1482
1483        // A short search should run end to end on Rosenbrock without error.
1484        let config = DartsConfig {
1485            epochs: 15,
1486            inner_steps: 4,
1487            learning_rate: 0.001,
1488            init_params_scale: 0.5,
1489            seed: 2,
1490            ..DartsConfig::default()
1491        };
1492        let mut search = DartsOptimizerSearch::<f64>::new(config).expect("search");
1493        let outcome = search.search(&objective).expect("search runs");
1494        assert!(outcome.validation_trajectory.iter().all(|x| x.is_finite()));
1495    }
1496}