1const OP_GE: &str = ">=";
13const OP_LE: &str = "<=";
14const OP_EQ: &str = "==";
15
16#[derive(Clone, Debug, Default, PartialEq)]
22pub enum OptimizationObjective {
23 #[default]
25 Minimize,
26 Maximize,
29 Satisfy(Vec<String>),
31}
32
33#[derive(Clone, Debug, PartialEq)]
42pub struct SymbolicConstraint {
43 pub name: String,
45 pub expression: String,
47 pub weight: f64,
49 pub is_hard: bool,
52}
53
54impl SymbolicConstraint {
55 pub fn new(
57 name: impl Into<String>,
58 expression: impl Into<String>,
59 weight: f64,
60 is_hard: bool,
61 ) -> Self {
62 SymbolicConstraint {
63 name: name.into(),
64 expression: expression.into(),
65 weight,
66 is_hard,
67 }
68 }
69
70 pub fn hard(name: impl Into<String>, expression: impl Into<String>, weight: f64) -> Self {
72 Self::new(name, expression, weight, true)
73 }
74
75 pub fn soft(name: impl Into<String>, expression: impl Into<String>, weight: f64) -> Self {
77 Self::new(name, expression, weight, false)
78 }
79}
80
81#[derive(Clone, Debug, PartialEq)]
87pub struct ParameterVector {
88 pub values: Vec<f64>,
90 pub names: Vec<String>,
92}
93
94impl ParameterVector {
95 pub fn new(names: Vec<String>, values: Vec<f64>) -> Self {
100 ParameterVector { names, values }
101 }
102
103 pub fn get(&self, name: &str) -> Option<f64> {
105 self.names
106 .iter()
107 .position(|n| n == name)
108 .map(|i| self.values[i])
109 }
110
111 pub fn set(&mut self, name: &str, value: f64) -> bool {
115 if let Some(i) = self.names.iter().position(|n| n == name) {
116 self.values[i] = value;
117 true
118 } else {
119 false
120 }
121 }
122
123 pub fn l2_norm(&self) -> f64 {
125 self.values.iter().map(|v| v * v).sum::<f64>().sqrt()
126 }
127
128 pub fn len(&self) -> usize {
130 self.values.len().min(self.names.len())
131 }
132
133 pub fn is_empty(&self) -> bool {
135 self.len() == 0
136 }
137
138 pub fn iter(&self) -> impl Iterator<Item = (&str, f64)> {
140 let n = self.len();
141 self.names[..n]
142 .iter()
143 .map(String::as_str)
144 .zip(self.values[..n].iter().copied())
145 }
146}
147
148#[derive(Clone, Debug, PartialEq)]
155pub struct SnoOptimizationStep {
156 pub iteration: u64,
158 pub loss: f64,
160 pub gradient_norm: f64,
162 pub constraint_violations: usize,
164 pub params: ParameterVector,
166}
167
168#[derive(Clone, Debug, PartialEq)]
175pub struct SnoOptimizationResult {
176 pub success: bool,
178 pub iterations: u64,
180 pub final_loss: f64,
182 pub final_params: ParameterVector,
184 pub constraint_violations: usize,
186 pub converged: bool,
189}
190
191#[derive(Clone, Debug, PartialEq)]
197pub struct SnoOptimizerConfig {
198 pub learning_rate: f64,
200 pub max_iterations: u64,
202 pub convergence_threshold: f64,
204 pub constraint_penalty: f64,
206 pub symbolic_correction_weight: f64,
208 pub objective: OptimizationObjective,
210}
211
212impl Default for SnoOptimizerConfig {
213 fn default() -> Self {
214 SnoOptimizerConfig {
215 learning_rate: 0.01,
216 max_iterations: 1000,
217 convergence_threshold: 1e-6,
218 constraint_penalty: 10.0,
219 symbolic_correction_weight: 0.5,
220 objective: OptimizationObjective::Minimize,
221 }
222 }
223}
224
225impl SnoOptimizerConfig {
226 pub fn new() -> Self {
228 Self::default()
229 }
230
231 pub fn with_learning_rate(mut self, lr: f64) -> Self {
233 self.learning_rate = lr;
234 self
235 }
236
237 pub fn with_max_iterations(mut self, n: u64) -> Self {
239 self.max_iterations = n;
240 self
241 }
242
243 pub fn with_convergence_threshold(mut self, t: f64) -> Self {
245 self.convergence_threshold = t;
246 self
247 }
248
249 pub fn with_constraint_penalty(mut self, p: f64) -> Self {
251 self.constraint_penalty = p;
252 self
253 }
254
255 pub fn with_symbolic_correction_weight(mut self, w: f64) -> Self {
257 self.symbolic_correction_weight = w;
258 self
259 }
260
261 pub fn with_objective(mut self, obj: OptimizationObjective) -> Self {
263 self.objective = obj;
264 self
265 }
266}
267
268pub fn xorshift64(state: &mut u64) -> u64 {
276 let mut x = *state;
277 x ^= x << 13;
278 x ^= x >> 7;
279 x ^= x << 17;
280 *state = x;
281 x
282}
283
284#[derive(Clone, Debug, PartialEq)]
286pub struct ConstraintBound {
287 pub param_name: String,
289 pub operator: String,
291 pub bound: f64,
293}
294
295pub fn parse_constraint_bound(expr: &str) -> Option<ConstraintBound> {
304 let expr = expr.trim();
305 for op in &[OP_GE, OP_LE, OP_EQ] {
307 if let Some(pos) = expr.find(op) {
308 let name_part = expr[..pos].trim();
309 let val_part = expr[pos + op.len()..].trim();
310 if name_part.is_empty() || val_part.is_empty() {
311 continue;
312 }
313 if name_part.contains(' ') {
315 continue;
316 }
317 if let Ok(bound) = val_part.parse::<f64>() {
318 return Some(ConstraintBound {
319 param_name: name_part.to_owned(),
320 operator: op.to_string(),
321 bound,
322 });
323 }
324 }
325 }
326 None
327}
328
329fn violation_amount(bound: &ConstraintBound, value: f64) -> f64 {
334 match bound.operator.as_str() {
335 ">=" if value < bound.bound => bound.bound - value,
336 "<=" if value > bound.bound => value - bound.bound,
337 "==" => (value - bound.bound).abs(),
338 _ => 0.0,
339 }
340}
341
342fn clamp_to_bound(bound: &ConstraintBound, value: f64) -> f64 {
344 match bound.operator.as_str() {
345 ">=" => value.max(bound.bound),
346 "<=" => value.min(bound.bound),
347 "==" => bound.bound,
348 _ => value,
349 }
350}
351
352pub struct SymbolicNeuralOptimizer {
373 config: SnoOptimizerConfig,
374 constraints: Vec<SymbolicConstraint>,
375 history: Vec<SnoOptimizationStep>,
376 iteration: u64,
377 rng_state: u64,
378}
379
380impl SymbolicNeuralOptimizer {
381 pub fn new(config: SnoOptimizerConfig) -> Self {
385 SymbolicNeuralOptimizer {
386 config,
387 constraints: Vec::new(),
388 history: Vec::new(),
389 iteration: 0,
390 rng_state: 12345,
391 }
392 }
393
394 pub fn add_constraint(&mut self, constraint: SymbolicConstraint) {
396 self.constraints.push(constraint);
397 }
398
399 pub fn remove_constraint(&mut self, name: &str) -> bool {
403 let before = self.constraints.len();
404 self.constraints.retain(|c| c.name != name);
405 self.constraints.len() < before
406 }
407
408 pub fn constraints(&self) -> &[SymbolicConstraint] {
410 &self.constraints
411 }
412
413 pub fn history(&self) -> &[SnoOptimizationStep] {
415 &self.history
416 }
417
418 pub fn best_step(&self) -> Option<&SnoOptimizationStep> {
421 self.history.iter().min_by(|a, b| {
422 a.loss
423 .partial_cmp(&b.loss)
424 .unwrap_or(std::cmp::Ordering::Equal)
425 })
426 }
427
428 pub fn iteration(&self) -> u64 {
430 self.iteration
431 }
432
433 pub fn compute_loss(&self, params: &ParameterVector, residuals: &[f64]) -> f64 {
438 let base_loss = if residuals.is_empty() {
440 0.0
441 } else {
442 residuals.iter().map(|r| r * r).sum::<f64>() / residuals.len() as f64
443 };
444
445 let sign = match &self.config.objective {
447 OptimizationObjective::Maximize => -1.0,
448 _ => 1.0,
449 };
450
451 let penalty: f64 = self
452 .constraints
453 .iter()
454 .filter(|c| !c.is_hard)
455 .map(|c| {
456 if let Some(bound) = parse_constraint_bound(&c.expression) {
457 let val = params.get(&bound.param_name).unwrap_or(0.0);
458 let viol = violation_amount(&bound, val);
459 c.weight * self.config.constraint_penalty * viol
460 } else {
461 0.0
462 }
463 })
464 .sum();
465
466 sign * base_loss + penalty
467 }
468
469 pub fn check_constraints(&self, params: &ParameterVector) -> usize {
471 self.constraints
472 .iter()
473 .filter(|c| c.is_hard)
474 .filter(|c| {
475 if let Some(bound) = parse_constraint_bound(&c.expression) {
476 let val = params.get(&bound.param_name).unwrap_or(0.0);
477 violation_amount(&bound, val) > 0.0
478 } else {
479 false
480 }
481 })
482 .count()
483 }
484
485 pub fn step(
491 &mut self,
492 params: &ParameterVector,
493 gradient: &ParameterVector,
494 ) -> ParameterVector {
495 let lr = self.config.learning_rate;
496 let penalty = self.config.constraint_penalty;
497 let corr_w = self.config.symbolic_correction_weight;
498
499 let n = params.len();
500 let mut new_values = Vec::with_capacity(n);
501
502 for i in 0..n {
504 let val = params.values[i];
505 let grad = gradient.get(¶ms.names[i]).unwrap_or(0.0);
508 new_values.push(val - lr * grad);
509 }
510
511 let mut new_params = ParameterVector::new(params.names[..n].to_vec(), new_values);
512
513 for constraint in &self.constraints {
515 if !constraint.is_hard {
516 continue;
517 }
518 if let Some(bound) = parse_constraint_bound(&constraint.expression) {
519 let current = new_params.get(&bound.param_name).unwrap_or(f64::NAN);
520 if !current.is_nan() {
521 let clamped = clamp_to_bound(&bound, current);
522 new_params.set(&bound.param_name, clamped);
523 }
524 }
525 }
526
527 for constraint in &self.constraints {
529 if constraint.is_hard {
530 continue;
531 }
532 if let Some(bound) = parse_constraint_bound(&constraint.expression) {
533 let val = new_params.get(&bound.param_name).unwrap_or(f64::NAN);
534 if val.is_nan() {
535 continue;
536 }
537 let viol = violation_amount(&bound, val);
538 if viol <= 0.0 {
539 continue;
540 }
541 let nudge_dir = match bound.operator.as_str() {
543 ">=" => 1.0, "<=" => -1.0, "==" => {
546 if val < bound.bound {
547 1.0
548 } else {
549 -1.0
550 }
551 }
552 _ => 0.0,
553 };
554 let nudge = nudge_dir * constraint.weight * penalty * corr_w * viol;
555 new_params.set(&bound.param_name, val + nudge);
556 }
557 }
558
559 self.iteration += 1;
560 new_params
561 }
562
563 pub fn optimize(
574 &mut self,
575 initial_params: ParameterVector,
576 loss_fn: &dyn Fn(&ParameterVector) -> (f64, ParameterVector),
577 ) -> SnoOptimizationResult {
578 let mut params = initial_params;
579 let mut prev_loss = f64::INFINITY;
580 let mut converged = false;
581
582 self.history.clear();
583 self.iteration = 0;
584
585 for _iter in 0..self.config.max_iterations {
586 let (loss, gradient) = loss_fn(¶ms);
587
588 let grad_norm = gradient.l2_norm();
589 let violations = self.check_constraints(¶ms);
590
591 let step_record = SnoOptimizationStep {
592 iteration: self.iteration,
593 loss,
594 gradient_norm: grad_norm,
595 constraint_violations: violations,
596 params: params.clone(),
597 };
598 self.history.push(step_record);
599
600 if (prev_loss - loss).abs() < self.config.convergence_threshold {
602 converged = true;
603 break;
604 }
605 prev_loss = loss;
606
607 params = self.step(¶ms, &gradient);
608 }
609
610 let (final_loss, _) = loss_fn(¶ms);
613 let final_violations = self.check_constraints(¶ms);
614
615 SnoOptimizationResult {
616 success: true,
617 iterations: self.iteration,
618 final_loss,
619 final_params: params,
620 constraint_violations: final_violations,
621 converged,
622 }
623 }
624
625 pub fn rng_state(&self) -> u64 {
627 self.rng_state
628 }
629
630 pub fn rand_u64(&mut self) -> u64 {
632 xorshift64(&mut self.rng_state)
633 }
634
635 pub fn rand_f64(&mut self) -> f64 {
637 let r = xorshift64(&mut self.rng_state);
638 (r as f64) / (u64::MAX as f64)
639 }
640
641 pub fn reset(&mut self) {
644 self.history.clear();
645 self.iteration = 0;
646 }
647
648 pub fn config(&self) -> &SnoOptimizerConfig {
650 &self.config
651 }
652}
653
654#[cfg(test)]
659mod tests {
660 use super::*;
661
662 fn default_config() -> SnoOptimizerConfig {
667 SnoOptimizerConfig::default()
668 }
669
670 fn optimizer() -> SymbolicNeuralOptimizer {
671 SymbolicNeuralOptimizer::new(default_config())
672 }
673
674 fn params(names: &[&str], values: &[f64]) -> ParameterVector {
675 ParameterVector::new(
676 names.iter().map(|s| s.to_string()).collect(),
677 values.to_vec(),
678 )
679 }
680
681 fn gradient_from(names: &[&str], grads: &[f64]) -> ParameterVector {
682 params(names, grads)
683 }
684
685 #[test]
690 fn test_parameter_vector_new() {
691 let pv = params(&["x", "y"], &[1.0, 2.0]);
692 assert_eq!(pv.len(), 2);
693 assert!(!pv.is_empty());
694 }
695
696 #[test]
697 fn test_parameter_vector_get_existing() {
698 let pv = params(&["x", "y"], &[3.0, 7.0]);
699 assert_eq!(pv.get("x"), Some(3.0));
700 assert_eq!(pv.get("y"), Some(7.0));
701 }
702
703 #[test]
704 fn test_parameter_vector_get_missing() {
705 let pv = params(&["x"], &[1.0]);
706 assert_eq!(pv.get("z"), None);
707 }
708
709 #[test]
710 fn test_parameter_vector_set_existing() {
711 let mut pv = params(&["x", "y"], &[1.0, 2.0]);
712 assert!(pv.set("x", 99.0));
713 assert_eq!(pv.get("x"), Some(99.0));
714 }
715
716 #[test]
717 fn test_parameter_vector_set_missing() {
718 let mut pv = params(&["x"], &[1.0]);
719 assert!(!pv.set("z", 0.0));
720 }
721
722 #[test]
723 fn test_parameter_vector_l2_norm_zero() {
724 let pv = params(&["x", "y"], &[0.0, 0.0]);
725 assert!((pv.l2_norm() - 0.0).abs() < 1e-12);
726 }
727
728 #[test]
729 fn test_parameter_vector_l2_norm_unit() {
730 let pv = params(&["x", "y"], &[1.0, 0.0]);
731 assert!((pv.l2_norm() - 1.0).abs() < 1e-12);
732 }
733
734 #[test]
735 fn test_parameter_vector_l2_norm_pythagorean() {
736 let pv = params(&["x", "y"], &[3.0, 4.0]);
737 assert!((pv.l2_norm() - 5.0).abs() < 1e-12);
738 }
739
740 #[test]
741 fn test_parameter_vector_iter() {
742 let pv = params(&["a", "b"], &[10.0, 20.0]);
743 let collected: Vec<_> = pv.iter().collect();
744 assert_eq!(collected, vec![("a", 10.0), ("b", 20.0)]);
745 }
746
747 #[test]
748 fn test_parameter_vector_empty() {
749 let pv = ParameterVector::new(vec![], vec![]);
750 assert!(pv.is_empty());
751 assert_eq!(pv.l2_norm(), 0.0);
752 }
753
754 #[test]
759 fn test_symbolic_constraint_hard() {
760 let c = SymbolicConstraint::hard("lower_x", "x >= 0.0", 1.0);
761 assert!(c.is_hard);
762 assert_eq!(c.name, "lower_x");
763 assert!((c.weight - 1.0).abs() < 1e-12);
764 }
765
766 #[test]
767 fn test_symbolic_constraint_soft() {
768 let c = SymbolicConstraint::soft("upper_y", "y <= 1.0", 0.5);
769 assert!(!c.is_hard);
770 }
771
772 #[test]
777 fn test_parse_ge() {
778 let b = parse_constraint_bound("x >= 0.0").expect("test: should succeed");
779 assert_eq!(b.param_name, "x");
780 assert_eq!(b.operator, ">=");
781 assert!((b.bound - 0.0).abs() < 1e-12);
782 }
783
784 #[test]
785 fn test_parse_le() {
786 let b = parse_constraint_bound("alpha <= 1.5").expect("test: should succeed");
787 assert_eq!(b.param_name, "alpha");
788 assert_eq!(b.operator, "<=");
789 assert!((b.bound - 1.5).abs() < 1e-12);
790 }
791
792 #[test]
793 fn test_parse_eq() {
794 let b = parse_constraint_bound("bias == 0.5").expect("test: should succeed");
795 assert_eq!(b.param_name, "bias");
796 assert_eq!(b.operator, "==");
797 assert!((b.bound - 0.5).abs() < 1e-12);
798 }
799
800 #[test]
801 fn test_parse_negative_bound() {
802 let b = parse_constraint_bound("x >= -1.0").expect("test: should succeed");
803 assert!((b.bound - (-1.0)).abs() < 1e-12);
804 }
805
806 #[test]
807 fn test_parse_no_spaces() {
808 let b = parse_constraint_bound("x>=0.0").expect("test: should succeed");
810 assert_eq!(b.operator, ">=");
811 }
812
813 #[test]
814 fn test_parse_invalid_expr() {
815 assert!(parse_constraint_bound("x 0.0").is_none());
817 }
818
819 #[test]
820 fn test_parse_invalid_value() {
821 assert!(parse_constraint_bound("x >= abc").is_none());
822 }
823
824 #[test]
829 fn test_optimizer_default_config() {
830 let opt = optimizer();
831 assert!((opt.config().learning_rate - 0.01).abs() < 1e-12);
832 assert_eq!(opt.config().max_iterations, 1000);
833 assert_eq!(opt.config().objective, OptimizationObjective::Minimize);
834 }
835
836 #[test]
837 fn test_optimizer_rng_initial_state() {
838 let opt = optimizer();
839 assert_eq!(opt.rng_state(), 12345);
840 }
841
842 #[test]
843 fn test_optimizer_rand_u64_deterministic() {
844 let mut opt = optimizer();
845 let r1 = opt.rand_u64();
846 let mut opt2 = optimizer();
847 let r2 = opt2.rand_u64();
848 assert_eq!(r1, r2);
849 }
850
851 #[test]
852 fn test_optimizer_rand_f64_in_unit_interval() {
853 let mut opt = optimizer();
854 for _ in 0..100 {
855 let r = opt.rand_f64();
856 assert!((0.0..=1.0).contains(&r));
857 }
858 }
859
860 #[test]
865 fn test_add_and_remove_constraint() {
866 let mut opt = optimizer();
867 opt.add_constraint(SymbolicConstraint::hard("c1", "x >= 0.0", 1.0));
868 assert_eq!(opt.constraints().len(), 1);
869 assert!(opt.remove_constraint("c1"));
870 assert_eq!(opt.constraints().len(), 0);
871 }
872
873 #[test]
874 fn test_remove_nonexistent_constraint() {
875 let mut opt = optimizer();
876 assert!(!opt.remove_constraint("ghost"));
877 }
878
879 #[test]
880 fn test_add_multiple_constraints() {
881 let mut opt = optimizer();
882 opt.add_constraint(SymbolicConstraint::hard("c1", "x >= 0.0", 1.0));
883 opt.add_constraint(SymbolicConstraint::soft("c2", "y <= 1.0", 0.5));
884 assert_eq!(opt.constraints().len(), 2);
885 }
886
887 #[test]
892 fn test_check_constraints_none_violated() {
893 let mut opt = optimizer();
894 opt.add_constraint(SymbolicConstraint::hard("lb", "x >= 0.0", 1.0));
895 let p = params(&["x"], &[1.0]);
896 assert_eq!(opt.check_constraints(&p), 0);
897 }
898
899 #[test]
900 fn test_check_constraints_one_violated() {
901 let mut opt = optimizer();
902 opt.add_constraint(SymbolicConstraint::hard("lb", "x >= 0.0", 1.0));
903 let p = params(&["x"], &[-1.0]);
904 assert_eq!(opt.check_constraints(&p), 1);
905 }
906
907 #[test]
908 fn test_check_constraints_soft_not_counted() {
909 let mut opt = optimizer();
910 opt.add_constraint(SymbolicConstraint::soft("s", "x <= 0.5", 1.0));
911 let p = params(&["x"], &[2.0]);
912 assert_eq!(opt.check_constraints(&p), 0);
914 }
915
916 #[test]
921 fn test_compute_loss_no_residuals() {
922 let opt = optimizer();
923 let p = params(&["x"], &[0.0]);
924 assert!((opt.compute_loss(&p, &[]) - 0.0).abs() < 1e-12);
925 }
926
927 #[test]
928 fn test_compute_loss_mse() {
929 let opt = optimizer();
930 let p = params(&["x"], &[0.0]);
931 let loss = opt.compute_loss(&p, &[1.0, -1.0]);
933 assert!((loss - 1.0).abs() < 1e-12);
934 }
935
936 #[test]
937 fn test_compute_loss_soft_penalty() {
938 let mut opt = SymbolicNeuralOptimizer::new(
939 SnoOptimizerConfig::default().with_constraint_penalty(10.0),
940 );
941 opt.add_constraint(SymbolicConstraint::soft("lb", "x >= 1.0", 1.0));
943 let p = params(&["x"], &[0.0]); let loss = opt.compute_loss(&p, &[]);
945 assert!((loss - 10.0).abs() < 1e-9);
947 }
948
949 #[test]
950 fn test_compute_loss_hard_no_penalty() {
951 let mut opt = optimizer();
952 opt.add_constraint(SymbolicConstraint::hard("lb", "x >= 1.0", 1.0));
953 let p = params(&["x"], &[0.0]); let loss = opt.compute_loss(&p, &[]);
955 assert!((loss - 0.0).abs() < 1e-12);
956 }
957
958 #[test]
963 fn test_step_gradient_descent() {
964 let mut opt = optimizer(); let p = params(&["x"], &[1.0]);
966 let g = gradient_from(&["x"], &[10.0]);
967 let new_p = opt.step(&p, &g);
968 assert!((new_p.get("x").unwrap_or(0.0) - 0.9).abs() < 1e-12);
970 }
971
972 #[test]
973 fn test_step_increments_iteration() {
974 let mut opt = optimizer();
975 let p = params(&["x"], &[0.0]);
976 let g = gradient_from(&["x"], &[0.0]);
977 opt.step(&p, &g);
978 assert_eq!(opt.iteration(), 1);
979 opt.step(&p, &g);
980 assert_eq!(opt.iteration(), 2);
981 }
982
983 #[test]
984 fn test_step_hard_constraint_clamp_lower() {
985 let mut opt = optimizer();
986 opt.add_constraint(SymbolicConstraint::hard("lb", "x >= 0.0", 1.0));
987 let p = params(&["x"], &[0.05]);
989 let g = gradient_from(&["x"], &[10.0]);
990 let new_p = opt.step(&p, &g);
991 assert!(new_p.get("x").unwrap_or(f64::NAN) >= 0.0);
992 }
993
994 #[test]
995 fn test_step_hard_constraint_clamp_upper() {
996 let mut opt = optimizer();
997 opt.add_constraint(SymbolicConstraint::hard("ub", "x <= 1.0", 1.0));
998 let p = params(&["x"], &[0.95]);
1000 let g = gradient_from(&["x"], &[-10.0]);
1001 let new_p = opt.step(&p, &g);
1002 assert!(new_p.get("x").unwrap_or(f64::NAN) <= 1.0);
1003 }
1004
1005 #[test]
1006 fn test_step_hard_constraint_equality() {
1007 let mut opt = optimizer();
1008 opt.add_constraint(SymbolicConstraint::hard("eq", "x == 0.5", 1.0));
1009 let p = params(&["x"], &[0.0]);
1010 let g = gradient_from(&["x"], &[0.0]);
1011 let new_p = opt.step(&p, &g);
1012 assert!((new_p.get("x").unwrap_or(0.0) - 0.5).abs() < 1e-12);
1014 }
1015
1016 #[test]
1017 fn test_step_soft_constraint_nudge() {
1018 let config = SnoOptimizerConfig::default()
1020 .with_learning_rate(0.0) .with_constraint_penalty(1.0)
1022 .with_symbolic_correction_weight(1.0);
1023 let mut opt = SymbolicNeuralOptimizer::new(config);
1024 opt.add_constraint(SymbolicConstraint::soft("lb", "x >= 1.0", 1.0));
1025 let p = params(&["x"], &[0.5]);
1027 let g = gradient_from(&["x"], &[0.0]);
1028 let new_p = opt.step(&p, &g);
1029 let x_after = new_p.get("x").unwrap_or(0.0);
1030 assert!(x_after > 0.5, "Expected x_after > 0.5, got {}", x_after);
1032 }
1033
1034 #[test]
1039 fn test_optimize_converges_quadratic() {
1040 let config = SnoOptimizerConfig::default()
1042 .with_learning_rate(0.1)
1043 .with_max_iterations(500)
1044 .with_convergence_threshold(1e-8);
1045 let mut opt = SymbolicNeuralOptimizer::new(config);
1046 let init = params(&["x"], &[0.0]);
1047 let result = opt.optimize(init, &|p| {
1048 let x = p.get("x").unwrap_or(0.0);
1049 let loss = (x - 2.0) * (x - 2.0);
1050 let grad = gradient_from(&["x"], &[2.0 * (x - 2.0)]);
1051 (loss, grad)
1052 });
1053 assert!(result.success);
1054 let x_final = result.final_params.get("x").unwrap_or(0.0);
1055 assert!((x_final - 2.0).abs() < 0.1, "x_final={}", x_final);
1056 }
1057
1058 #[test]
1059 fn test_optimize_respects_max_iterations() {
1060 let config = SnoOptimizerConfig::default()
1061 .with_max_iterations(5)
1062 .with_convergence_threshold(f64::EPSILON);
1063 let mut opt = SymbolicNeuralOptimizer::new(config);
1064 let init = params(&["x"], &[0.0]);
1065 let result = opt.optimize(init, &|p| {
1066 let x = p.get("x").unwrap_or(0.0);
1067 let grad = gradient_from(&["x"], &[2.0 * x]);
1068 (x * x, grad)
1069 });
1070 assert!(result.iterations <= 5);
1071 }
1072
1073 #[test]
1074 fn test_optimize_history_populated() {
1075 let config = SnoOptimizerConfig::default()
1076 .with_max_iterations(10)
1077 .with_convergence_threshold(f64::EPSILON);
1078 let mut opt = SymbolicNeuralOptimizer::new(config);
1079 let init = params(&["x"], &[5.0]);
1080 opt.optimize(init, &|p| {
1081 let x = p.get("x").unwrap_or(0.0);
1082 let grad = gradient_from(&["x"], &[2.0 * x]);
1083 (x * x, grad)
1084 });
1085 assert!(!opt.history().is_empty());
1086 }
1087
1088 #[test]
1089 fn test_optimize_best_step_is_minimum_loss() {
1090 let config = SnoOptimizerConfig::default()
1091 .with_max_iterations(20)
1092 .with_convergence_threshold(f64::EPSILON);
1093 let mut opt = SymbolicNeuralOptimizer::new(config);
1094 let init = params(&["x"], &[5.0]);
1095 opt.optimize(init, &|p| {
1096 let x = p.get("x").unwrap_or(0.0);
1097 let grad = gradient_from(&["x"], &[2.0 * x]);
1098 (x * x, grad)
1099 });
1100 let best = opt.best_step().expect("history non-empty");
1101 let min_loss = opt
1102 .history()
1103 .iter()
1104 .map(|s| s.loss)
1105 .fold(f64::INFINITY, f64::min);
1106 assert!((best.loss - min_loss).abs() < 1e-12);
1107 }
1108
1109 #[test]
1110 fn test_optimize_with_hard_constraint_satisfied() {
1111 let config = SnoOptimizerConfig::default()
1113 .with_learning_rate(0.1)
1114 .with_max_iterations(200)
1115 .with_convergence_threshold(1e-8);
1116 let mut opt = SymbolicNeuralOptimizer::new(config);
1117 opt.add_constraint(SymbolicConstraint::hard("lb", "x >= 1.0", 1.0));
1118 let init = params(&["x"], &[5.0]);
1119 let result = opt.optimize(init, &|p| {
1120 let x = p.get("x").unwrap_or(0.0);
1121 let grad = gradient_from(&["x"], &[2.0 * x]);
1122 (x * x, grad)
1123 });
1124 let x_final = result.final_params.get("x").unwrap_or(0.0);
1125 assert!(x_final >= 1.0 - 1e-9, "x_final={}", x_final);
1127 assert_eq!(result.constraint_violations, 0);
1128 }
1129
1130 #[test]
1131 fn test_optimize_maximize() {
1132 let config = SnoOptimizerConfig::default()
1134 .with_learning_rate(0.1)
1135 .with_max_iterations(500)
1136 .with_convergence_threshold(1e-8)
1137 .with_objective(OptimizationObjective::Maximize);
1138 let mut opt = SymbolicNeuralOptimizer::new(config);
1139 let init = params(&["x"], &[0.0]);
1140 let result = opt.optimize(init, &|p| {
1141 let x = p.get("x").unwrap_or(0.0);
1142 let val = -(x - 3.0) * (x - 3.0) + 9.0;
1145 let grad_val = -2.0 * (x - 3.0);
1147 let grad = gradient_from(&["x"], &[grad_val]);
1148 (val, grad)
1149 });
1150 assert!(result.success);
1151 }
1152
1153 #[test]
1154 fn test_optimize_satisfy_objective_type() {
1155 let config = SnoOptimizerConfig::default()
1156 .with_objective(OptimizationObjective::Satisfy(vec!["x_pos".to_string()]));
1157 let mut opt = SymbolicNeuralOptimizer::new(config);
1158 opt.add_constraint(SymbolicConstraint::soft("x_pos", "x >= 0.0", 1.0));
1159 let init = params(&["x"], &[-2.0]);
1160 let result = opt.optimize(init, &|p| {
1161 let x = p.get("x").unwrap_or(0.0);
1162 let grad = gradient_from(&["x"], &[0.0]);
1163 (x.abs(), grad)
1164 });
1165 assert!(result.success);
1166 }
1167
1168 #[test]
1169 fn test_reset_clears_history() {
1170 let mut opt = optimizer();
1171 let p = params(&["x"], &[0.0]);
1172 let g = gradient_from(&["x"], &[0.0]);
1173 opt.step(&p, &g);
1174 opt.reset();
1175 assert!(opt.history().is_empty());
1176 assert_eq!(opt.iteration(), 0);
1177 }
1178
1179 #[test]
1180 fn test_best_step_none_when_empty() {
1181 let opt = optimizer();
1182 assert!(opt.best_step().is_none());
1183 }
1184
1185 #[test]
1186 fn test_optimize_multi_param() {
1187 let config = SnoOptimizerConfig::default()
1189 .with_learning_rate(0.05)
1190 .with_max_iterations(1000)
1191 .with_convergence_threshold(1e-9);
1192 let mut opt = SymbolicNeuralOptimizer::new(config);
1193 let init = params(&["x", "y"], &[5.0, 5.0]);
1194 let result = opt.optimize(init, &|p| {
1195 let x = p.get("x").unwrap_or(0.0);
1196 let y = p.get("y").unwrap_or(0.0);
1197 let loss = (x - 1.0) * (x - 1.0) + (y + 2.0) * (y + 2.0);
1198 let gx = 2.0 * (x - 1.0);
1199 let gy = 2.0 * (y + 2.0);
1200 let grad = gradient_from(&["x", "y"], &[gx, gy]);
1201 (loss, grad)
1202 });
1203 let x_f = result.final_params.get("x").unwrap_or(0.0);
1204 let y_f = result.final_params.get("y").unwrap_or(0.0);
1205 assert!((x_f - 1.0).abs() < 0.5, "x_f={}", x_f);
1206 assert!((y_f + 2.0).abs() < 0.5, "y_f={}", y_f);
1207 }
1208
1209 #[test]
1210 fn test_optimizer_config_builder() {
1211 let cfg = SnoOptimizerConfig::new()
1212 .with_learning_rate(0.001)
1213 .with_max_iterations(2000)
1214 .with_convergence_threshold(1e-10)
1215 .with_constraint_penalty(5.0)
1216 .with_symbolic_correction_weight(0.3)
1217 .with_objective(OptimizationObjective::Maximize);
1218 assert!((cfg.learning_rate - 0.001).abs() < 1e-15);
1219 assert_eq!(cfg.max_iterations, 2000);
1220 assert!((cfg.convergence_threshold - 1e-10).abs() < 1e-20);
1221 assert!((cfg.constraint_penalty - 5.0).abs() < 1e-12);
1222 assert!((cfg.symbolic_correction_weight - 0.3).abs() < 1e-12);
1223 assert_eq!(cfg.objective, OptimizationObjective::Maximize);
1224 }
1225
1226 #[test]
1227 fn test_xorshift64_not_stuck() {
1228 let mut state: u64 = 12345;
1229 let r1 = xorshift64(&mut state);
1230 let r2 = xorshift64(&mut state);
1231 let r3 = xorshift64(&mut state);
1232 assert_ne!(r1, r2);
1233 assert_ne!(r2, r3);
1234 }
1235
1236 #[test]
1237 fn test_xorshift64_zero_seed_skips() {
1238 let mut state: u64 = 1;
1240 let r = xorshift64(&mut state);
1241 assert_ne!(r, 0);
1242 }
1243
1244 #[test]
1245 fn test_constraint_bound_eq_clamp() {
1246 let mut opt =
1247 SymbolicNeuralOptimizer::new(SnoOptimizerConfig::default().with_learning_rate(0.0));
1248 opt.add_constraint(SymbolicConstraint::hard("fix", "w == 2.0", 1.0));
1249 let p = params(&["w"], &[5.0]);
1250 let g = gradient_from(&["w"], &[0.0]);
1251 let new_p = opt.step(&p, &g);
1252 assert!((new_p.get("w").unwrap_or(0.0) - 2.0).abs() < 1e-12);
1253 }
1254
1255 #[test]
1256 fn test_step_unknown_param_in_gradient() {
1257 let mut opt = optimizer();
1259 let p = params(&["x"], &[1.0]);
1260 let g = gradient_from(&["z"], &[100.0]); let new_p = opt.step(&p, &g);
1262 assert!((new_p.get("x").unwrap_or(0.0) - 1.0).abs() < 1e-12);
1264 }
1265
1266 #[test]
1267 fn test_history_loss_monotone_tendency() {
1268 let config = SnoOptimizerConfig::default()
1271 .with_learning_rate(0.05)
1272 .with_max_iterations(50)
1273 .with_convergence_threshold(1e-15);
1274 let mut opt = SymbolicNeuralOptimizer::new(config);
1275 let init = params(&["x"], &[10.0]);
1276 opt.optimize(init, &|p| {
1277 let x = p.get("x").unwrap_or(0.0);
1278 let grad = gradient_from(&["x"], &[2.0 * x]);
1279 (x * x, grad)
1280 });
1281 let first_loss = opt
1282 .history()
1283 .first()
1284 .map(|s| s.loss)
1285 .unwrap_or(f64::INFINITY);
1286 let last_loss = opt.history().last().map(|s| s.loss).unwrap_or(0.0);
1287 assert!(
1288 last_loss <= first_loss,
1289 "first={} last={}",
1290 first_loss,
1291 last_loss
1292 );
1293 }
1294
1295 #[test]
1296 fn test_remove_constraint_removes_all_matching() {
1297 let mut opt = optimizer();
1298 opt.add_constraint(SymbolicConstraint::hard("c", "x >= 0.0", 1.0));
1299 opt.add_constraint(SymbolicConstraint::hard("c", "x <= 5.0", 1.0));
1300 opt.add_constraint(SymbolicConstraint::soft("d", "y <= 1.0", 0.5));
1301 assert!(opt.remove_constraint("c"));
1302 assert_eq!(opt.constraints().len(), 1);
1303 assert_eq!(opt.constraints()[0].name, "d");
1304 }
1305
1306 #[test]
1307 fn test_sno_optimization_result_fields() {
1308 let r = SnoOptimizationResult {
1309 success: true,
1310 iterations: 42,
1311 final_loss: 0.001,
1312 final_params: params(&["x"], &[1.0]),
1313 constraint_violations: 0,
1314 converged: true,
1315 };
1316 assert!(r.success);
1317 assert_eq!(r.iterations, 42);
1318 assert!(r.converged);
1319 }
1320
1321 #[test]
1322 fn test_sno_optimization_step_fields() {
1323 let s = SnoOptimizationStep {
1324 iteration: 7,
1325 loss: std::f64::consts::PI,
1326 gradient_norm: 0.5,
1327 constraint_violations: 2,
1328 params: params(&["a"], &[9.9]),
1329 };
1330 assert_eq!(s.iteration, 7);
1331 assert_eq!(s.constraint_violations, 2);
1332 }
1333}