Skip to main content

quantrs2_tytan/optimization/
penalty.rs

1//! Penalty function optimization for QUBO problems
2//!
3//! This module provides advanced penalty weight optimization using SciRS2
4//! for automatic tuning and constraint satisfaction analysis.
5
6use crate::optimization::constraints::{ConstraintType, Expression};
7use scirs2_core::ndarray::{Array1, Array2};
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10
11#[cfg(feature = "scirs")]
12use crate::scirs_stub::{
13    scirs2_linalg::norm::Norm,
14    scirs2_optimization::{OptimizationProblem, Optimizer},
15};
16
17/// Penalty function configuration
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct PenaltyConfig {
20    /// Initial penalty weight
21    pub initial_weight: f64,
22    /// Minimum penalty weight
23    pub min_weight: f64,
24    /// Maximum penalty weight
25    pub max_weight: f64,
26    /// Weight adjustment factor
27    pub adjustment_factor: f64,
28    /// Target constraint violation tolerance
29    pub violation_tolerance: f64,
30    /// Maximum optimization iterations
31    pub max_iterations: usize,
32    /// Use adaptive penalty scaling
33    pub adaptive_scaling: bool,
34    /// Penalty function type
35    pub penalty_type: PenaltyType,
36}
37
38/// Types of penalty functions
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
40pub enum PenaltyType {
41    /// Quadratic penalty: weight * violation^2
42    Quadratic,
43    /// Linear penalty: weight * |violation|
44    Linear,
45    /// Logarithmic barrier: -weight * log(slack)
46    LogBarrier,
47    /// Exponential penalty: weight * exp(violation) - 1
48    Exponential,
49    /// Augmented Lagrangian method
50    AugmentedLagrangian,
51}
52
53impl Default for PenaltyConfig {
54    fn default() -> Self {
55        Self {
56            initial_weight: 1.0,
57            min_weight: 0.001,
58            max_weight: 1000.0,
59            adjustment_factor: 2.0,
60            violation_tolerance: 1e-6,
61            max_iterations: 100,
62            adaptive_scaling: true,
63            penalty_type: PenaltyType::Quadratic,
64        }
65    }
66}
67
68/// Penalty function optimizer
69pub struct PenaltyOptimizer {
70    config: PenaltyConfig,
71    constraint_weights: HashMap<String, f64>,
72    violation_history: Vec<ConstraintViolation>,
73    #[cfg(feature = "scirs")]
74    optimizer: Option<Box<dyn Optimizer>>,
75}
76
77/// Constraint violation information
78#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct ConstraintViolation {
80    pub constraint_name: String,
81    pub violation_amount: f64,
82    pub penalty_weight: f64,
83    pub iteration: usize,
84}
85
86/// Penalty optimization result
87#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct PenaltyOptimizationResult {
89    pub optimal_weights: HashMap<String, f64>,
90    pub final_violations: HashMap<String, f64>,
91    pub converged: bool,
92    pub iterations: usize,
93    pub objective_value: f64,
94    pub constraint_satisfaction: f64,
95}
96
97impl PenaltyOptimizer {
98    /// Create new penalty optimizer
99    pub fn new(config: PenaltyConfig) -> Self {
100        Self {
101            config,
102            constraint_weights: HashMap::new(),
103            violation_history: Vec::new(),
104            #[cfg(feature = "scirs")]
105            optimizer: None,
106        }
107    }
108
109    /// Initialize constraint weights
110    pub fn initialize_weights(&mut self, constraints: &[String]) {
111        for constraint in constraints {
112            self.constraint_weights
113                .insert(constraint.clone(), self.config.initial_weight);
114        }
115
116        #[cfg(feature = "scirs")]
117        {
118            // Initialize SciRS2 optimizer
119            use crate::scirs_stub::scirs2_optimization::gradient::LBFGS;
120            self.optimizer = Some(Box::new(LBFGS::new(constraints.len())));
121        }
122    }
123
124    /// Optimize penalty weights for a compiled model
125    pub fn optimize_penalties(
126        &mut self,
127        model: &CompiledModel,
128        sample_results: &[(Vec<bool>, f64)],
129    ) -> Result<PenaltyOptimizationResult, Box<dyn std::error::Error>> {
130        let mut iteration = 0;
131        let mut converged = false;
132
133        while iteration < self.config.max_iterations && !converged {
134            // Evaluate constraint violations
135            let violations = self.evaluate_violations(model, sample_results)?;
136
137            // Check convergence
138            let max_violation = violations.values().map(|v| v.abs()).fold(0.0, f64::max);
139
140            if max_violation < self.config.violation_tolerance {
141                converged = true;
142                break;
143            }
144
145            // Update penalty weights
146            self.update_weights(&violations, iteration)?;
147
148            // Record history
149            for (name, &violation) in &violations {
150                self.violation_history.push(ConstraintViolation {
151                    constraint_name: name.clone(),
152                    violation_amount: violation,
153                    penalty_weight: self.constraint_weights[name],
154                    iteration,
155                });
156            }
157
158            iteration += 1;
159        }
160
161        // Calculate final metrics
162        let final_violations = self.evaluate_violations(model, sample_results)?;
163        let objective_value = self.calculate_objective(model, sample_results)?;
164        let constraint_satisfaction = self.calculate_satisfaction_rate(&final_violations);
165
166        Ok(PenaltyOptimizationResult {
167            optimal_weights: self.constraint_weights.clone(),
168            final_violations,
169            converged,
170            iterations: iteration,
171            objective_value,
172            constraint_satisfaction,
173        })
174    }
175
176    /// Evaluate constraint violations
177    fn evaluate_violations(
178        &self,
179        model: &CompiledModel,
180        sample_results: &[(Vec<bool>, f64)],
181    ) -> Result<HashMap<String, f64>, Box<dyn std::error::Error>> {
182        let mut violations = HashMap::new();
183
184        // For each constraint in the model
185        for (constraint_name, constraint_expr) in model.get_constraints() {
186            let mut total_violation = 0.0;
187            let mut count = 0;
188
189            // Evaluate constraint for each sample
190            for (assignment, _energy) in sample_results {
191                let violation = self.evaluate_constraint_violation(
192                    constraint_expr,
193                    assignment,
194                    model.get_variable_map(),
195                )?;
196
197                total_violation += violation;
198                count += 1;
199            }
200
201            // Average violation
202            violations.insert(
203                constraint_name.clone(),
204                if count > 0 {
205                    total_violation / count as f64
206                } else {
207                    0.0
208                },
209            );
210        }
211
212        Ok(violations)
213    }
214
215    /// Evaluate single constraint violation
216    ///
217    /// Actually evaluates `constraint.expression` against `assignment`
218    /// (mapped through `var_map`) and computes the real violation for
219    /// `constraint.constraint_type`, rather than a hardcoded `0.0`.
220    fn evaluate_constraint_violation(
221        &self,
222        constraint: &ConstraintExpr,
223        assignment: &[bool],
224        var_map: &HashMap<String, usize>,
225    ) -> Result<f64, Box<dyn std::error::Error>> {
226        let named_assignment: HashMap<String, bool> = var_map
227            .iter()
228            .filter_map(|(name, &index)| assignment.get(index).map(|&value| (name.clone(), value)))
229            .collect();
230
231        let value = constraint.violation(&named_assignment);
232
233        // Calculate violation based on constraint type
234        Ok(match self.config.penalty_type {
235            PenaltyType::Quadratic => value.powi(2),
236            PenaltyType::Linear => value.abs(),
237            PenaltyType::LogBarrier => {
238                if value > 0.0 {
239                    -value.ln()
240                } else {
241                    f64::INFINITY
242                }
243            }
244            PenaltyType::Exponential => value.exp_m1(),
245            PenaltyType::AugmentedLagrangian => {
246                // Simplified augmented Lagrangian
247                value.mul_add(value, value.abs())
248            }
249        })
250    }
251
252    /// Update penalty weights based on violations
253    fn update_weights(
254        &mut self,
255        violations: &HashMap<String, f64>,
256        iteration: usize,
257    ) -> Result<(), Box<dyn std::error::Error>> {
258        #[cfg(feature = "scirs")]
259        {
260            if self.config.adaptive_scaling && self.optimizer.is_some() {
261                // Use SciRS2 optimizer for weight updates
262                self.update_weights_optimized(violations, iteration)?;
263                return Ok(());
264            }
265        }
266
267        // Standard weight update
268        for (constraint_name, &violation) in violations {
269            if let Some(weight) = self.constraint_weights.get_mut(constraint_name) {
270                if violation.abs() > self.config.violation_tolerance {
271                    // Increase penalty weight
272                    *weight = (*weight * self.config.adjustment_factor).min(self.config.max_weight);
273                } else if violation.abs() < self.config.violation_tolerance * 0.1 {
274                    // Decrease penalty weight if over-penalized
275                    *weight = (*weight / self.config.adjustment_factor.sqrt())
276                        .max(self.config.min_weight);
277                }
278            }
279        }
280
281        Ok(())
282    }
283
284    #[cfg(feature = "scirs")]
285    /// Update weights using SciRS2 optimization
286    fn update_weights_optimized(
287        &mut self,
288        violations: &HashMap<String, f64>,
289        iteration: usize,
290    ) -> Result<(), Box<dyn std::error::Error>> {
291        use crate::scirs_stub::scirs2_optimization::{Bounds, ObjectiveFunction};
292
293        // Define optimization problem
294        let constraint_names: Vec<_> = violations.keys().cloned().collect();
295        let current_weights: Array1<f64> = constraint_names
296            .iter()
297            .map(|name| self.constraint_weights[name])
298            .collect();
299
300        // Objective: minimize total weighted violations
301        let violations_vec: Array1<f64> = constraint_names
302            .iter()
303            .map(|name| violations[name].abs())
304            .collect();
305
306        let mut objective = WeightOptimizationObjective {
307            violations: violations_vec,
308            penalty_type: self.config.penalty_type,
309            regularization: 0.01, // L2 regularization on weights
310        };
311
312        // Set bounds
313        let lower_bounds = Array1::from_elem(constraint_names.len(), self.config.min_weight);
314        let upper_bounds = Array1::from_elem(constraint_names.len(), self.config.max_weight);
315        let bounds = Bounds::new(lower_bounds, upper_bounds);
316
317        // Optimize. The outer loop counter is not an iteration budget for the inner solve:
318        // passing it meant the very first weight update ran the optimizer for zero
319        // iterations and returned the starting weights unchanged.
320        let inner_max_iterations = self.config.max_iterations.max(1);
321        let _ = iteration;
322        if let Some(ref mut optimizer) = self.optimizer {
323            let mut result =
324                optimizer.minimize(&objective, &current_weights, &bounds, inner_max_iterations)?;
325
326            // Update weights
327            for (i, name) in constraint_names.iter().enumerate() {
328                self.constraint_weights.insert(name.clone(), result.x[i]);
329            }
330        }
331
332        Ok(())
333    }
334
335    /// Calculate objective value
336    fn calculate_objective(
337        &self,
338        model: &CompiledModel,
339        sample_results: &[(Vec<bool>, f64)],
340    ) -> Result<f64, Box<dyn std::error::Error>> {
341        let mut total_objective = 0.0;
342
343        for (assignment, energy) in sample_results {
344            // Original objective
345            let mut penalized_objective = *energy;
346
347            // Add penalty terms
348            for (constraint_name, constraint_expr) in model.get_constraints() {
349                let violation = self.evaluate_constraint_violation(
350                    constraint_expr,
351                    assignment,
352                    model.get_variable_map(),
353                )?;
354
355                let weight = self
356                    .constraint_weights
357                    .get(constraint_name)
358                    .copied()
359                    .unwrap_or(1.0);
360
361                penalized_objective += weight * violation;
362            }
363
364            total_objective += penalized_objective;
365        }
366
367        Ok(total_objective / sample_results.len() as f64)
368    }
369
370    /// Calculate constraint satisfaction rate
371    fn calculate_satisfaction_rate(&self, violations: &HashMap<String, f64>) -> f64 {
372        let satisfied = violations
373            .values()
374            .filter(|&&v| v.abs() < self.config.violation_tolerance)
375            .count();
376
377        if violations.is_empty() {
378            1.0
379        } else {
380            satisfied as f64 / violations.len() as f64
381        }
382    }
383
384    /// Get penalty weight for a constraint
385    pub fn get_weight(&self, constraint_name: &str) -> Option<f64> {
386        self.constraint_weights.get(constraint_name).copied()
387    }
388
389    /// Get violation history
390    pub fn get_violation_history(&self) -> &[ConstraintViolation] {
391        &self.violation_history
392    }
393
394    /// Export penalty configuration
395    pub fn export_config(&self) -> PenaltyExport {
396        PenaltyExport {
397            config: self.config.clone(),
398            weights: self.constraint_weights.clone(),
399            final_violations: self
400                .violation_history
401                .iter()
402                .filter(|v| {
403                    v.iteration
404                        == self
405                            .violation_history
406                            .iter()
407                            .map(|h| h.iteration)
408                            .max()
409                            .unwrap_or(0)
410                })
411                .map(|v| (v.constraint_name.clone(), v.violation_amount))
412                .collect(),
413        }
414    }
415}
416
417/// Exported penalty configuration
418#[derive(Debug, Clone, Serialize, Deserialize)]
419pub struct PenaltyExport {
420    pub config: PenaltyConfig,
421    pub weights: HashMap<String, f64>,
422    pub final_violations: HashMap<String, f64>,
423}
424
425#[cfg(feature = "scirs")]
426/// Objective function for weight optimization
427struct WeightOptimizationObjective {
428    violations: Array1<f64>,
429    penalty_type: PenaltyType,
430    regularization: f64,
431}
432
433#[cfg(feature = "scirs")]
434impl WeightOptimizationObjective {
435    /// Floor applied to a weight before dividing by it, so a probe at or below zero cannot
436    /// produce a non-finite residual. The optimizer's own bounds keep weights at or above
437    /// `PenaltyConfig::min_weight` in practice.
438    const MIN_SAFE_WEIGHT: f64 = 1e-12;
439}
440
441#[cfg(feature = "scirs")]
442impl crate::scirs_stub::scirs2_optimization::ObjectiveFunction for WeightOptimizationObjective {
443    fn evaluate(&self, weights: &Array1<f64>) -> f64 {
444        // Residual violation left by each penalty weight, plus L2 regularisation.
445        //
446        // The residual has to *decrease* as a weight grows. The previous form summed
447        // `weight * violation`, which increases with the weight, so minimising it drove
448        // every weight down to `min_weight` precisely when a constraint was still
449        // violated — the opposite of what an adaptive penalty method must do, and why a
450        // persistently violated constraint never had its weight raised. Balancing
451        // `v / w` against `lambda * w^2` puts the optimum at `w = (v / (2*lambda))^(1/3)`,
452        // which grows with the violation and stays finite.
453        let residual: f64 = self
454            .violations
455            .iter()
456            .zip(weights.iter())
457            .map(|(&violation, &weight)| violation / weight.max(Self::MIN_SAFE_WEIGHT))
458            .sum();
459
460        residual + self.regularization * weights.dot(weights)
461    }
462
463    fn gradient(&self, weights: &Array1<f64>) -> Array1<f64> {
464        let mut gradient = Array1::zeros(weights.len());
465        for (index, (&violation, &weight)) in self.violations.iter().zip(weights.iter()).enumerate()
466        {
467            let safe_weight = weight.max(Self::MIN_SAFE_WEIGHT);
468            gradient[index] =
469                2.0 * self.regularization * weight - violation / (safe_weight * safe_weight);
470        }
471        gradient
472    }
473}
474
475/// A penalty-optimization view of a compiled QUBO/HOBO model: the set of
476/// named constraints (as real, evaluable [`ConstraintExpr`]s) plus the
477/// variable name -> QUBO index mapping used to interpret sample bit vectors.
478#[derive(Debug, Clone)]
479pub struct CompiledModel {
480    constraints: HashMap<String, ConstraintExpr>,
481    variable_map: HashMap<String, usize>,
482}
483
484impl Default for CompiledModel {
485    fn default() -> Self {
486        Self::new()
487    }
488}
489
490impl CompiledModel {
491    pub fn new() -> Self {
492        Self {
493            constraints: HashMap::new(),
494            variable_map: HashMap::new(),
495        }
496    }
497
498    /// Build a model with an explicit variable ordering (name -> QUBO index).
499    #[must_use]
500    pub fn with_variables(variables: &[String]) -> Self {
501        let variable_map = variables
502            .iter()
503            .enumerate()
504            .map(|(index, name)| (name.clone(), index))
505            .collect();
506        Self {
507            constraints: HashMap::new(),
508            variable_map,
509        }
510    }
511
512    /// Register a real constraint to be tracked/optimized.
513    pub fn add_constraint(&mut self, name: impl Into<String>, constraint: ConstraintExpr) {
514        self.constraints.insert(name.into(), constraint);
515    }
516
517    /// Register (or overwrite) a variable's QUBO index.
518    pub fn set_variable_index(&mut self, name: impl Into<String>, index: usize) {
519        self.variable_map.insert(name.into(), index);
520    }
521
522    pub const fn get_constraints(&self) -> &HashMap<String, ConstraintExpr> {
523        &self.constraints
524    }
525
526    pub const fn get_variable_map(&self) -> &HashMap<String, usize> {
527        &self.variable_map
528    }
529
530    pub fn to_qubo(&self) -> (Array2<f64>, HashMap<String, usize>) {
531        let size = self.variable_map.len();
532        (Array2::zeros((size, size)), self.variable_map.clone())
533    }
534}
535
536/// A real, evaluable constraint expression: an [`Expression`] tree (reusing
537/// the evaluator from [`crate::optimization::constraints`]) together with
538/// the [`ConstraintType`] that determines how far a given expression value
539/// is from feasibility.
540#[derive(Debug, Clone)]
541pub struct ConstraintExpr {
542    pub expression: Expression,
543    pub constraint_type: ConstraintType,
544}
545
546impl ConstraintExpr {
547    #[must_use]
548    pub const fn new(expression: Expression, constraint_type: ConstraintType) -> Self {
549        Self {
550            expression,
551            constraint_type,
552        }
553    }
554
555    /// Evaluate the real constraint violation (`0.0` when satisfied) for a
556    /// given named variable assignment.
557    #[must_use]
558    pub fn violation(&self, assignment: &HashMap<String, bool>) -> f64 {
559        let value = self.expression.evaluate(assignment);
560        self.constraint_type.violation(value)
561    }
562}
563
564/// Analyze penalty function behavior
565pub fn analyze_penalty_landscape(config: &PenaltyConfig, violations: &[f64]) -> PenaltyAnalysis {
566    let weights = Array1::linspace(config.min_weight, config.max_weight, 100);
567    let mut penalties = Vec::new();
568
569    for &weight in &weights {
570        let penalty_values: Vec<f64> = violations
571            .iter()
572            .map(|&v| calculate_penalty(v, weight, config.penalty_type))
573            .collect();
574
575        penalties.push(PenaltyPoint {
576            weight,
577            avg_penalty: penalty_values.iter().sum::<f64>() / penalty_values.len() as f64,
578            max_penalty: penalty_values.iter().fold(0.0, |a, &b| a.max(b)),
579            min_penalty: penalty_values.iter().fold(f64::INFINITY, |a, &b| a.min(b)),
580        });
581    }
582
583    PenaltyAnalysis {
584        penalty_points: penalties,
585        optimal_weight: find_optimal_weight(&weights, violations, config),
586        sensitivity: calculate_sensitivity(violations, config),
587    }
588}
589
590/// Calculate penalty value
591fn calculate_penalty(violation: f64, weight: f64, penalty_type: PenaltyType) -> f64 {
592    weight
593        * match penalty_type {
594            PenaltyType::Quadratic => violation.powi(2),
595            PenaltyType::Linear => violation.abs(),
596            PenaltyType::LogBarrier => {
597                if violation > 0.0 {
598                    -violation.ln()
599                } else {
600                    1e10 // Large penalty for infeasible region
601                }
602            }
603            PenaltyType::Exponential => violation.exp_m1(),
604            PenaltyType::AugmentedLagrangian => violation.mul_add(violation, violation.abs()),
605        }
606}
607
608/// Find optimal penalty weight
609fn find_optimal_weight(weights: &Array1<f64>, violations: &[f64], config: &PenaltyConfig) -> f64 {
610    // Simple heuristic: find weight that balances constraint satisfaction
611    // with objective minimization
612    let target_penalty = violations.len() as f64 * config.violation_tolerance;
613
614    let mut best_weight = config.initial_weight;
615    let mut best_diff = f64::INFINITY;
616
617    for &weight in weights {
618        let total_penalty: f64 = violations
619            .iter()
620            .map(|&v| calculate_penalty(v, weight, config.penalty_type))
621            .sum();
622
623        let diff = (total_penalty - target_penalty).abs();
624        if diff < best_diff {
625            best_diff = diff;
626            best_weight = weight;
627        }
628    }
629
630    best_weight
631}
632
633/// Calculate penalty sensitivity
634fn calculate_sensitivity(violations: &[f64], config: &PenaltyConfig) -> f64 {
635    if violations.is_empty() {
636        return 0.0;
637    }
638
639    // Calculate derivative of penalty w.r.t. weight at current weight
640    let weight = config.initial_weight;
641    let penalties: Vec<f64> = violations
642        .iter()
643        .map(|&v| calculate_penalty(v, weight, config.penalty_type))
644        .collect();
645
646    let delta = 0.01 * weight;
647    let penalties_delta: Vec<f64> = violations
648        .iter()
649        .map(|&v| calculate_penalty(v, weight + delta, config.penalty_type))
650        .collect();
651
652    let derivatives: Vec<f64> = penalties
653        .iter()
654        .zip(penalties_delta.iter())
655        .map(|(&p1, &p2)| (p2 - p1) / delta)
656        .collect();
657
658    // Return average sensitivity
659    derivatives.iter().sum::<f64>() / derivatives.len() as f64
660}
661
662/// Penalty analysis results
663#[derive(Debug, Clone, Serialize, Deserialize)]
664pub struct PenaltyAnalysis {
665    pub penalty_points: Vec<PenaltyPoint>,
666    pub optimal_weight: f64,
667    pub sensitivity: f64,
668}
669
670/// Penalty evaluation point
671#[derive(Debug, Clone, Serialize, Deserialize)]
672pub struct PenaltyPoint {
673    pub weight: f64,
674    pub avg_penalty: f64,
675    pub max_penalty: f64,
676    pub min_penalty: f64,
677}
678
679#[cfg(test)]
680mod tests {
681    use super::*;
682    use crate::optimization::constraints::Variable;
683
684    /// Builds a model with a single equality constraint `x0 == 1` over two
685    /// QUBO variables `x0`, `x1`.
686    fn equality_model() -> CompiledModel {
687        let mut model = CompiledModel::with_variables(&["x0".to_string(), "x1".to_string()]);
688        let expr: Expression = Variable::new("x0".to_string()).into();
689        model.add_constraint(
690            "x0_must_be_true",
691            ConstraintExpr::new(expr, ConstraintType::Equality { target: 1.0 }),
692        );
693        model
694    }
695
696    #[test]
697    fn test_evaluate_constraint_violation_is_real() {
698        let optimizer = PenaltyOptimizer::new(PenaltyConfig::default());
699        let model = equality_model();
700        let constraint = &model.get_constraints()["x0_must_be_true"];
701
702        // x0 = false violates x0 == 1 by -1.0; quadratic penalty type squares it.
703        let violated = optimizer
704            .evaluate_constraint_violation(constraint, &[false, true], model.get_variable_map())
705            .expect("evaluation should succeed");
706        assert_eq!(violated, 1.0);
707
708        // x0 = true satisfies the constraint exactly.
709        let satisfied = optimizer
710            .evaluate_constraint_violation(constraint, &[true, false], model.get_variable_map())
711            .expect("evaluation should succeed");
712        assert_eq!(satisfied, 0.0);
713    }
714
715    #[test]
716    fn test_optimize_penalties_detects_real_violations() {
717        let mut optimizer = PenaltyOptimizer::new(PenaltyConfig {
718            max_iterations: 5,
719            ..PenaltyConfig::default()
720        });
721        let model = equality_model();
722        optimizer.initialize_weights(&["x0_must_be_true".to_string()]);
723
724        // All sampled assignments violate x0 == 1 (x0 is always false).
725        let sample_results = vec![
726            (vec![false, false], 0.0),
727            (vec![false, true], 0.0),
728            (vec![false, true], 0.0),
729        ];
730
731        let result = optimizer
732            .optimize_penalties(&model, &sample_results)
733            .expect("optimization should succeed");
734
735        // With a genuine (nonzero) violation, satisfaction cannot be
736        // trivially reported as perfect, and the penalty weight must have
737        // been increased from its initial value to push toward feasibility.
738        assert!(result.final_violations["x0_must_be_true"].abs() > 0.0);
739        assert!(result.constraint_satisfaction < 1.0);
740        assert!(
741            result.optimal_weights["x0_must_be_true"] > PenaltyConfig::default().initial_weight
742        );
743    }
744
745    #[test]
746    fn test_optimize_penalties_recognizes_feasible_samples() {
747        let mut optimizer = PenaltyOptimizer::new(PenaltyConfig::default());
748        let model = equality_model();
749        optimizer.initialize_weights(&["x0_must_be_true".to_string()]);
750
751        // All sampled assignments satisfy x0 == 1.
752        let sample_results = vec![(vec![true, false], -1.0), (vec![true, true], -0.5)];
753
754        let result = optimizer
755            .optimize_penalties(&model, &sample_results)
756            .expect("optimization should succeed");
757
758        assert!(result.converged);
759        assert_eq!(result.constraint_satisfaction, 1.0);
760        assert_eq!(result.final_violations["x0_must_be_true"], 0.0);
761    }
762}