1use crate::error::{OptimError, Result};
71use scirs2_core::ndarray::{Array1, Array2};
72use scirs2_core::random::Random;
73use serde::{Deserialize, Serialize};
74use std::path::Path;
75
76pub const NUM_FEATURES: usize = 9;
78
79pub const NUM_CLASSES: usize = 5;
81
82pub 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
95const EPS: f64 = 1e-12;
97
98const STD_FLOOR: f64 = 1e-8;
101
102#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
108pub enum OptimizerKind {
109 Sgd,
111 Momentum,
113 Adam,
115 RmsProp,
117 Adagrad,
119}
120
121impl OptimizerKind {
122 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 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 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 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 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#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
215pub struct OptimizerHyperparameters {
216 pub momentum: f64,
218 pub beta1: f64,
220 pub beta2: f64,
222 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
237pub trait TaskProbe {
247 fn dimension(&self) -> usize;
249
250 fn gradient_at(&self, params: &Array1<f64>) -> Array1<f64>;
252
253 fn initial_params(&self) -> Array1<f64>;
255
256 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#[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 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 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 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 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#[derive(Debug, Clone)]
386pub struct RosenbrockProbe {
387 dim: usize,
388 a: f64,
389 b: f64,
390 init: Array1<f64>,
391}
392
393impl RosenbrockProbe {
394 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
444pub struct MetaFeatures {
445 pub values: Array1<f64>,
447}
448
449impl MetaFeatures {
450 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 pub fn as_array(&self) -> &Array1<f64> {
463 &self.values
464 }
465
466 pub fn names() -> [&'static str; NUM_FEATURES] {
468 FEATURE_NAMES
469 }
470
471 pub fn log_dimension(&self) -> f64 {
473 self.values[0]
474 }
475
476 pub fn grad_norm_mean(&self) -> f64 {
478 self.values[1]
479 }
480
481 pub fn grad_norm_cv(&self) -> f64 {
483 self.values[2]
484 }
485
486 pub fn grad_sparsity(&self) -> f64 {
488 self.values[3]
489 }
490
491 pub fn mean_log_curvature(&self) -> f64 {
493 self.values[4]
494 }
495
496 pub fn log_condition_number(&self) -> f64 {
499 self.values[5]
500 }
501
502 pub fn direction_stability(&self) -> f64 {
504 self.values[6]
505 }
506
507 pub fn noise_estimate(&self) -> f64 {
509 self.values[7]
510 }
511
512 pub fn log_param_scale(&self) -> f64 {
514 self.values[8]
515 }
516}
517
518#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
521pub struct MetaExample {
522 pub features: MetaFeatures,
524 pub best_optimizer: OptimizerKind,
526 pub best_learning_rate: f64,
528}
529
530impl MetaExample {
531 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
556pub struct OptimizerRecommendation {
557 pub optimizer: OptimizerKind,
559 pub learning_rate: f64,
561 pub hyperparameters: OptimizerHyperparameters,
563 pub confidence: f64,
565}
566
567#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
573pub struct ZeroShotConfig {
574 pub num_probe_points: usize,
576 pub probe_step: f64,
578 pub num_curvature_directions: usize,
580 pub num_random_curvature_dirs: usize,
582 pub finite_diff_eps: f64,
584 pub sparsity_rel_threshold: f64,
586 pub noise_repeats: usize,
588 pub classifier_epochs: usize,
590 pub classifier_lr: f64,
592 pub classifier_l2: f64,
594 pub regressor_epochs: usize,
596 pub regressor_lr: f64,
598 pub regressor_l2: f64,
600 pub min_learning_rate: f64,
602 pub max_learning_rate: f64,
604 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
692pub struct ZeroShotSelector {
693 config: ZeroShotConfig,
694 fitted: bool,
695 feature_mean: Array1<f64>,
697 feature_std: Array1<f64>,
699 clf_w: Array2<f64>,
701 clf_b: Array1<f64>,
703 reg_w: Array1<f64>,
705 reg_b: f64,
707}
708
709impl ZeroShotSelector {
710 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 pub fn with_default_config() -> Result<Self> {
727 Self::new(ZeroShotConfig::default())
728 }
729
730 pub fn is_fitted(&self) -> bool {
732 self.fitted
733 }
734
735 pub fn config(&self) -> &ZeroShotConfig {
737 &self.config
738 }
739
740 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 (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 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 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 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 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
1221fn 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
1234fn l2_norm(arr: &Array1<f64>) -> f64 {
1236 arr.iter().fold(0.0, |acc, &x| acc + x * x).sqrt()
1237}
1238
1239fn 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
1246fn 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
1253fn 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
1264fn 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
1272fn 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
1280fn 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
1293fn 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 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
1308fn 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
1329fn 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
1353fn 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
1367fn 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#[cfg(test)]
1389mod tests {
1390 use super::*;
1391
1392 fn selector() -> ZeroShotSelector {
1393 ZeroShotSelector::with_default_config().expect("default selector")
1394 }
1395
1396 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 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 assert!((feats.log_dimension() - 5f64.ln()).abs() < 1e-9);
1424 assert!(feats.noise_estimate().abs() < 1e-12);
1426 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 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 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 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 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 #[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 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 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 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 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 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 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 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 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 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 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 #[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 {
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 {
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 {
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 assert!(fitted.validate_parameters().is_ok());
1832 let _ = &json;
1833
1834 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}