Skip to main content

ipfrs_tensorlogic/
symbolic_neural_optimizer.rs

1//! SymbolicNeuralOptimizer — hybrid optimizer combining symbolic rule-based
2//! parameter updates with gradient-based neural optimization.
3//!
4//! Symbolic constraints guide the search space and apply logical
5//! post-corrections to neural gradient steps.  Hard constraints are enforced
6//! via clamping; soft constraints add a penalty term to the loss.
7
8// ---------------------------------------------------------------------------
9// Constraint expression operator tokens
10// ---------------------------------------------------------------------------
11
12const OP_GE: &str = ">=";
13const OP_LE: &str = "<=";
14const OP_EQ: &str = "==";
15
16// ---------------------------------------------------------------------------
17// OptimizationObjective
18// ---------------------------------------------------------------------------
19
20/// The high-level goal of the optimization run.
21#[derive(Clone, Debug, Default, PartialEq)]
22pub enum OptimizationObjective {
23    /// Minimize the scalar loss returned by the loss function.
24    #[default]
25    Minimize,
26    /// Maximize the scalar loss returned by the loss function (internally the
27    /// optimizer negates the value so that a minimiser can be reused).
28    Maximize,
29    /// Satisfy the named constraints; loss is the total weighted violation.
30    Satisfy(Vec<String>),
31}
32
33// ---------------------------------------------------------------------------
34// SymbolicConstraint
35// ---------------------------------------------------------------------------
36
37/// A named symbolic constraint with a weight and hardness flag.
38///
39/// Hard constraints are enforced by clamping parameters.  Soft constraints
40/// contribute a penalty term to the loss function.
41#[derive(Clone, Debug, PartialEq)]
42pub struct SymbolicConstraint {
43    /// Unique identifier for this constraint.
44    pub name: String,
45    /// Simple single-variable expression, e.g. `"x >= 0.0"`.
46    pub expression: String,
47    /// Weight used for soft-constraint penalties and gradient nudges.
48    pub weight: f64,
49    /// When `true` the constraint is enforced by clamping (hard).
50    /// When `false` the constraint enters the loss as a penalty (soft).
51    pub is_hard: bool,
52}
53
54impl SymbolicConstraint {
55    /// Construct a new constraint.
56    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    /// Return a hard constraint.
71    pub fn hard(name: impl Into<String>, expression: impl Into<String>, weight: f64) -> Self {
72        Self::new(name, expression, weight, true)
73    }
74
75    /// Return a soft constraint.
76    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// ---------------------------------------------------------------------------
82// ParameterVector
83// ---------------------------------------------------------------------------
84
85/// A named parameter vector.
86#[derive(Clone, Debug, PartialEq)]
87pub struct ParameterVector {
88    /// Parameter values.
89    pub values: Vec<f64>,
90    /// Parameter names (parallel to `values`).
91    pub names: Vec<String>,
92}
93
94impl ParameterVector {
95    /// Construct a new `ParameterVector`.
96    ///
97    /// If `names` and `values` lengths differ the shorter one determines the
98    /// logical length; the remainder is ignored.
99    pub fn new(names: Vec<String>, values: Vec<f64>) -> Self {
100        ParameterVector { names, values }
101    }
102
103    /// Return the value of the parameter with the given name, if present.
104    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    /// Set the value of the parameter with the given name.
112    ///
113    /// Returns `true` if the name was found and updated, `false` otherwise.
114    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    /// Compute the L2 (Euclidean) norm of the value vector.
124    pub fn l2_norm(&self) -> f64 {
125        self.values.iter().map(|v| v * v).sum::<f64>().sqrt()
126    }
127
128    /// Number of parameters.
129    pub fn len(&self) -> usize {
130        self.values.len().min(self.names.len())
131    }
132
133    /// Return `true` when there are no parameters.
134    pub fn is_empty(&self) -> bool {
135        self.len() == 0
136    }
137
138    /// Iterate over `(name, value)` pairs.
139    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// ---------------------------------------------------------------------------
149// SnoOptimizationStep  (renamed to avoid collision with OptimizationStep from
150// optimization_history module)
151// ---------------------------------------------------------------------------
152
153/// A single recorded step produced by [`SymbolicNeuralOptimizer`].
154#[derive(Clone, Debug, PartialEq)]
155pub struct SnoOptimizationStep {
156    /// Monotonically increasing iteration counter (0-based).
157    pub iteration: u64,
158    /// Loss value after this step.
159    pub loss: f64,
160    /// L2 norm of the gradient at this step.
161    pub gradient_norm: f64,
162    /// Number of hard constraint violations after the update.
163    pub constraint_violations: usize,
164    /// Parameter state after the update.
165    pub params: ParameterVector,
166}
167
168// ---------------------------------------------------------------------------
169// SnoOptimizationResult  (renamed to avoid collision with OptimizationResult
170// from query_optimizer module)
171// ---------------------------------------------------------------------------
172
173/// The final result returned by [`SymbolicNeuralOptimizer::optimize`].
174#[derive(Clone, Debug, PartialEq)]
175pub struct SnoOptimizationResult {
176    /// `true` when the run finished without error.
177    pub success: bool,
178    /// Total number of iterations executed.
179    pub iterations: u64,
180    /// Loss at the final iteration.
181    pub final_loss: f64,
182    /// Parameter values at the final iteration.
183    pub final_params: ParameterVector,
184    /// Number of hard constraint violations in the final parameter state.
185    pub constraint_violations: usize,
186    /// `true` when the run stopped due to convergence rather than hitting the
187    /// iteration limit.
188    pub converged: bool,
189}
190
191// ---------------------------------------------------------------------------
192// OptimizerConfig
193// ---------------------------------------------------------------------------
194
195/// Configuration for [`SymbolicNeuralOptimizer`].
196#[derive(Clone, Debug, PartialEq)]
197pub struct SnoOptimizerConfig {
198    /// Gradient descent step size.
199    pub learning_rate: f64,
200    /// Maximum number of optimization iterations.
201    pub max_iterations: u64,
202    /// Convergence threshold: stop when |loss_prev − loss_curr| < threshold.
203    pub convergence_threshold: f64,
204    /// Penalty multiplier applied to soft constraint violations.
205    pub constraint_penalty: f64,
206    /// Weight applied to symbolic correction nudges relative to the gradient.
207    pub symbolic_correction_weight: f64,
208    /// High-level objective of the optimization.
209    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    /// Create a new config with default values.
227    pub fn new() -> Self {
228        Self::default()
229    }
230
231    /// Builder: set learning rate.
232    pub fn with_learning_rate(mut self, lr: f64) -> Self {
233        self.learning_rate = lr;
234        self
235    }
236
237    /// Builder: set max iterations.
238    pub fn with_max_iterations(mut self, n: u64) -> Self {
239        self.max_iterations = n;
240        self
241    }
242
243    /// Builder: set convergence threshold.
244    pub fn with_convergence_threshold(mut self, t: f64) -> Self {
245        self.convergence_threshold = t;
246        self
247    }
248
249    /// Builder: set constraint penalty.
250    pub fn with_constraint_penalty(mut self, p: f64) -> Self {
251        self.constraint_penalty = p;
252        self
253    }
254
255    /// Builder: set symbolic correction weight.
256    pub fn with_symbolic_correction_weight(mut self, w: f64) -> Self {
257        self.symbolic_correction_weight = w;
258        self
259    }
260
261    /// Builder: set optimization objective.
262    pub fn with_objective(mut self, obj: OptimizationObjective) -> Self {
263        self.objective = obj;
264        self
265    }
266}
267
268// ---------------------------------------------------------------------------
269// Internal helpers
270// ---------------------------------------------------------------------------
271
272/// Xorshift64 pseudo-random number generator.
273///
274/// Advances `state` and returns the new value.
275pub 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/// Parsed bound from a simple constraint expression.
285#[derive(Clone, Debug, PartialEq)]
286pub struct ConstraintBound {
287    /// Parameter name on the left-hand side.
288    pub param_name: String,
289    /// Operator: `">="`, `"<="`, or `"=="`.
290    pub operator: String,
291    /// Bound value on the right-hand side.
292    pub bound: f64,
293}
294
295/// Parse a simple single-variable constraint expression.
296///
297/// Recognised forms:
298/// - `"x >= 0.0"` — lower bound
299/// - `"x <= 1.0"` — upper bound
300/// - `"x == 0.5"` — equality
301///
302/// Returns `None` for any expression that does not match these forms.
303pub fn parse_constraint_bound(expr: &str) -> Option<ConstraintBound> {
304    let expr = expr.trim();
305    // Try each operator in longest-first order to avoid `>` matching `>=`.
306    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            // The name must be a simple identifier (no spaces).
314            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
329/// Compute the violation amount for a parsed bound given a parameter value.
330///
331/// Returns `0.0` when the constraint is satisfied, a positive amount when it
332/// is violated.
333fn 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
342/// Clamp a value so that it satisfies a hard constraint bound.
343fn 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
352// ---------------------------------------------------------------------------
353// SymbolicNeuralOptimizer
354// ---------------------------------------------------------------------------
355
356/// A hybrid optimizer that combines symbolic rule-based parameter updates with
357/// gradient-based neural optimization.
358///
359/// # How it works
360///
361/// 1. **Gradient step** — apply a standard gradient-descent update using the
362///    configured learning rate.
363/// 2. **Hard constraint enforcement** — for each hard constraint whose
364///    expression can be parsed as a simple bound, clamp the relevant parameter
365///    to the feasible region.
366/// 3. **Soft constraint correction** — apply a small gradient nudge
367///    proportional to `weight × constraint_penalty × symbolic_correction_weight`
368///    for each violated soft constraint.
369///
370/// The [`SymbolicNeuralOptimizer::optimize`] method drives the loop, calling a
371/// user-supplied `loss_fn` at each iteration and recording history.
372pub 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    /// Create a new optimizer with the given configuration.
382    ///
383    /// The internal PRNG is seeded to `12345`.
384    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    /// Add a symbolic constraint.
395    pub fn add_constraint(&mut self, constraint: SymbolicConstraint) {
396        self.constraints.push(constraint);
397    }
398
399    /// Remove all constraints whose name equals `name`.
400    ///
401    /// Returns `true` if at least one constraint was removed.
402    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    /// Return all registered constraints.
409    pub fn constraints(&self) -> &[SymbolicConstraint] {
410        &self.constraints
411    }
412
413    /// Return the optimization history (one entry per completed step).
414    pub fn history(&self) -> &[SnoOptimizationStep] {
415        &self.history
416    }
417
418    /// Return the step with the lowest recorded loss, or `None` if no steps
419    /// have been taken yet.
420    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    /// Return the current iteration counter.
429    pub fn iteration(&self) -> u64 {
430        self.iteration
431    }
432
433    /// Compute the total loss for the given parameters and residuals.
434    ///
435    /// The base loss is the mean squared residual.  Each **soft** constraint
436    /// that is violated adds `weight × constraint_penalty × violation_amount`.
437    pub fn compute_loss(&self, params: &ParameterVector, residuals: &[f64]) -> f64 {
438        // Base loss: mean squared residual.
439        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        // Sign factor for Maximize objective.
446        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    /// Count the number of **hard** constraints that are currently violated.
470    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    /// Perform a single optimization step.
486    ///
487    /// 1. Apply the gradient step: `new_val = val − lr × grad_val`.
488    /// 2. Enforce hard constraints by clamping.
489    /// 3. Apply soft constraint gradient nudges.
490    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        // --- 1. Gradient step ---
503        for i in 0..n {
504            let val = params.values[i];
505            // Look up gradient by name for robustness against ordering
506            // differences between the two vectors.
507            let grad = gradient.get(&params.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        // --- 2. Hard constraint clamping ---
514        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        // --- 3. Soft constraint gradient nudge ---
528        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                // Nudge direction: towards the feasible side.
542                let nudge_dir = match bound.operator.as_str() {
543                    ">=" => 1.0,  // must go up
544                    "<=" => -1.0, // must go down
545                    "==" => {
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    /// Run the full optimization loop.
564    ///
565    /// `loss_fn` receives the current `ParameterVector` and must return
566    /// `(loss, gradient)` where `gradient` is a `ParameterVector` with the
567    /// same names as the input, containing the partial derivatives of the loss
568    /// with respect to each parameter.
569    ///
570    /// The loop terminates when:
571    /// - `|loss_prev − loss_curr| < convergence_threshold` (converged), or
572    /// - `iteration >= max_iterations`.
573    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(&params);
587
588            let grad_norm = gradient.l2_norm();
589            let violations = self.check_constraints(&params);
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            // Convergence check (before taking the next step).
601            if (prev_loss - loss).abs() < self.config.convergence_threshold {
602                converged = true;
603                break;
604            }
605            prev_loss = loss;
606
607            params = self.step(&params, &gradient);
608        }
609
610        // Record a final step after the last update so that `final_params`
611        // reflects the state after the last gradient step.
612        let (final_loss, _) = loss_fn(&params);
613        let final_violations = self.check_constraints(&params);
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    /// Access the raw PRNG state (useful for reproducibility testing).
626    pub fn rng_state(&self) -> u64 {
627        self.rng_state
628    }
629
630    /// Advance the internal PRNG and return a random `u64`.
631    pub fn rand_u64(&mut self) -> u64 {
632        xorshift64(&mut self.rng_state)
633    }
634
635    /// Advance the internal PRNG and return a random `f64` in `[0, 1)`.
636    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    /// Reset the optimizer state (history, iteration counter) but keep the
642    /// configuration and constraints.
643    pub fn reset(&mut self) {
644        self.history.clear();
645        self.iteration = 0;
646    }
647
648    /// Return a reference to the configuration.
649    pub fn config(&self) -> &SnoOptimizerConfig {
650        &self.config
651    }
652}
653
654// ---------------------------------------------------------------------------
655// Tests
656// ---------------------------------------------------------------------------
657
658#[cfg(test)]
659mod tests {
660    use super::*;
661
662    // -----------------------------------------------------------------------
663    // Helper factories
664    // -----------------------------------------------------------------------
665
666    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    // -----------------------------------------------------------------------
686    // ParameterVector tests
687    // -----------------------------------------------------------------------
688
689    #[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    // -----------------------------------------------------------------------
755    // SymbolicConstraint tests
756    // -----------------------------------------------------------------------
757
758    #[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    // -----------------------------------------------------------------------
773    // parse_constraint_bound tests
774    // -----------------------------------------------------------------------
775
776    #[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        // Tight format without spaces should still parse.
809        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        // No operator
816        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    // -----------------------------------------------------------------------
825    // Optimizer construction and config tests
826    // -----------------------------------------------------------------------
827
828    #[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    // -----------------------------------------------------------------------
861    // Constraint management tests
862    // -----------------------------------------------------------------------
863
864    #[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    // -----------------------------------------------------------------------
888    // check_constraints tests
889    // -----------------------------------------------------------------------
890
891    #[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        // Soft constraints don't count in check_constraints.
913        assert_eq!(opt.check_constraints(&p), 0);
914    }
915
916    // -----------------------------------------------------------------------
917    // compute_loss tests
918    // -----------------------------------------------------------------------
919
920    #[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        // MSE([1.0, -1.0]) = (1+1)/2 = 1.0
932        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        // x must be >= 1.0 (soft), weight=1.0
942        opt.add_constraint(SymbolicConstraint::soft("lb", "x >= 1.0", 1.0));
943        let p = params(&["x"], &[0.0]); // violation = 1.0
944        let loss = opt.compute_loss(&p, &[]);
945        // penalty = 1.0 * 10.0 * 1.0 = 10.0; base_loss = 0
946        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]); // violated, but hard → no penalty
954        let loss = opt.compute_loss(&p, &[]);
955        assert!((loss - 0.0).abs() < 1e-12);
956    }
957
958    // -----------------------------------------------------------------------
959    // step tests
960    // -----------------------------------------------------------------------
961
962    #[test]
963    fn test_step_gradient_descent() {
964        let mut opt = optimizer(); // lr = 0.01
965        let p = params(&["x"], &[1.0]);
966        let g = gradient_from(&["x"], &[10.0]);
967        let new_p = opt.step(&p, &g);
968        // 1.0 - 0.01 * 10.0 = 0.9
969        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        // Start at 0.05, gradient=10 → raw new = 0.05 - 0.01*10 = -0.05 → clamped to 0.0
988        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        // Start at 0.95, gradient=-10 → raw new = 0.95 + 0.1 = 1.05 → clamped to 1.0
999        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        // Hard equality forces x to 0.5.
1013        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        // Set up optimizer with a soft lower bound.
1019        let config = SnoOptimizerConfig::default()
1020            .with_learning_rate(0.0) // no gradient movement
1021            .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        // x starts at 0.5 → violation = 0.5 → nudge = +1.0*1.0*1.0*0.5 = 0.5
1026        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        // Should be nudged upward toward 1.0.
1031        assert!(x_after > 0.5, "Expected x_after > 0.5, got {}", x_after);
1032    }
1033
1034    // -----------------------------------------------------------------------
1035    // optimize tests
1036    // -----------------------------------------------------------------------
1037
1038    #[test]
1039    fn test_optimize_converges_quadratic() {
1040        // Minimize (x - 2)^2; analytic gradient = 2*(x-2).
1041        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        // Minimize x^2 with hard constraint x >= 1.0 → minimum is 1.0.
1112        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        // Hard constraint forces x >= 1.0.
1126        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        // Maximize -(x-3)^2 + 9  (peak at x=3).
1133        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            // For Maximize the optimizer negates internally, so loss_fn should
1143            // return the value we want to maximise (not the negation).
1144            let val = -(x - 3.0) * (x - 3.0) + 9.0;
1145            // Gradient of val w.r.t. x = -2*(x-3)
1146            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        // Minimize (x-1)^2 + (y+2)^2; analytic minimum: x=1, y=-2.
1188        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        // xorshift64 with state=0 would be stuck; confirm non-zero seed works.
1239        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        // Gradient has a name not in params — should default to 0.0 grad.
1258        let mut opt = optimizer();
1259        let p = params(&["x"], &[1.0]);
1260        let g = gradient_from(&["z"], &[100.0]); // "z" not in params
1261        let new_p = opt.step(&p, &g);
1262        // No gradient applied for "x" since "z" doesn't match.
1263        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        // For a simple convex problem without constraints the loss should
1269        // generally decrease.
1270        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}