Skip to main content

optirs_learned/
zero_shot.rs

1//! # Zero-Shot Optimizer Selection
2//!
3//! This module implements **zero-shot optimizer selection**: given a brand-new
4//! optimization task, it recommends an optimizer class together with hyper-parameters
5//! *without performing any per-task training*. The recommendation is produced by
6//!
7//! 1. **lightweight probing** of the task (querying a few gradients), which yields a
8//!    real-valued **meta-feature** vector describing the local geometry of the problem,
9//!    and
10//! 2. a **meta-model** that was fit OFFLINE on a meta-dataset of previously-solved tasks
11//!    and that maps a (standardized) meta-feature vector to an optimizer class and a
12//!    learning rate.
13//!
14//! No gradient steps are taken on the new task itself — only a handful of gradient
15//! *evaluations* are needed to extract the meta-features. This mirrors the "learning to
16//! optimize / algorithm selection" literature, where a portfolio of optimizers is chosen
17//! per problem from cheap descriptive features.
18//!
19//! ## Meta-features
20//!
21//! The extractor probes the task at the initial point and along a short
22//! normalized-descent path, plus along sampled directions for curvature, and computes a
23//! fixed-length vector of [`NUM_FEATURES`] features. Each feature is documented on the
24//! corresponding accessor of [`MetaFeatures`]; in index order they are:
25//!
26//! - `log_dimension` (0): `ln(dim + 1)`, the (log) problem dimensionality.
27//! - `grad_norm_mean` (1): mean gradient L2-norm across the probe path.
28//! - `grad_norm_cv` (2): coefficient of variation of the gradient L2-norm — how strongly
29//!   the gradient magnitude changes across the landscape.
30//! - `grad_sparsity` (3): average fraction of gradient components that are effectively
31//!   zero (below a relative threshold).
32//! - `mean_log_curvature` (4): `ln(1 + mean directional curvature)`, an overall scale of
33//!   the local Hessian.
34//! - `log_condition_number` (5): `ln(max_dir_curvature / min_dir_curvature)`, a
35//!   condition-number proxy estimated from finite differences of the gradient along
36//!   sampled directions. This is the feature that distinguishes ill-conditioned from
37//!   well-conditioned problems.
38//! - `direction_stability` (6): mean cosine similarity between successive gradients along
39//!   the descent path (gradient-direction autocorrelation).
40//! - `noise_estimate` (7): mean per-component coefficient of variation of repeated
41//!   (stochastic) gradient samples at the initial point; `0` for a deterministic oracle,
42//!   `> 0` for a noisy one.
43//! - `log_param_scale` (8): `ln(1 + RMS(params))`, the magnitude/scale of the parameters.
44//!
45//! The raw features are **standardized** with the offline mean/standard-deviation learned
46//! by [`ZeroShotSelector::fit`]; a feature with zero variance over the meta-dataset is
47//! divided by one (instead of zero) so standardization never produces `NaN`.
48//!
49//! ## Meta-model (learned offline)
50//!
51//! The meta-model is a genuine learned mapping, trained on the standardized meta-features:
52//!
53//! - **Optimizer class:** a **multinomial logistic-regression** classifier over the five
54//!   [`OptimizerKind`] classes, trained by full-batch gradient descent on the softmax
55//!   cross-entropy loss with L2 regularization. The winning class is the recommendation
56//!   and the maximum softmax probability is reported as the **confidence**.
57//! - **Learning rate:** a **linear regressor** predicting `log10(learning_rate)`, trained
58//!   by gradient descent on the mean-squared error with L2 regularization. The prediction
59//!   is exponentiated and clamped to a sane range.
60//!
61//! Both predictors are deterministic functions of the meta-dataset (zero-initialized,
62//! full-batch gradient descent), so a fixed meta-dataset yields a fixed meta-model.
63//!
64//! ## Zero-shot inference
65//!
66//! [`ZeroShotSelector::recommend`] extracts meta-features from a [`TaskProbe`],
67//! standardizes them with the fitted statistics, and runs the two predictors — without
68//! ever touching the new task beyond the cheap probe.
69
70use crate::error::{OptimError, Result};
71use scirs2_core::ndarray::{Array1, Array2};
72use scirs2_core::random::Random;
73use serde::{Deserialize, Serialize};
74use std::path::Path;
75
76/// Number of meta-features produced by the extractor.
77pub const NUM_FEATURES: usize = 9;
78
79/// Number of optimizer classes the meta-model discriminates between.
80pub const NUM_CLASSES: usize = 5;
81
82/// Human-readable names of the meta-features, in index order.
83pub const FEATURE_NAMES: [&str; NUM_FEATURES] = [
84    "log_dimension",
85    "grad_norm_mean",
86    "grad_norm_cv",
87    "grad_sparsity",
88    "mean_log_curvature",
89    "log_condition_number",
90    "direction_stability",
91    "noise_estimate",
92    "log_param_scale",
93];
94
95/// Small constant guarding divisions by (near-)zero quantities.
96const EPS: f64 = 1e-12;
97
98/// Lower bound on a feature's standard deviation before it is treated as zero-variance and
99/// the standardization divisor is replaced by one.
100const STD_FLOOR: f64 = 1e-8;
101
102// ---------------------------------------------------------------------------------------
103// Optimizer kinds and hyper-parameters
104// ---------------------------------------------------------------------------------------
105
106/// The optimizer families the zero-shot selector can recommend.
107#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
108pub enum OptimizerKind {
109    /// Plain stochastic gradient descent.
110    Sgd,
111    /// SGD with classical (heavy-ball) momentum.
112    Momentum,
113    /// Adam (adaptive moments with bias correction).
114    Adam,
115    /// RMSProp (root-mean-square propagation).
116    RmsProp,
117    /// Adagrad (per-parameter accumulated-gradient scaling).
118    Adagrad,
119}
120
121impl OptimizerKind {
122    /// All optimizer kinds in canonical (class-index) order.
123    pub fn all() -> [OptimizerKind; NUM_CLASSES] {
124        [
125            OptimizerKind::Sgd,
126            OptimizerKind::Momentum,
127            OptimizerKind::Adam,
128            OptimizerKind::RmsProp,
129            OptimizerKind::Adagrad,
130        ]
131    }
132
133    /// Class index in `0..NUM_CLASSES` used by the meta-model.
134    pub fn index(self) -> usize {
135        match self {
136            OptimizerKind::Sgd => 0,
137            OptimizerKind::Momentum => 1,
138            OptimizerKind::Adam => 2,
139            OptimizerKind::RmsProp => 3,
140            OptimizerKind::Adagrad => 4,
141        }
142    }
143
144    /// Inverse of [`OptimizerKind::index`]; errors on an out-of-range index.
145    pub fn from_index(index: usize) -> Result<OptimizerKind> {
146        match index {
147            0 => Ok(OptimizerKind::Sgd),
148            1 => Ok(OptimizerKind::Momentum),
149            2 => Ok(OptimizerKind::Adam),
150            3 => Ok(OptimizerKind::RmsProp),
151            4 => Ok(OptimizerKind::Adagrad),
152            other => Err(OptimError::ComputationError(format!(
153                "optimizer class index {other} out of range 0..{NUM_CLASSES}"
154            ))),
155        }
156    }
157
158    /// Stable human-readable name.
159    pub fn name(self) -> &'static str {
160        match self {
161            OptimizerKind::Sgd => "Sgd",
162            OptimizerKind::Momentum => "Momentum",
163            OptimizerKind::Adam => "Adam",
164            OptimizerKind::RmsProp => "RmsProp",
165            OptimizerKind::Adagrad => "Adagrad",
166        }
167    }
168
169    /// Canonical hyper-parameters for this optimizer family.
170    ///
171    /// The learning rate is predicted separately by the regressor; these momentum / beta /
172    /// epsilon values are the well-established defaults for the selected family.
173    pub fn default_hyperparameters(self) -> OptimizerHyperparameters {
174        match self {
175            OptimizerKind::Sgd => OptimizerHyperparameters {
176                momentum: 0.0,
177                beta1: 0.0,
178                beta2: 0.0,
179                epsilon: 0.0,
180            },
181            OptimizerKind::Momentum => OptimizerHyperparameters {
182                momentum: 0.9,
183                beta1: 0.0,
184                beta2: 0.0,
185                epsilon: 0.0,
186            },
187            OptimizerKind::Adam => OptimizerHyperparameters {
188                momentum: 0.0,
189                beta1: 0.9,
190                beta2: 0.999,
191                epsilon: 1e-8,
192            },
193            OptimizerKind::RmsProp => OptimizerHyperparameters {
194                momentum: 0.0,
195                beta1: 0.0,
196                beta2: 0.9,
197                epsilon: 1e-8,
198            },
199            OptimizerKind::Adagrad => OptimizerHyperparameters {
200                momentum: 0.0,
201                beta1: 0.0,
202                beta2: 0.0,
203                epsilon: 1e-10,
204            },
205        }
206    }
207}
208
209/// Hyper-parameters accompanying an optimizer recommendation.
210///
211/// Not every field is meaningful for every optimizer family — e.g. `beta1`/`beta2` apply
212/// to Adam, `momentum` to heavy-ball momentum, `beta2` to the RMSProp decay. Fields that
213/// do not apply to the selected family are reported as `0`.
214#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
215pub struct OptimizerHyperparameters {
216    /// Heavy-ball momentum coefficient (Momentum family).
217    pub momentum: f64,
218    /// First-moment decay `β₁` (Adam family).
219    pub beta1: f64,
220    /// Second-moment / squared-gradient decay `β₂` (Adam, RMSProp families).
221    pub beta2: f64,
222    /// Numerical stabilizer added before division.
223    pub epsilon: f64,
224}
225
226impl Default for OptimizerHyperparameters {
227    fn default() -> Self {
228        OptimizerHyperparameters {
229            momentum: 0.0,
230            beta1: 0.9,
231            beta2: 0.999,
232            epsilon: 1e-8,
233        }
234    }
235}
236
237// ---------------------------------------------------------------------------------------
238// Task probe abstraction
239// ---------------------------------------------------------------------------------------
240
241/// A lightweight handle on an optimization task used for meta-feature extraction.
242///
243/// A probe exposes the problem dimensionality, a starting point, and a way to query the
244/// gradient at arbitrary points. Implementations should make [`TaskProbe::gradient_at`]
245/// cheap, since the extractor calls it a handful of times.
246pub trait TaskProbe {
247    /// Dimensionality of the parameter vector.
248    fn dimension(&self) -> usize;
249
250    /// Gradient (or its expectation, for a stochastic oracle) at `params`.
251    fn gradient_at(&self, params: &Array1<f64>) -> Array1<f64>;
252
253    /// A representative initial parameter vector for the task.
254    fn initial_params(&self) -> Array1<f64>;
255
256    /// Draw the `sample_index`-th (possibly noisy) gradient sample at `params`.
257    ///
258    /// The default implementation is deterministic and equal to [`TaskProbe::gradient_at`]
259    /// (i.e. a noise-free oracle). Stochastic tasks override this so that distinct
260    /// `sample_index` values yield distinct — but *reproducible* — gradient samples,
261    /// which lets the extractor estimate the gradient noise level deterministically.
262    fn gradient_sample(&self, params: &Array1<f64>, sample_index: usize) -> Array1<f64> {
263        let _ = sample_index;
264        self.gradient_at(params)
265    }
266}
267
268/// A separable quadratic probe `L(w) = ½ Σ c_i (w_i - center_i)²`.
269///
270/// Its gradient is `g_i = c_i (w_i - center_i)` and its Hessian is the diagonal matrix
271/// `diag(c)`, so the per-coordinate `curvature` values are exactly the eigenvalues. This
272/// makes it the natural fixture for exercising the condition-number meta-feature: an
273/// anisotropic curvature vector produces a known, controllable condition number.
274///
275/// An optional gradient-noise level turns the probe into a *stochastic* oracle whose
276/// per-sample noise is seeded by the sample index, so repeated samples differ yet the
277/// whole extraction stays reproducible.
278#[derive(Debug, Clone)]
279pub struct QuadraticProbe {
280    curvature: Array1<f64>,
281    center: Array1<f64>,
282    init: Array1<f64>,
283    noise_std: f64,
284    noise_seed: u64,
285}
286
287impl QuadraticProbe {
288    /// Build a bowl with per-coordinate `curvature` (the Hessian diagonal), centered at
289    /// the origin, started from `init`.
290    pub fn new(curvature: Array1<f64>, init: Array1<f64>) -> Result<Self> {
291        if curvature.is_empty() {
292            return Err(OptimError::InvalidConfig(
293                "QuadraticProbe curvature must be non-empty".to_string(),
294            ));
295        }
296        if curvature.len() != init.len() {
297            return Err(OptimError::InvalidConfig(format!(
298                "curvature length {} != init length {}",
299                curvature.len(),
300                init.len()
301            )));
302        }
303        let center = Array1::zeros(curvature.len());
304        Ok(Self {
305            curvature,
306            center,
307            init,
308            noise_std: 0.0,
309            noise_seed: 0,
310        })
311    }
312
313    /// Build an isotropic `dim`-dimensional bowl with constant `curvature`, started from a
314    /// constant `init_value` in every coordinate.
315    pub fn isotropic(dim: usize, curvature: f64, init_value: f64) -> Result<Self> {
316        if dim == 0 {
317            return Err(OptimError::InvalidConfig(
318                "QuadraticProbe dimension must be positive".to_string(),
319            ));
320        }
321        Self::new(
322            Array1::from_elem(dim, curvature),
323            Array1::from_elem(dim, init_value),
324        )
325    }
326
327    /// Override the bowl center (the location of the minimum).
328    pub fn with_center(mut self, center: Array1<f64>) -> Result<Self> {
329        if center.len() != self.curvature.len() {
330            return Err(OptimError::InvalidConfig(
331                "center dimension must match curvature dimension".to_string(),
332            ));
333        }
334        self.center = center;
335        Ok(self)
336    }
337
338    /// Turn the probe into a stochastic oracle whose gradient samples carry uniform noise
339    /// of the given standard deviation, seeded by `seed`.
340    pub fn with_noise(mut self, noise_std: f64, seed: u64) -> Self {
341        self.noise_std = noise_std.abs();
342        self.noise_seed = seed;
343        self
344    }
345
346    fn mean_gradient(&self, params: &Array1<f64>) -> Array1<f64> {
347        Array1::from_shape_fn(self.curvature.len(), |i| {
348            let xi = params.get(i).copied().unwrap_or(0.0);
349            self.curvature[i] * (xi - self.center[i])
350        })
351    }
352}
353
354impl TaskProbe for QuadraticProbe {
355    fn dimension(&self) -> usize {
356        self.curvature.len()
357    }
358
359    fn gradient_at(&self, params: &Array1<f64>) -> Array1<f64> {
360        self.mean_gradient(params)
361    }
362
363    fn initial_params(&self) -> Array1<f64> {
364        self.init.clone()
365    }
366
367    fn gradient_sample(&self, params: &Array1<f64>, sample_index: usize) -> Array1<f64> {
368        let mut grad = self.mean_gradient(params);
369        if self.noise_std > 0.0 {
370            let seed = mix_seed(self.noise_seed, sample_index as u64);
371            let mut rng = Random::seed(seed);
372            let std = self.noise_std;
373            grad.mapv_inplace(|g| g + rng.random_range(-std..std));
374        }
375        grad
376    }
377}
378
379/// The classical Rosenbrock valley probe
380/// `L(w) = Σ_i [ b (w_{i+1} - w_i²)² + (a - w_i)² ]`.
381///
382/// A non-convex, strongly ill-conditioned task (the default `b = 100` produces a long
383/// curved valley), useful for stress-testing the curvature meta-feature on a non-quadratic
384/// landscape.
385#[derive(Debug, Clone)]
386pub struct RosenbrockProbe {
387    dim: usize,
388    a: f64,
389    b: f64,
390    init: Array1<f64>,
391}
392
393impl RosenbrockProbe {
394    /// Build a `dim`-dimensional Rosenbrock probe (`a = 1`, `b = 100`) started from
395    /// `init`. Requires `dim >= 2`.
396    pub fn new(init: Array1<f64>) -> Result<Self> {
397        let dim = init.len();
398        if dim < 2 {
399            return Err(OptimError::InvalidConfig(
400                "RosenbrockProbe requires at least two dimensions".to_string(),
401            ));
402        }
403        Ok(Self {
404            dim,
405            a: 1.0,
406            b: 100.0,
407            init,
408        })
409    }
410}
411
412impl TaskProbe for RosenbrockProbe {
413    fn dimension(&self) -> usize {
414        self.dim
415    }
416
417    fn gradient_at(&self, params: &Array1<f64>) -> Array1<f64> {
418        let mut grad = Array1::<f64>::zeros(self.dim);
419        for i in 0..self.dim - 1 {
420            let xi = params.get(i).copied().unwrap_or(0.0);
421            let xi1 = params.get(i + 1).copied().unwrap_or(0.0);
422            let residual = xi1 - xi * xi;
423            let offset = self.a - xi;
424            grad[i] += self.b * 2.0 * residual * (-2.0 * xi) - 2.0 * offset;
425            grad[i + 1] += self.b * 2.0 * residual;
426        }
427        grad
428    }
429
430    fn initial_params(&self) -> Array1<f64> {
431        self.init.clone()
432    }
433}
434
435// ---------------------------------------------------------------------------------------
436// Meta-features
437// ---------------------------------------------------------------------------------------
438
439/// A fixed-length, real-valued description of an optimization task.
440///
441/// The values are stored in canonical order (see the module-level documentation and
442/// [`FEATURE_NAMES`]); named accessors document the meaning of each entry.
443#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
444pub struct MetaFeatures {
445    /// The raw (un-standardized) feature values, length [`NUM_FEATURES`].
446    pub values: Array1<f64>,
447}
448
449impl MetaFeatures {
450    /// Wrap a raw feature vector, validating its length.
451    pub fn from_values(values: Array1<f64>) -> Result<Self> {
452        if values.len() != NUM_FEATURES {
453            return Err(OptimError::InvalidConfig(format!(
454                "meta-feature vector has length {} but {NUM_FEATURES} were expected",
455                values.len()
456            )));
457        }
458        Ok(Self { values })
459    }
460
461    /// The raw feature values.
462    pub fn as_array(&self) -> &Array1<f64> {
463        &self.values
464    }
465
466    /// Feature names in index order.
467    pub fn names() -> [&'static str; NUM_FEATURES] {
468        FEATURE_NAMES
469    }
470
471    /// `ln(dim + 1)` — the (log) problem dimensionality.
472    pub fn log_dimension(&self) -> f64 {
473        self.values[0]
474    }
475
476    /// Mean gradient L2-norm across the probe path.
477    pub fn grad_norm_mean(&self) -> f64 {
478        self.values[1]
479    }
480
481    /// Coefficient of variation of the gradient L2-norm across the probe path.
482    pub fn grad_norm_cv(&self) -> f64 {
483        self.values[2]
484    }
485
486    /// Average fraction of gradient components that are effectively zero.
487    pub fn grad_sparsity(&self) -> f64 {
488        self.values[3]
489    }
490
491    /// `ln(1 + mean directional curvature)`.
492    pub fn mean_log_curvature(&self) -> f64 {
493        self.values[4]
494    }
495
496    /// `ln(max directional curvature / min directional curvature)` — condition-number
497    /// proxy.
498    pub fn log_condition_number(&self) -> f64 {
499        self.values[5]
500    }
501
502    /// Mean cosine similarity between successive gradients along the descent path.
503    pub fn direction_stability(&self) -> f64 {
504        self.values[6]
505    }
506
507    /// Mean per-component coefficient of variation of repeated gradient samples.
508    pub fn noise_estimate(&self) -> f64 {
509        self.values[7]
510    }
511
512    /// `ln(1 + RMS(params))` — the magnitude/scale of the parameters.
513    pub fn log_param_scale(&self) -> f64 {
514        self.values[8]
515    }
516}
517
518/// One row of the offline meta-dataset: the meta-features of a previously solved task,
519/// together with the optimizer / learning rate that worked best on it.
520#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
521pub struct MetaExample {
522    /// Meta-features describing the task.
523    pub features: MetaFeatures,
524    /// The optimizer family that performed best on the task.
525    pub best_optimizer: OptimizerKind,
526    /// The learning rate that performed best on the task (must be positive).
527    pub best_learning_rate: f64,
528}
529
530impl MetaExample {
531    /// Construct a meta-dataset row, validating the learning rate.
532    pub fn new(
533        features: MetaFeatures,
534        best_optimizer: OptimizerKind,
535        best_learning_rate: f64,
536    ) -> Result<Self> {
537        if !(best_learning_rate.is_finite() && best_learning_rate > 0.0) {
538            return Err(OptimError::InvalidConfig(format!(
539                "best_learning_rate must be finite and positive, got {best_learning_rate}"
540            )));
541        }
542        Ok(Self {
543            features,
544            best_optimizer,
545            best_learning_rate,
546        })
547    }
548}
549
550// ---------------------------------------------------------------------------------------
551// Recommendation
552// ---------------------------------------------------------------------------------------
553
554/// The output of zero-shot inference.
555#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
556pub struct OptimizerRecommendation {
557    /// Recommended optimizer family.
558    pub optimizer: OptimizerKind,
559    /// Recommended learning rate.
560    pub learning_rate: f64,
561    /// Recommended hyper-parameters for the selected family.
562    pub hyperparameters: OptimizerHyperparameters,
563    /// Classifier confidence in `[0, 1]` (the maximum softmax probability).
564    pub confidence: f64,
565}
566
567// ---------------------------------------------------------------------------------------
568// Configuration
569// ---------------------------------------------------------------------------------------
570
571/// Configuration for meta-feature extraction and meta-model training.
572#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
573pub struct ZeroShotConfig {
574    /// Number of points visited along the normalized-descent probe path (`>= 1`).
575    pub num_probe_points: usize,
576    /// Step size of the normalized-descent probe walk.
577    pub probe_step: f64,
578    /// Maximum number of coordinate directions used for curvature probing.
579    pub num_curvature_directions: usize,
580    /// Number of additional random directions used for curvature probing.
581    pub num_random_curvature_dirs: usize,
582    /// Finite-difference step (scaled by the parameter RMS) for curvature estimation.
583    pub finite_diff_eps: f64,
584    /// Relative threshold below which a gradient component counts as "zero" for sparsity.
585    pub sparsity_rel_threshold: f64,
586    /// Number of repeated gradient samples used to estimate gradient noise (`>= 0`).
587    pub noise_repeats: usize,
588    /// Number of full-batch gradient-descent epochs for the classifier.
589    pub classifier_epochs: usize,
590    /// Classifier learning rate.
591    pub classifier_lr: f64,
592    /// Classifier L2 regularization strength.
593    pub classifier_l2: f64,
594    /// Number of full-batch gradient-descent epochs for the learning-rate regressor.
595    pub regressor_epochs: usize,
596    /// Regressor learning rate.
597    pub regressor_lr: f64,
598    /// Regressor L2 regularization strength.
599    pub regressor_l2: f64,
600    /// Minimum learning rate the regressor may recommend.
601    pub min_learning_rate: f64,
602    /// Maximum learning rate the regressor may recommend.
603    pub max_learning_rate: f64,
604    /// Seed for the random curvature directions (keeps extraction deterministic).
605    pub seed: u64,
606}
607
608impl Default for ZeroShotConfig {
609    fn default() -> Self {
610        Self {
611            num_probe_points: 6,
612            probe_step: 0.05,
613            num_curvature_directions: 12,
614            num_random_curvature_dirs: 4,
615            finite_diff_eps: 1e-3,
616            sparsity_rel_threshold: 1e-3,
617            noise_repeats: 5,
618            classifier_epochs: 800,
619            classifier_lr: 0.2,
620            classifier_l2: 1e-4,
621            regressor_epochs: 800,
622            regressor_lr: 0.1,
623            regressor_l2: 1e-4,
624            min_learning_rate: 1e-6,
625            max_learning_rate: 1.0,
626            seed: 0,
627        }
628    }
629}
630
631impl ZeroShotConfig {
632    /// Validate the configuration, returning an error describing the first problem found.
633    pub fn validate(&self) -> Result<()> {
634        if self.num_probe_points == 0 {
635            return Err(OptimError::InvalidConfig(
636                "num_probe_points must be >= 1".to_string(),
637            ));
638        }
639        if !(self.probe_step.is_finite() && self.probe_step > 0.0) {
640            return Err(OptimError::InvalidConfig(
641                "probe_step must be finite and positive".to_string(),
642            ));
643        }
644        if !(self.finite_diff_eps.is_finite() && self.finite_diff_eps > 0.0) {
645            return Err(OptimError::InvalidConfig(
646                "finite_diff_eps must be finite and positive".to_string(),
647            ));
648        }
649        if self.num_curvature_directions == 0 && self.num_random_curvature_dirs == 0 {
650            return Err(OptimError::InvalidConfig(
651                "at least one curvature direction is required".to_string(),
652            ));
653        }
654        if self.classifier_epochs == 0 || self.regressor_epochs == 0 {
655            return Err(OptimError::InvalidConfig(
656                "classifier_epochs and regressor_epochs must be >= 1".to_string(),
657            ));
658        }
659        if !(self.classifier_lr > 0.0 && self.regressor_lr > 0.0) {
660            return Err(OptimError::InvalidConfig(
661                "classifier_lr and regressor_lr must be positive".to_string(),
662            ));
663        }
664        if self.classifier_l2 < 0.0 || self.regressor_l2 < 0.0 {
665            return Err(OptimError::InvalidConfig(
666                "L2 strengths must be non-negative".to_string(),
667            ));
668        }
669        if !(self.min_learning_rate > 0.0
670            && self.max_learning_rate > self.min_learning_rate
671            && self.max_learning_rate.is_finite())
672        {
673            return Err(OptimError::InvalidConfig(
674                "require 0 < min_learning_rate < max_learning_rate < inf".to_string(),
675            ));
676        }
677        Ok(())
678    }
679}
680
681// ---------------------------------------------------------------------------------------
682// Zero-shot selector
683// ---------------------------------------------------------------------------------------
684
685/// Zero-shot optimizer selector: an offline-fitted meta-model plus the meta-feature
686/// extractor it consumes.
687///
688/// The selector is fit once on a meta-dataset of [`MetaExample`]s (see
689/// [`ZeroShotSelector::fit`]) and thereafter answers [`ZeroShotSelector::recommend`]
690/// queries for brand-new tasks with no further training.
691#[derive(Debug, Clone, Serialize, Deserialize)]
692pub struct ZeroShotSelector {
693    config: ZeroShotConfig,
694    fitted: bool,
695    /// Per-feature mean over the meta-dataset (standardization).
696    feature_mean: Array1<f64>,
697    /// Per-feature standard deviation over the meta-dataset (standardization).
698    feature_std: Array1<f64>,
699    /// Logistic-regression weights, shape `(NUM_CLASSES, NUM_FEATURES)`.
700    clf_w: Array2<f64>,
701    /// Logistic-regression biases, length `NUM_CLASSES`.
702    clf_b: Array1<f64>,
703    /// Learning-rate regressor weights, length `NUM_FEATURES`.
704    reg_w: Array1<f64>,
705    /// Learning-rate regressor bias.
706    reg_b: f64,
707}
708
709impl ZeroShotSelector {
710    /// Create an unfitted selector with the given configuration.
711    pub fn new(config: ZeroShotConfig) -> Result<Self> {
712        config.validate()?;
713        Ok(Self {
714            config,
715            fitted: false,
716            feature_mean: Array1::zeros(NUM_FEATURES),
717            feature_std: Array1::ones(NUM_FEATURES),
718            clf_w: Array2::zeros((NUM_CLASSES, NUM_FEATURES)),
719            clf_b: Array1::zeros(NUM_CLASSES),
720            reg_w: Array1::zeros(NUM_FEATURES),
721            reg_b: 0.0,
722        })
723    }
724
725    /// Create an unfitted selector with the default configuration.
726    pub fn with_default_config() -> Result<Self> {
727        Self::new(ZeroShotConfig::default())
728    }
729
730    /// Whether [`ZeroShotSelector::fit`] has been called successfully.
731    pub fn is_fitted(&self) -> bool {
732        self.fitted
733    }
734
735    /// The active configuration.
736    pub fn config(&self) -> &ZeroShotConfig {
737        &self.config
738    }
739
740    // -- meta-feature extraction --------------------------------------------------------
741
742    /// Extract the meta-feature vector for a task by lightweight probing.
743    ///
744    /// This performs only gradient *evaluations* (no optimization steps on the task) and
745    /// is deterministic: the random curvature directions are drawn from a generator seeded
746    /// by [`ZeroShotConfig::seed`], and the noise estimate uses index-seeded gradient
747    /// samples, so repeated calls return identical features.
748    pub fn extract_meta_features<P>(&self, probe: &P) -> Result<MetaFeatures>
749    where
750        P: TaskProbe + ?Sized,
751    {
752        let dim = probe.dimension();
753        if dim == 0 {
754            return Err(OptimError::InvalidConfig(
755                "probe dimension must be positive".to_string(),
756            ));
757        }
758        let x0 = probe.initial_params();
759        if x0.len() != dim {
760            return Err(OptimError::InvalidConfig(format!(
761                "initial_params length {} != probe dimension {dim}",
762                x0.len()
763            )));
764        }
765
766        let cfg = &self.config;
767
768        // --- Probe-path statistics: walk a short normalized-descent path, collecting the
769        //     gradient at each visited point plus the cosine between successive gradients.
770        let mut path_norms: Vec<f64> = Vec::with_capacity(cfg.num_probe_points);
771        let mut sparsity_acc = 0.0;
772        let mut stability_acc = 0.0;
773        let mut stability_count = 0usize;
774        let mut prev_grad: Option<Array1<f64>> = None;
775        let mut x = x0.clone();
776
777        for _ in 0..cfg.num_probe_points {
778            let grad = probe.gradient_at(&x);
779            if grad.len() != dim {
780                return Err(OptimError::ComputationError(format!(
781                    "probe gradient length {} != dimension {dim}",
782                    grad.len()
783                )));
784            }
785            let norm = l2_norm(&grad);
786            path_norms.push(norm);
787            sparsity_acc += fraction_near_zero(&grad, cfg.sparsity_rel_threshold);
788
789            if let Some(prev) = &prev_grad {
790                stability_acc += cosine_similarity(prev, &grad);
791                stability_count += 1;
792            }
793
794            // Normalized-descent step; if the gradient vanishes we stay put.
795            if norm > EPS {
796                let scale = cfg.probe_step / norm;
797                x = &x - &grad.mapv(|g| g * scale);
798            }
799            prev_grad = Some(grad);
800        }
801
802        let grad_norm_mean = mean(&path_norms);
803        let grad_norm_cv = coefficient_of_variation(&path_norms);
804        let grad_sparsity = sparsity_acc / cfg.num_probe_points as f64;
805        let direction_stability = if stability_count > 0 {
806            stability_acc / stability_count as f64
807        } else {
808            0.0
809        };
810
811        // --- Curvature / condition-number proxy: finite-difference the gradient along
812        //     coordinate directions (which recover the Hessian diagonal exactly for a
813        //     separable quadratic) plus a few random directions.
814        let g_base = probe.gradient_at(&x0);
815        if g_base.len() != dim {
816            return Err(OptimError::ComputationError(
817                "probe gradient length mismatch at base point".to_string(),
818            ));
819        }
820        let h = cfg.finite_diff_eps * (1.0 + rms(&x0));
821        let mut curvatures: Vec<f64> = Vec::new();
822
823        let n_coord = dim.min(cfg.num_curvature_directions);
824        for i in 0..n_coord {
825            let mut xp = x0.clone();
826            xp[i] += h;
827            let gp = probe.gradient_at(&xp);
828            if gp.len() != dim {
829                return Err(OptimError::ComputationError(
830                    "probe gradient length mismatch during curvature probe".to_string(),
831                ));
832            }
833            // e_iᵀ H e_i = ∂g_i / ∂x_i ≈ (g_i(x + h e_i) − g_i(x)) / h.
834            curvatures.push(((gp[i] - g_base[i]) / h).abs());
835        }
836
837        let mut rng = Random::seed(cfg.seed);
838        for _ in 0..cfg.num_random_curvature_dirs {
839            let mut dir = Array1::from_shape_fn(dim, |_| rng.random_range(-1.0..1.0));
840            let dir_norm = l2_norm(&dir);
841            if dir_norm <= EPS {
842                continue;
843            }
844            dir.mapv_inplace(|v| v / dir_norm);
845            let xp = &x0 + &dir.mapv(|v| v * h);
846            let gp = probe.gradient_at(&xp);
847            if gp.len() != dim {
848                return Err(OptimError::ComputationError(
849                    "probe gradient length mismatch during random curvature probe".to_string(),
850                ));
851            }
852            // dᵀ H d ≈ (g(x + h d) − g(x)) · d / h.
853            let diff = &gp - &g_base;
854            curvatures.push((dot(&diff, &dir) / h).abs());
855        }
856
857        let mean_curvature = mean(&curvatures);
858        let mean_log_curvature = (1.0 + mean_curvature).ln();
859        let log_condition_number = log_condition(&curvatures);
860
861        // --- Noise estimate: per-component coefficient of variation across repeated,
862        //     index-seeded gradient samples at the base point.
863        let noise_estimate = if cfg.noise_repeats >= 2 {
864            let samples: Vec<Array1<f64>> = (0..cfg.noise_repeats)
865                .map(|k| probe.gradient_sample(&x0, k))
866                .collect();
867            mean_component_cv(&samples, dim)
868        } else {
869            0.0
870        };
871
872        let log_dimension = (dim as f64 + 1.0).ln();
873        let log_param_scale = (1.0 + rms(&x0)).ln();
874
875        let values = Array1::from_vec(vec![
876            log_dimension,
877            grad_norm_mean,
878            grad_norm_cv,
879            grad_sparsity,
880            mean_log_curvature,
881            log_condition_number,
882            direction_stability,
883            noise_estimate,
884            log_param_scale,
885        ]);
886
887        // Guard against non-finite values leaking into the meta-model.
888        if values.iter().any(|v| !v.is_finite()) {
889            return Err(OptimError::ComputationError(
890                "meta-feature extraction produced a non-finite value".to_string(),
891            ));
892        }
893
894        MetaFeatures::from_values(values)
895    }
896
897    /// Standardize a raw feature vector with the fitted mean/standard-deviation.
898    ///
899    /// A feature whose standard deviation is below [`STD_FLOOR`] is treated as
900    /// zero-variance and divided by one, so the result is never `NaN`.
901    fn standardize(&self, raw: &Array1<f64>) -> Array1<f64> {
902        Array1::from_shape_fn(NUM_FEATURES, |i| {
903            let std = self.feature_std[i];
904            let denom = if std > STD_FLOOR { std } else { 1.0 };
905            (raw[i] - self.feature_mean[i]) / denom
906        })
907    }
908
909    // -- offline fitting ----------------------------------------------------------------
910
911    /// Fit the meta-model on an offline meta-dataset.
912    ///
913    /// This learns the standardization statistics, the multinomial logistic-regression
914    /// classifier (softmax cross-entropy, full-batch gradient descent with L2), and the
915    /// linear `log10(learning_rate)` regressor (MSE, full-batch gradient descent with L2).
916    pub fn fit(&mut self, examples: &[MetaExample]) -> Result<()> {
917        if examples.is_empty() {
918            return Err(OptimError::InsufficientData(
919                "meta-dataset must contain at least one example".to_string(),
920            ));
921        }
922        for ex in examples {
923            if ex.features.values.len() != NUM_FEATURES {
924                return Err(OptimError::InvalidConfig(
925                    "a meta-example has the wrong feature length".to_string(),
926                ));
927            }
928            if !(ex.best_learning_rate.is_finite() && ex.best_learning_rate > 0.0) {
929                return Err(OptimError::InvalidConfig(
930                    "a meta-example has a non-positive learning rate".to_string(),
931                ));
932            }
933        }
934
935        let n = examples.len();
936        let n_f = n as f64;
937
938        // Standardization statistics (population mean / standard deviation).
939        let mut mean_vec = Array1::<f64>::zeros(NUM_FEATURES);
940        for ex in examples {
941            mean_vec = &mean_vec + &ex.features.values;
942        }
943        mean_vec.mapv_inplace(|v| v / n_f);
944
945        let mut var_vec = Array1::<f64>::zeros(NUM_FEATURES);
946        for ex in examples {
947            let diff = &ex.features.values - &mean_vec;
948            var_vec = &var_vec + &diff.mapv(|d| d * d);
949        }
950        var_vec.mapv_inplace(|v| v / n_f);
951        let std_vec = var_vec.mapv(f64::sqrt);
952
953        self.feature_mean = mean_vec;
954        self.feature_std = std_vec;
955
956        // Standardize all rows once.
957        let xs: Vec<Array1<f64>> = examples
958            .iter()
959            .map(|ex| self.standardize(&ex.features.values))
960            .collect();
961        let y_class: Vec<usize> = examples
962            .iter()
963            .map(|ex| ex.best_optimizer.index())
964            .collect();
965        let y_lr: Vec<f64> = examples
966            .iter()
967            .map(|ex| ex.best_learning_rate.log10())
968            .collect();
969
970        self.train_classifier(&xs, &y_class);
971        self.train_regressor(&xs, &y_lr);
972        self.fitted = true;
973        Ok(())
974    }
975
976    /// Train the softmax classifier by full-batch gradient descent on cross-entropy.
977    fn train_classifier(&mut self, xs: &[Array1<f64>], y: &[usize]) {
978        let n = xs.len().max(1) as f64;
979        let lr = self.config.classifier_lr;
980        let l2 = self.config.classifier_l2;
981        let mut w = Array2::<f64>::zeros((NUM_CLASSES, NUM_FEATURES));
982        let mut b = Array1::<f64>::zeros(NUM_CLASSES);
983
984        for _ in 0..self.config.classifier_epochs {
985            let mut grad_w = Array2::<f64>::zeros((NUM_CLASSES, NUM_FEATURES));
986            let mut grad_b = Array1::<f64>::zeros(NUM_CLASSES);
987
988            for (x, &yi) in xs.iter().zip(y.iter()) {
989                let logits =
990                    Array1::from_shape_fn(NUM_CLASSES, |c| linear_score(&w.row(c), x) + b[c]);
991                let probs = softmax(&logits);
992                for (c, mut row) in grad_w.outer_iter_mut().enumerate() {
993                    let target = if c == yi { 1.0 } else { 0.0 };
994                    let diff = probs[c] - target;
995                    for (slot, &xj) in row.iter_mut().zip(x.iter()) {
996                        *slot += diff * xj;
997                    }
998                    grad_b[c] += diff;
999                }
1000            }
1001
1002            grad_w.mapv_inplace(|g| g / n);
1003            grad_b.mapv_inplace(|g| g / n);
1004            grad_w = &grad_w + &w.mapv(|wv| wv * l2);
1005
1006            w = &w - &grad_w.mapv(|g| g * lr);
1007            b = &b - &grad_b.mapv(|g| g * lr);
1008        }
1009
1010        self.clf_w = w;
1011        self.clf_b = b;
1012    }
1013
1014    /// Train the linear `log10(lr)` regressor by full-batch gradient descent on MSE.
1015    fn train_regressor(&mut self, xs: &[Array1<f64>], y: &[f64]) {
1016        let n = xs.len().max(1) as f64;
1017        let lr = self.config.regressor_lr;
1018        let l2 = self.config.regressor_l2;
1019        let mut w = Array1::<f64>::zeros(NUM_FEATURES);
1020        let mut b = 0.0f64;
1021
1022        for _ in 0..self.config.regressor_epochs {
1023            let mut grad_w = Array1::<f64>::zeros(NUM_FEATURES);
1024            let mut grad_b = 0.0f64;
1025
1026            for (x, &yi) in xs.iter().zip(y.iter()) {
1027                let pred = dot(&w, x) + b;
1028                let err = pred - yi;
1029                for (slot, &xj) in grad_w.iter_mut().zip(x.iter()) {
1030                    *slot += err * xj;
1031                }
1032                grad_b += err;
1033            }
1034
1035            let scale = 2.0 / n;
1036            grad_w.mapv_inplace(|g| g * scale);
1037            grad_b *= scale;
1038            grad_w = &grad_w + &w.mapv(|wv| wv * (2.0 * l2));
1039
1040            w = &w - &grad_w.mapv(|g| g * lr);
1041            b -= grad_b * lr;
1042        }
1043
1044        self.reg_w = w;
1045        self.reg_b = b;
1046    }
1047
1048    // -- zero-shot inference ------------------------------------------------------------
1049
1050    /// Recommend an optimizer and hyper-parameters for a brand-new task.
1051    ///
1052    /// Extracts meta-features from the probe, standardizes them with the fitted statistics,
1053    /// and runs the classifier + regressor. Returns an error if the selector has not been
1054    /// fit yet.
1055    pub fn recommend<P>(&self, probe: &P) -> Result<OptimizerRecommendation>
1056    where
1057        P: TaskProbe + ?Sized,
1058    {
1059        if !self.fitted {
1060            return Err(OptimError::InvalidState(
1061                "ZeroShotSelector must be fit before calling recommend".to_string(),
1062            ));
1063        }
1064        let features = self.extract_meta_features(probe)?;
1065        self.predict(&features)
1066    }
1067
1068    /// Predict directly from an already-extracted meta-feature vector.
1069    ///
1070    /// Returns an error if the selector has not been fit yet.
1071    pub fn predict(&self, features: &MetaFeatures) -> Result<OptimizerRecommendation> {
1072        if !self.fitted {
1073            return Err(OptimError::InvalidState(
1074                "ZeroShotSelector must be fit before calling predict".to_string(),
1075            ));
1076        }
1077        if features.values.len() != NUM_FEATURES {
1078            return Err(OptimError::InvalidConfig(
1079                "meta-feature vector has the wrong length".to_string(),
1080            ));
1081        }
1082
1083        let xs = self.standardize(&features.values);
1084
1085        let logits = Array1::from_shape_fn(NUM_CLASSES, |c| {
1086            linear_score(&self.clf_w.row(c), &xs) + self.clf_b[c]
1087        });
1088        let probs = softmax(&logits);
1089        let (class_index, confidence) = argmax(&probs);
1090        let optimizer = OptimizerKind::from_index(class_index)?;
1091
1092        let log_lr = dot(&self.reg_w, &xs) + self.reg_b;
1093        let learning_rate = if log_lr.is_finite() {
1094            10f64
1095                .powf(log_lr)
1096                .clamp(self.config.min_learning_rate, self.config.max_learning_rate)
1097        } else {
1098            // Non-finite prediction: fall back to the geometric midpoint of the range.
1099            (self.config.min_learning_rate * self.config.max_learning_rate).sqrt()
1100        };
1101
1102        Ok(OptimizerRecommendation {
1103            optimizer,
1104            learning_rate,
1105            hyperparameters: optimizer.default_hyperparameters(),
1106            confidence: confidence.clamp(0.0, 1.0),
1107        })
1108    }
1109
1110    // -- persistence --------------------------------------------------------------------
1111
1112    /// Serialize the fitted selector (including its learned parameters) to JSON on disk.
1113    pub fn save_json(&self, path: &Path) -> Result<()> {
1114        let serialized = serde_json::to_string_pretty(self)?;
1115        std::fs::write(path, serialized)?;
1116        Ok(())
1117    }
1118
1119    /// Load a previously-saved selector from JSON on disk.
1120    ///
1121    /// The deserialized value is validated before it is handed back: JSON is
1122    /// structurally permissive, so a file that parses can still carry a weight
1123    /// matrix of the wrong shape, a zero/negative feature standard deviation
1124    /// (which would divide by zero during standardization) or a non-finite
1125    /// parameter. Those used to sail through and panic — or silently produce
1126    /// garbage predictions — at the first `predict` call.
1127    ///
1128    /// # Errors
1129    /// Returns `Err` on I/O failure, on malformed JSON, or when
1130    /// [`Self::validate_parameters`] rejects the loaded parameters.
1131    pub fn load_json(path: &Path) -> Result<Self> {
1132        let serialized = std::fs::read_to_string(path)?;
1133        let selector: ZeroShotSelector = serde_json::from_str(&serialized)?;
1134        selector.validate_parameters()?;
1135        Ok(selector)
1136    }
1137
1138    /// Check that the learned parameters are internally consistent and usable.
1139    ///
1140    /// Verifies the configuration, every array shape against `NUM_FEATURES` /
1141    /// `NUM_CLASSES`, that no parameter is `NaN`/infinite, and that every feature
1142    /// standard deviation is strictly positive.
1143    ///
1144    /// # Errors
1145    /// Returns `Err` describing the first inconsistency found.
1146    pub fn validate_parameters(&self) -> Result<()> {
1147        self.config.validate()?;
1148
1149        let shape_err = |what: &str, got: String, want: String| {
1150            OptimError::InvalidConfig(format!(
1151                "loaded selector has {what} of shape {got}, expected {want}"
1152            ))
1153        };
1154        if self.feature_mean.len() != NUM_FEATURES {
1155            return Err(shape_err(
1156                "feature_mean",
1157                self.feature_mean.len().to_string(),
1158                NUM_FEATURES.to_string(),
1159            ));
1160        }
1161        if self.feature_std.len() != NUM_FEATURES {
1162            return Err(shape_err(
1163                "feature_std",
1164                self.feature_std.len().to_string(),
1165                NUM_FEATURES.to_string(),
1166            ));
1167        }
1168        if self.clf_w.dim() != (NUM_CLASSES, NUM_FEATURES) {
1169            return Err(shape_err(
1170                "clf_w",
1171                format!("{:?}", self.clf_w.dim()),
1172                format!("({NUM_CLASSES}, {NUM_FEATURES})"),
1173            ));
1174        }
1175        if self.clf_b.len() != NUM_CLASSES {
1176            return Err(shape_err(
1177                "clf_b",
1178                self.clf_b.len().to_string(),
1179                NUM_CLASSES.to_string(),
1180            ));
1181        }
1182        if self.reg_w.len() != NUM_FEATURES {
1183            return Err(shape_err(
1184                "reg_w",
1185                self.reg_w.len().to_string(),
1186                NUM_FEATURES.to_string(),
1187            ));
1188        }
1189
1190        // A standard deviation of exactly zero is a legitimate state: it means a
1191        // zero-variance feature, and `standardize` floors it at `STD_FLOOR` and
1192        // divides by one. A *negative* or non-finite deviation, though, cannot
1193        // come from `fit` and would silently corrupt every prediction.
1194        for (i, &s) in self.feature_std.iter().enumerate() {
1195            if !s.is_finite() || s < 0.0 {
1196                return Err(OptimError::InvalidConfig(format!(
1197                    "loaded selector has feature_std[{i}] = {s}; a standard \
1198                     deviation must be finite and non-negative"
1199                )));
1200            }
1201        }
1202
1203        let non_finite = self
1204            .feature_mean
1205            .iter()
1206            .chain(self.clf_w.iter())
1207            .chain(self.clf_b.iter())
1208            .chain(self.reg_w.iter())
1209            .chain(std::iter::once(&self.reg_b))
1210            .any(|v| !v.is_finite());
1211        if non_finite {
1212            return Err(OptimError::InvalidConfig(
1213                "loaded selector contains a non-finite parameter".to_string(),
1214            ));
1215        }
1216
1217        Ok(())
1218    }
1219}
1220
1221// ---------------------------------------------------------------------------------------
1222// Numeric helpers
1223// ---------------------------------------------------------------------------------------
1224
1225/// Deterministically mix a base seed with a sample index into a fresh seed.
1226fn mix_seed(base: u64, index: u64) -> u64 {
1227    let mut h = base ^ 0x9E37_79B9_7F4A_7C15;
1228    h = h.wrapping_add(index.wrapping_add(1).wrapping_mul(0xBF58_476D_1CE4_E5B9));
1229    h ^= h >> 27;
1230    h = h.wrapping_mul(0x94D0_49BB_1331_11EB);
1231    h ^ (h >> 31)
1232}
1233
1234/// Euclidean (L2) norm.
1235fn l2_norm(arr: &Array1<f64>) -> f64 {
1236    arr.iter().fold(0.0, |acc, &x| acc + x * x).sqrt()
1237}
1238
1239/// Inner product of two equally-sized vectors (extra elements of the longer are ignored).
1240fn dot(a: &Array1<f64>, b: &Array1<f64>) -> f64 {
1241    a.iter()
1242        .zip(b.iter())
1243        .fold(0.0, |acc, (&x, &y)| acc + x * y)
1244}
1245
1246/// Inner product of a matrix-row view with a vector.
1247fn linear_score(row: &scirs2_core::ndarray::ArrayView1<f64>, x: &Array1<f64>) -> f64 {
1248    row.iter()
1249        .zip(x.iter())
1250        .fold(0.0, |acc, (&w, &v)| acc + w * v)
1251}
1252
1253/// Cosine similarity; returns `0` if either vector is (numerically) zero.
1254fn cosine_similarity(a: &Array1<f64>, b: &Array1<f64>) -> f64 {
1255    let na = l2_norm(a);
1256    let nb = l2_norm(b);
1257    if na <= EPS || nb <= EPS {
1258        0.0
1259    } else {
1260        (dot(a, b) / (na * nb)).clamp(-1.0, 1.0)
1261    }
1262}
1263
1264/// Root-mean-square of a vector.
1265fn rms(arr: &Array1<f64>) -> f64 {
1266    if arr.is_empty() {
1267        return 0.0;
1268    }
1269    (arr.iter().fold(0.0, |acc, &x| acc + x * x) / arr.len() as f64).sqrt()
1270}
1271
1272/// Arithmetic mean of a slice (`0` if empty).
1273fn mean(values: &[f64]) -> f64 {
1274    if values.is_empty() {
1275        return 0.0;
1276    }
1277    values.iter().sum::<f64>() / values.len() as f64
1278}
1279
1280/// Population standard deviation divided by the mean (`0` if the mean is ~0 or empty).
1281fn coefficient_of_variation(values: &[f64]) -> f64 {
1282    if values.is_empty() {
1283        return 0.0;
1284    }
1285    let m = mean(values);
1286    if m.abs() <= EPS {
1287        return 0.0;
1288    }
1289    let var = values.iter().fold(0.0, |acc, &x| acc + (x - m) * (x - m)) / values.len() as f64;
1290    var.sqrt() / m.abs()
1291}
1292
1293/// Fraction of components of `grad` whose magnitude is below `rel_threshold · max|grad|`.
1294fn fraction_near_zero(grad: &Array1<f64>, rel_threshold: f64) -> f64 {
1295    if grad.is_empty() {
1296        return 0.0;
1297    }
1298    let max_abs = grad.iter().fold(0.0_f64, |acc, &g| acc.max(g.abs()));
1299    if max_abs <= EPS {
1300        // An all-zero gradient is maximally sparse.
1301        return 1.0;
1302    }
1303    let threshold = rel_threshold * max_abs;
1304    let near = grad.iter().filter(|&&g| g.abs() < threshold).count();
1305    near as f64 / grad.len() as f64
1306}
1307
1308/// `ln(max_positive / min_positive)` over the curvature magnitudes (`0` if degenerate).
1309fn log_condition(curvatures: &[f64]) -> f64 {
1310    let mut max_c = 0.0_f64;
1311    let mut min_c = f64::INFINITY;
1312    for &c in curvatures {
1313        if c > EPS {
1314            if c > max_c {
1315                max_c = c;
1316            }
1317            if c < min_c {
1318                min_c = c;
1319            }
1320        }
1321    }
1322    if max_c > EPS && min_c.is_finite() && min_c > EPS {
1323        (max_c / min_c).ln()
1324    } else {
1325        0.0
1326    }
1327}
1328
1329/// Mean over components of the coefficient of variation across repeated gradient samples.
1330fn mean_component_cv(samples: &[Array1<f64>], dim: usize) -> f64 {
1331    if samples.len() < 2 || dim == 0 {
1332        return 0.0;
1333    }
1334    let n = samples.len() as f64;
1335    let mut acc = 0.0;
1336    for j in 0..dim {
1337        let mut sum = 0.0;
1338        for s in samples {
1339            sum += s.get(j).copied().unwrap_or(0.0);
1340        }
1341        let m = sum / n;
1342        let mut var = 0.0;
1343        for s in samples {
1344            let v = s.get(j).copied().unwrap_or(0.0);
1345            var += (v - m) * (v - m);
1346        }
1347        var /= n;
1348        acc += var.sqrt() / (m.abs() + EPS);
1349    }
1350    acc / dim as f64
1351}
1352
1353/// Numerically-stable softmax. The result is strictly positive and sums to one.
1354fn softmax(logits: &Array1<f64>) -> Array1<f64> {
1355    if logits.is_empty() {
1356        return Array1::zeros(0);
1357    }
1358    let max_logit = logits.iter().fold(f64::NEG_INFINITY, |acc, &x| acc.max(x));
1359    let exps = logits.mapv(|x| (x - max_logit).exp());
1360    let sum = exps.iter().sum::<f64>();
1361    if sum <= EPS {
1362        return Array1::from_elem(logits.len(), 1.0 / logits.len() as f64);
1363    }
1364    exps.mapv(|x| x / sum)
1365}
1366
1367/// Index of the maximum entry and its value (`(0, 0.0)` for an empty vector).
1368fn argmax(values: &Array1<f64>) -> (usize, f64) {
1369    let mut best_index = 0usize;
1370    let mut best_value = f64::NEG_INFINITY;
1371    for (i, &v) in values.iter().enumerate() {
1372        if v > best_value {
1373            best_value = v;
1374            best_index = i;
1375        }
1376    }
1377    if best_value.is_finite() {
1378        (best_index, best_value)
1379    } else {
1380        (0, 0.0)
1381    }
1382}
1383
1384// ---------------------------------------------------------------------------------------
1385// Tests
1386// ---------------------------------------------------------------------------------------
1387
1388#[cfg(test)]
1389mod tests {
1390    use super::*;
1391
1392    fn selector() -> ZeroShotSelector {
1393        ZeroShotSelector::with_default_config().expect("default selector")
1394    }
1395
1396    /// Build a meta-example by extracting features from a probe and labeling it.
1397    fn labeled_example(
1398        sel: &ZeroShotSelector,
1399        probe: &QuadraticProbe,
1400        kind: OptimizerKind,
1401        lr: f64,
1402    ) -> MetaExample {
1403        let feats = sel.extract_meta_features(probe).expect("extract");
1404        MetaExample::new(feats, kind, lr).expect("meta example")
1405    }
1406
1407    #[test]
1408    fn test_extract_features_on_known_quadratic_condition_number() {
1409        let sel = selector();
1410        // Curvatures [1, 1, 1, 100] => Hessian eigenvalues {1, 100} => condition 100.
1411        let curvature = Array1::from_vec(vec![1.0, 1.0, 1.0, 100.0]);
1412        let init = Array1::from_vec(vec![1.0, 1.0, 1.0, 1.0]);
1413        let probe = QuadraticProbe::new(curvature, init).expect("probe");
1414
1415        let feats = sel.extract_meta_features(&probe).expect("extract");
1416        let expected = 100f64.ln();
1417        assert!(
1418            (feats.log_condition_number() - expected).abs() < 1e-3,
1419            "log condition number should reflect curvature 100, expected {expected}, got {}",
1420            feats.log_condition_number()
1421        );
1422        // Dimensionality feature.
1423        assert!((feats.log_dimension() - 5f64.ln()).abs() < 1e-9);
1424        // Deterministic, noise-free quadratic => zero noise estimate.
1425        assert!(feats.noise_estimate().abs() < 1e-12);
1426        // All features must be finite.
1427        assert!(feats.as_array().iter().all(|v| v.is_finite()));
1428    }
1429
1430    #[test]
1431    fn test_isotropic_quadratic_is_well_conditioned() {
1432        let sel = selector();
1433        let probe = QuadraticProbe::isotropic(5, 3.0, 1.0).expect("probe");
1434        let feats = sel.extract_meta_features(&probe).expect("extract");
1435        // Equal curvatures => condition number ~1 => log ~0.
1436        assert!(
1437            feats.log_condition_number().abs() < 1e-6,
1438            "isotropic bowl should have log-condition ~ 0, got {}",
1439            feats.log_condition_number()
1440        );
1441    }
1442
1443    #[test]
1444    fn test_extraction_is_deterministic() {
1445        let sel = selector();
1446        let probe = QuadraticProbe::new(
1447            Array1::from_vec(vec![1.0, 7.0, 30.0, 2.0]),
1448            Array1::from_vec(vec![0.5, -1.0, 2.0, 1.5]),
1449        )
1450        .expect("probe");
1451        let a = sel.extract_meta_features(&probe).expect("extract a");
1452        let b = sel.extract_meta_features(&probe).expect("extract b");
1453        assert_eq!(a, b, "extraction must be deterministic");
1454    }
1455
1456    #[test]
1457    fn test_noise_feature_detects_stochastic_probe() {
1458        let sel = selector();
1459        let curvature = Array1::from_vec(vec![1.0, 2.0, 3.0]);
1460        let init = Array1::from_vec(vec![1.0, 1.0, 1.0]);
1461
1462        let det = QuadraticProbe::new(curvature.clone(), init.clone()).expect("det");
1463        let det_feats = sel.extract_meta_features(&det).expect("extract det");
1464        assert!(det_feats.noise_estimate().abs() < 1e-12);
1465
1466        let noisy = QuadraticProbe::new(curvature, init)
1467            .expect("noisy")
1468            .with_noise(0.5, 123);
1469        let noisy_feats_a = sel.extract_meta_features(&noisy).expect("extract noisy a");
1470        let noisy_feats_b = sel.extract_meta_features(&noisy).expect("extract noisy b");
1471        assert!(
1472            noisy_feats_a.noise_estimate() > 1e-3,
1473            "stochastic probe should have a positive noise estimate, got {}",
1474            noisy_feats_a.noise_estimate()
1475        );
1476        // Index-seeded samples keep extraction reproducible even for a noisy oracle.
1477        assert_eq!(noisy_feats_a, noisy_feats_b);
1478    }
1479
1480    #[test]
1481    fn test_grad_sparsity_detects_zero_components() {
1482        let sel = selector();
1483        // A coordinate already at its optimum contributes a zero gradient component.
1484        let curvature = Array1::from_vec(vec![1.0, 1.0, 1.0, 1.0]);
1485        let init = Array1::from_vec(vec![0.0, 0.0, 1.0, 1.0]);
1486        let probe = QuadraticProbe::new(curvature, init).expect("probe");
1487        let feats = sel.extract_meta_features(&probe).expect("extract");
1488        assert!(
1489            feats.grad_sparsity() >= 0.5 - 1e-9,
1490            "two of four components are zero, sparsity should be >= 0.5, got {}",
1491            feats.grad_sparsity()
1492        );
1493    }
1494
1495    #[test]
1496    fn test_rosenbrock_probe_extracts_finite_features() {
1497        let sel = selector();
1498        let probe = RosenbrockProbe::new(Array1::from_vec(vec![-1.2, 1.0, -0.5])).expect("probe");
1499        let feats = sel.extract_meta_features(&probe).expect("extract");
1500        assert!(feats.as_array().iter().all(|v| v.is_finite()));
1501        // Rosenbrock is strongly ill-conditioned => sizeable log condition number.
1502        assert!(feats.log_condition_number() > 0.5);
1503    }
1504
1505    #[test]
1506    fn test_recommend_before_fit_errors() {
1507        let sel = selector();
1508        let probe = QuadraticProbe::isotropic(4, 1.0, 1.0).expect("probe");
1509        let result = sel.recommend(&probe);
1510        assert!(result.is_err(), "recommend before fit must error");
1511
1512        let feats = sel.extract_meta_features(&probe).expect("extract");
1513        assert!(
1514            sel.predict(&feats).is_err(),
1515            "predict before fit must error"
1516        );
1517    }
1518
1519    #[test]
1520    fn test_fit_rejects_empty_dataset() {
1521        let mut sel = selector();
1522        assert!(sel.fit(&[]).is_err(), "fit on empty dataset must error");
1523    }
1524
1525    /// The central behavioral test: after fitting on a meta-dataset following the rule
1526    /// "ill-conditioned => Adam, well-conditioned => SGD", held-out probes obeying the
1527    /// rule are classified accordingly.
1528    #[test]
1529    fn test_rule_illconditioned_adam_wellconditioned_sgd() {
1530        let sel = selector();
1531        let dim = 5usize;
1532        let mut examples: Vec<MetaExample> = Vec::new();
1533
1534        // Well-conditioned (isotropic) training tasks => SGD with a larger learning rate.
1535        for k in 0..8 {
1536            let c = 1.0 + 0.1 * k as f64;
1537            let probe = QuadraticProbe::isotropic(dim, c, 1.0 + 0.05 * k as f64).expect("probe");
1538            examples.push(labeled_example(&sel, &probe, OptimizerKind::Sgd, 0.05));
1539        }
1540        // Ill-conditioned (anisotropic) training tasks => Adam with a smaller learning rate.
1541        for k in 0..8 {
1542            let big = 50.0 + 15.0 * k as f64;
1543            let mut curvature = Array1::from_elem(dim, 1.0);
1544            curvature[dim - 1] = big;
1545            let init = Array1::from_elem(dim, 1.0 + 0.05 * k as f64);
1546            let probe = QuadraticProbe::new(curvature, init).expect("probe");
1547            examples.push(labeled_example(&sel, &probe, OptimizerKind::Adam, 0.001));
1548        }
1549
1550        let mut fitted = selector();
1551        fitted.fit(&examples).expect("fit");
1552        assert!(fitted.is_fitted());
1553
1554        // Held-out ill-conditioned probe (condition 120, not present in training).
1555        let mut ill_curv = Array1::from_elem(dim, 1.0);
1556        ill_curv[dim - 1] = 120.0;
1557        let ill_probe = QuadraticProbe::new(ill_curv, Array1::from_elem(dim, 1.3)).expect("probe");
1558        let ill_rec = fitted.recommend(&ill_probe).expect("recommend ill");
1559        assert_eq!(
1560            ill_rec.optimizer,
1561            OptimizerKind::Adam,
1562            "ill-conditioned held-out task should map to Adam"
1563        );
1564
1565        // Held-out well-conditioned probe (isotropic, not present in training).
1566        let well_probe = QuadraticProbe::isotropic(dim, 2.0, 0.8).expect("probe");
1567        let well_rec = fitted.recommend(&well_probe).expect("recommend well");
1568        assert_eq!(
1569            well_rec.optimizer,
1570            OptimizerKind::Sgd,
1571            "well-conditioned held-out task should map to SGD"
1572        );
1573
1574        // Confidence is a probability.
1575        for rec in [&ill_rec, &well_rec] {
1576            assert!(
1577                (0.0..=1.0).contains(&rec.confidence),
1578                "confidence must be in [0, 1], got {}",
1579                rec.confidence
1580            );
1581            assert!(rec.confidence >= 0.5, "clear cases should be confident");
1582        }
1583
1584        // The learning-rate regressor should order the rates as in the meta-dataset.
1585        assert!(
1586            ill_rec.learning_rate < well_rec.learning_rate,
1587            "ill-conditioned lr ({}) should be below well-conditioned lr ({})",
1588            ill_rec.learning_rate,
1589            well_rec.learning_rate
1590        );
1591        assert!(
1592            ill_rec.learning_rate >= fitted.config().min_learning_rate
1593                && well_rec.learning_rate <= fitted.config().max_learning_rate,
1594            "learning rates must stay within the configured range"
1595        );
1596    }
1597
1598    #[test]
1599    fn test_logistic_regression_separates_synthetic_features() {
1600        // Construct meta-examples directly (bypassing extraction) with a single strongly
1601        // discriminative feature (the condition number, index 5) and verify the trained
1602        // classifier separates two classes on held-out feature vectors.
1603        let make = |cond: f64, kind: OptimizerKind| -> MetaExample {
1604            let mut v = vec![1.0; NUM_FEATURES];
1605            v[5] = cond;
1606            MetaExample::new(
1607                MetaFeatures::from_values(Array1::from_vec(v)).expect("feats"),
1608                kind,
1609                0.01,
1610            )
1611            .expect("example")
1612        };
1613
1614        let mut examples = Vec::new();
1615        for k in 0..6 {
1616            examples.push(make(0.0 + 0.05 * k as f64, OptimizerKind::Sgd));
1617            examples.push(make(4.0 + 0.05 * k as f64, OptimizerKind::Adam));
1618        }
1619
1620        let mut sel = selector();
1621        sel.fit(&examples).expect("fit");
1622
1623        let mut low = vec![1.0; NUM_FEATURES];
1624        low[5] = 0.1;
1625        let low_rec = sel
1626            .predict(&MetaFeatures::from_values(Array1::from_vec(low)).expect("feats"))
1627            .expect("predict low");
1628        assert_eq!(low_rec.optimizer, OptimizerKind::Sgd);
1629
1630        let mut high = vec![1.0; NUM_FEATURES];
1631        high[5] = 4.2;
1632        let high_rec = sel
1633            .predict(&MetaFeatures::from_values(Array1::from_vec(high)).expect("feats"))
1634            .expect("predict high");
1635        assert_eq!(high_rec.optimizer, OptimizerKind::Adam);
1636    }
1637
1638    #[test]
1639    fn test_standardization_zero_variance_no_nan() {
1640        // Every example shares an identical feature vector => every feature has zero
1641        // variance. Standardization must not produce NaN/inf.
1642        let v = Array1::from_vec(vec![2.0; NUM_FEATURES]);
1643        let examples: Vec<MetaExample> = (0..4)
1644            .map(|_| {
1645                MetaExample::new(
1646                    MetaFeatures::from_values(v.clone()).expect("feats"),
1647                    OptimizerKind::RmsProp,
1648                    0.01,
1649                )
1650                .expect("example")
1651            })
1652            .collect();
1653
1654        let mut sel = selector();
1655        sel.fit(&examples).expect("fit");
1656
1657        let standardized = sel.standardize(&v);
1658        assert!(
1659            standardized.iter().all(|x| x.is_finite()),
1660            "zero-variance standardization must stay finite"
1661        );
1662        // On a training point each zero-variance feature standardizes exactly to zero.
1663        assert!(standardized.iter().all(|&x| x.abs() < 1e-12));
1664
1665        let rec = sel
1666            .predict(&MetaFeatures::from_values(v).expect("feats"))
1667            .expect("predict");
1668        assert!(rec.learning_rate.is_finite() && rec.confidence.is_finite());
1669        // With a single class present it must be the recommendation.
1670        assert_eq!(rec.optimizer, OptimizerKind::RmsProp);
1671    }
1672
1673    #[test]
1674    fn test_meta_features_length_validation() {
1675        let too_short = Array1::from_vec(vec![0.0; NUM_FEATURES - 1]);
1676        assert!(MetaFeatures::from_values(too_short).is_err());
1677        let ok = Array1::from_vec(vec![0.0; NUM_FEATURES]);
1678        assert!(MetaFeatures::from_values(ok).is_ok());
1679    }
1680
1681    #[test]
1682    fn test_meta_example_rejects_bad_learning_rate() {
1683        let feats =
1684            MetaFeatures::from_values(Array1::from_vec(vec![0.0; NUM_FEATURES])).expect("feats");
1685        assert!(MetaExample::new(feats.clone(), OptimizerKind::Sgd, 0.0).is_err());
1686        assert!(MetaExample::new(feats.clone(), OptimizerKind::Sgd, -1.0).is_err());
1687        assert!(MetaExample::new(feats, OptimizerKind::Sgd, 0.01).is_ok());
1688    }
1689
1690    #[test]
1691    fn test_optimizer_kind_index_roundtrip() {
1692        for kind in OptimizerKind::all() {
1693            let idx = kind.index();
1694            assert_eq!(OptimizerKind::from_index(idx).expect("roundtrip"), kind);
1695        }
1696        assert!(OptimizerKind::from_index(NUM_CLASSES).is_err());
1697    }
1698
1699    #[test]
1700    fn test_config_validation_rejects_bad_inputs() {
1701        let bad_points = ZeroShotConfig {
1702            num_probe_points: 0,
1703            ..ZeroShotConfig::default()
1704        };
1705        assert!(bad_points.validate().is_err());
1706
1707        let bad_range = ZeroShotConfig {
1708            min_learning_rate: 1.0,
1709            max_learning_rate: 0.1,
1710            ..ZeroShotConfig::default()
1711        };
1712        assert!(bad_range.validate().is_err());
1713
1714        let bad_epochs = ZeroShotConfig {
1715            classifier_epochs: 0,
1716            ..ZeroShotConfig::default()
1717        };
1718        assert!(bad_epochs.validate().is_err());
1719
1720        assert!(ZeroShotConfig::default().validate().is_ok());
1721    }
1722
1723    #[test]
1724    fn test_softmax_is_distribution() {
1725        let logits = Array1::from_vec(vec![-1.0, 0.0, 2.0, 0.5, -3.0]);
1726        let probs = softmax(&logits);
1727        let sum: f64 = probs.iter().sum();
1728        assert!((sum - 1.0).abs() < 1e-12);
1729        assert!(probs.iter().all(|&p| p > 0.0));
1730        let (idx, _) = argmax(&probs);
1731        assert_eq!(idx, 2);
1732    }
1733
1734    #[test]
1735    fn test_save_and_load_roundtrip() {
1736        let sel = selector();
1737        let probe = QuadraticProbe::isotropic(4, 2.0, 1.0).expect("probe");
1738        let example = labeled_example(&sel, &probe, OptimizerKind::Adam, 0.01);
1739        let mut fitted = selector();
1740        fitted.fit(std::slice::from_ref(&example)).expect("fit");
1741
1742        let mut path = std::env::temp_dir();
1743        path.push(format!("optirs_zero_shot_{}.json", std::process::id()));
1744        fitted.save_json(&path).expect("save");
1745        let loaded = ZeroShotSelector::load_json(&path).expect("load");
1746        let _ = std::fs::remove_file(&path);
1747
1748        assert!(loaded.is_fitted());
1749        let original = fitted.recommend(&probe).expect("recommend original");
1750        let reloaded = loaded.recommend(&probe).expect("recommend reloaded");
1751        assert_eq!(original.optimizer, reloaded.optimizer);
1752        assert!((original.learning_rate - reloaded.learning_rate).abs() < 1e-9);
1753    }
1754
1755    /// F73: `load_json` deserialized without validating, so a file that parses
1756    /// but carries inconsistent parameters was accepted and blew up (or produced
1757    /// garbage) at the first `recommend`.
1758    #[test]
1759    fn load_json_rejects_structurally_valid_but_inconsistent_files() {
1760        let sel = selector();
1761        let probe = QuadraticProbe::isotropic(4, 2.0, 1.0).expect("probe");
1762        let example = labeled_example(&sel, &probe, OptimizerKind::Adam, 0.01);
1763        let mut fitted = selector();
1764        fitted.fit(std::slice::from_ref(&example)).expect("fit");
1765
1766        let mut dir = std::env::temp_dir();
1767        dir.push(format!("optirs_zero_shot_bad_{}", std::process::id()));
1768        let _ = std::fs::create_dir_all(&dir);
1769
1770        let good = serde_json::to_string(&fitted).expect("serialize");
1771        let json: serde_json::Value = serde_json::from_str(&good).expect("parse");
1772
1773        // 1. A negative standard deviation cannot come from `fit` and would
1774        //    invert every standardized feature. (Zero is legitimate — it means a
1775        //    zero-variance feature, which `standardize` floors — so it must
1776        //    still load.)
1777        {
1778            let mut broken = json.clone();
1779            broken["feature_std"]["data"][0] = serde_json::json!(-1.5);
1780            let path = dir.join("negative_std.json");
1781            std::fs::write(&path, broken.to_string()).expect("write");
1782            assert!(
1783                ZeroShotSelector::load_json(&path).is_err(),
1784                "a negative feature_std must be rejected"
1785            );
1786
1787            let mut zeroed = json.clone();
1788            zeroed["feature_std"]["data"][0] = serde_json::json!(0.0);
1789            let ok_path = dir.join("zero_std.json");
1790            std::fs::write(&ok_path, zeroed.to_string()).expect("write");
1791            assert!(
1792                ZeroShotSelector::load_json(&ok_path).is_ok(),
1793                "a zero feature_std is a legitimate zero-variance feature"
1794            );
1795        }
1796
1797        // 2. A truncated weight vector.
1798        {
1799            let mut broken = json.clone();
1800            if let Some(arr) = broken["reg_w"]["data"].as_array_mut() {
1801                arr.truncate(2);
1802            }
1803            let path = dir.join("short_reg_w.json");
1804            std::fs::write(&path, broken.to_string()).expect("write");
1805            assert!(
1806                ZeroShotSelector::load_json(&path).is_err(),
1807                "a mis-shaped reg_w must be rejected"
1808            );
1809        }
1810
1811        // 3. A non-finite parameter. JSON has no literal for infinity, so this
1812        //    exercises the guard directly on the loaded struct — which is the
1813        //    same check `load_json` runs.
1814        {
1815            let mut corrupted = fitted.clone();
1816            corrupted.reg_b = f64::INFINITY;
1817            assert!(
1818                corrupted.validate_parameters().is_err(),
1819                "an infinite reg_b must be rejected"
1820            );
1821
1822            let mut nan_weight = fitted.clone();
1823            nan_weight.clf_w[[0, 0]] = f64::NAN;
1824            assert!(
1825                nan_weight.validate_parameters().is_err(),
1826                "a NaN classifier weight must be rejected"
1827            );
1828        }
1829
1830        // The freshly fitted selector itself must of course validate.
1831        assert!(fitted.validate_parameters().is_ok());
1832        let _ = &json;
1833
1834        // The unmodified file still loads.
1835        let path = dir.join("good.json");
1836        std::fs::write(&path, good).expect("write");
1837        assert!(ZeroShotSelector::load_json(&path).is_ok());
1838
1839        let _ = std::fs::remove_dir_all(&dir);
1840    }
1841}