Skip to main content

quantrs2_tytan/optimization/
constraints.rs

1//! Constraint handling for quantum annealing
2//!
3//! This module provides comprehensive constraint management including
4//! automatic penalty term generation and constraint analysis.
5
6// Optimization penalty types
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9
10/// Constraint types
11#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
12pub enum ConstraintType {
13    /// Equality constraint: expr = target
14    Equality { target: f64 },
15    /// Inequality constraint: expr <= bound
16    LessThanOrEqual { bound: f64 },
17    /// Inequality constraint: expr >= bound
18    GreaterThanOrEqual { bound: f64 },
19    /// Range constraint: lower <= expr <= upper
20    Range { lower: f64, upper: f64 },
21    /// One-hot constraint: exactly one variable true
22    OneHot,
23    /// Cardinality constraint: exactly k variables true
24    Cardinality { k: usize },
25    /// Integer encoding constraint
26    IntegerEncoding { min: i32, max: i32 },
27}
28
29/// Constraint definition
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct Constraint {
32    pub name: String,
33    pub constraint_type: ConstraintType,
34    pub expression: Expression,
35    pub variables: Vec<String>,
36    pub penalty_weight: Option<f64>,
37    pub slack_variables: Vec<String>,
38}
39
40impl Constraint {
41    /// Evaluate the real constraint violation for a given variable
42    /// assignment. Returns `0.0` when the constraint is satisfied.
43    pub fn violation(&self, assignment: &HashMap<String, bool>) -> f64 {
44        let value = self.expression.evaluate(assignment);
45        self.constraint_type.violation(value)
46    }
47}
48
49impl ConstraintType {
50    /// Compute how far `value` (the constraint's expression evaluated
51    /// against some assignment) is from satisfying this constraint. Returns
52    /// `0.0` exactly when the constraint is satisfied.
53    #[must_use]
54    pub fn violation(&self, value: f64) -> f64 {
55        match *self {
56            Self::Equality { target } => value - target,
57            Self::LessThanOrEqual { bound } => (value - bound).max(0.0),
58            Self::GreaterThanOrEqual { bound } => (bound - value).max(0.0),
59            Self::Range { lower, upper } => {
60                if value < lower {
61                    lower - value
62                } else if value > upper {
63                    value - upper
64                } else {
65                    0.0
66                }
67            }
68            // These constraints encode the violation directly in their
69            // expression (e.g. `sum(x_i) - 1` for one-hot, `sum(x_i) - k` for
70            // cardinality), so the evaluated value already *is* the
71            // violation.
72            Self::OneHot | Self::Cardinality { .. } => value,
73            // Integer-encoding "constraints" are a bookkeeping marker for the
74            // bit variables backing an encoded integer; the actual
75            // constraints they may imply (e.g. `EncodingType::Unary`,
76            // `EncodingType::OneHot`) are registered as their own
77            // `Constraint`s, so this marker itself has no independent
78            // violation to report.
79            Self::IntegerEncoding { .. } => 0.0,
80        }
81    }
82}
83
84/// Constraint handler for automatic penalty generation
85pub struct ConstraintHandler {
86    constraints: Vec<Constraint>,
87    slack_variable_counter: usize,
88    encoding_cache: HashMap<String, EncodingInfo>,
89}
90
91/// Encoding information for integer variables
92#[derive(Debug, Clone, Serialize, Deserialize)]
93struct EncodingInfo {
94    pub variable_name: String,
95    pub bit_variables: Vec<String>,
96    pub min_value: i32,
97    pub max_value: i32,
98    pub encoding_type: EncodingType,
99}
100
101/// Integer encoding types
102#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
103pub enum EncodingType {
104    Binary,
105    Unary,
106    OneHot,
107    Gray,
108}
109
110impl Default for ConstraintHandler {
111    fn default() -> Self {
112        Self::new()
113    }
114}
115
116impl ConstraintHandler {
117    /// Create new constraint handler
118    pub fn new() -> Self {
119        Self {
120            constraints: Vec::new(),
121            slack_variable_counter: 0,
122            encoding_cache: HashMap::new(),
123        }
124    }
125
126    /// Add constraint
127    pub fn add_constraint(&mut self, constraint: Constraint) {
128        self.constraints.push(constraint);
129    }
130
131    /// Add equality constraint
132    pub fn add_equality(
133        &mut self,
134        name: String,
135        expression: Expression,
136        target: f64,
137    ) -> Result<(), Box<dyn std::error::Error>> {
138        let variables = expression.get_variables();
139
140        self.add_constraint(Constraint {
141            name,
142            constraint_type: ConstraintType::Equality { target },
143            expression,
144            variables,
145            penalty_weight: None,
146            slack_variables: Vec::new(),
147        });
148
149        Ok(())
150    }
151
152    /// Add inequality constraint
153    pub fn add_inequality(
154        &mut self,
155        name: String,
156        expression: Expression,
157        bound: f64,
158        less_than: bool,
159    ) -> Result<(), Box<dyn std::error::Error>> {
160        let variables = expression.get_variables();
161        let mut constraint = Constraint {
162            name: name.clone(),
163            constraint_type: if less_than {
164                ConstraintType::LessThanOrEqual { bound }
165            } else {
166                ConstraintType::GreaterThanOrEqual { bound }
167            },
168            expression,
169            variables,
170            penalty_weight: None,
171            slack_variables: Vec::new(),
172        };
173
174        // Add slack variables for inequality constraints
175        if less_than {
176            // expr + slack = bound, slack >= 0
177            let slack_var = self.create_slack_variable(&name);
178            constraint.slack_variables.push(slack_var);
179        } else {
180            // expr - slack = bound, slack >= 0
181            let slack_var = self.create_slack_variable(&name);
182            constraint.slack_variables.push(slack_var);
183        }
184
185        self.add_constraint(constraint);
186        Ok(())
187    }
188
189    /// Add one-hot constraint
190    pub fn add_one_hot(
191        &mut self,
192        name: String,
193        variables: Vec<String>,
194    ) -> Result<(), Box<dyn std::error::Error>> {
195        // Create expression: (sum_i x_i - 1)^2
196        let mut expr = Expression::zero();
197        for var in &variables {
198            expr = expr + Variable::new(var.clone()).into();
199        }
200        expr = expr - 1.0.into();
201
202        self.add_constraint(Constraint {
203            name,
204            constraint_type: ConstraintType::OneHot,
205            expression: expr,
206            variables,
207            penalty_weight: None,
208            slack_variables: Vec::new(),
209        });
210
211        Ok(())
212    }
213
214    /// Add cardinality constraint
215    pub fn add_cardinality(
216        &mut self,
217        name: String,
218        variables: Vec<String>,
219        k: usize,
220    ) -> Result<(), Box<dyn std::error::Error>> {
221        // Create expression: (sum_i x_i - k)^2
222        let mut expr = Expression::zero();
223        for var in &variables {
224            expr = expr + Variable::new(var.clone()).into();
225        }
226        expr = expr - (k as f64).into();
227
228        self.add_constraint(Constraint {
229            name,
230            constraint_type: ConstraintType::Cardinality { k },
231            expression: expr,
232            variables,
233            penalty_weight: None,
234            slack_variables: Vec::new(),
235        });
236
237        Ok(())
238    }
239
240    /// Add integer encoding constraint
241    pub fn add_integer_encoding(
242        &mut self,
243        name: String,
244        base_name: String,
245        min: i32,
246        max: i32,
247        encoding_type: EncodingType,
248    ) -> Result<Vec<String>, Box<dyn std::error::Error>> {
249        let num_bits = ((max - min + 1) as f64).log2().ceil() as usize;
250        let mut bit_variables = Vec::new();
251
252        // Create bit variables
253        for i in 0..num_bits {
254            bit_variables.push(format!("{base_name}_{i}"));
255        }
256
257        // Store encoding info
258        self.encoding_cache.insert(
259            base_name.clone(),
260            EncodingInfo {
261                variable_name: base_name,
262                bit_variables: bit_variables.clone(),
263                min_value: min,
264                max_value: max,
265                encoding_type,
266            },
267        );
268
269        // Add encoding-specific constraints
270        match encoding_type {
271            EncodingType::Binary => {
272                // No additional constraints for binary encoding
273            }
274            EncodingType::Unary => {
275                // Unary: if x_i = 1, then x_{i-1} = 1
276                for i in 1..bit_variables.len() {
277                    let expr: Expression = Variable::new(bit_variables[i].clone()).into();
278                    let prev_expr: Expression = Variable::new(bit_variables[i - 1].clone()).into();
279                    let constraint_expr = expr - prev_expr;
280
281                    self.add_inequality(format!("{name}_unary_{i}"), constraint_expr, 0.0, true)?;
282                }
283            }
284            EncodingType::OneHot => {
285                // Exactly one bit active
286                self.add_one_hot(format!("{name}_onehot"), bit_variables.clone())?;
287            }
288            EncodingType::Gray => {
289                // Gray code constraints are implicit in the mapping
290            }
291        }
292
293        self.add_constraint(Constraint {
294            name,
295            constraint_type: ConstraintType::IntegerEncoding { min, max },
296            expression: Expression::zero(), // Placeholder
297            variables: bit_variables.clone(),
298            penalty_weight: None,
299            slack_variables: Vec::new(),
300        });
301
302        Ok(bit_variables)
303    }
304
305    /// Generate penalty terms for all constraints
306    ///
307    /// Takes `&mut self` because inequality/range constraints that were
308    /// added without a pre-existing slack variable (i.e. not through
309    /// [`Self::add_inequality`]) have one allocated for them here, on demand,
310    /// so the returned expression is an exact quadratic penalty rather than
311    /// a fabricated zero.
312    pub fn generate_penalty_terms(
313        &mut self,
314        penalty_weights: &HashMap<String, f64>,
315    ) -> Result<Expression, Box<dyn std::error::Error>> {
316        let mut total_penalty = Expression::zero();
317
318        for idx in 0..self.constraints.len() {
319            let constraint = self.constraints[idx].clone();
320            let weight = penalty_weights
321                .get(&constraint.name)
322                .or(constraint.penalty_weight.as_ref())
323                .copied()
324                .unwrap_or(1.0);
325
326            let penalty_expr = match &constraint.constraint_type {
327                ConstraintType::Equality { target } => {
328                    // (expr - target)^2
329                    let diff = constraint.expression.clone() - (*target).into();
330                    diff.clone() * diff
331                }
332                ConstraintType::LessThanOrEqual { bound } => {
333                    // expr + slack = bound => (expr + slack - bound)^2
334                    if let Some(slack_var) = constraint.slack_variables.first() {
335                        let expr_with_slack =
336                            constraint.expression.clone() + Variable::new(slack_var.clone()).into();
337                        let diff = expr_with_slack - (*bound).into();
338                        diff.clone() * diff
339                    } else {
340                        // Penalty for violation: max(0, expr - bound)^2, via
341                        // an auxiliary slack variable.
342                        let (penalty, slack_name) =
343                            self.generate_inequality_penalty(&constraint.expression, *bound, true);
344                        self.constraints[idx].slack_variables.push(slack_name);
345                        penalty
346                    }
347                }
348                ConstraintType::GreaterThanOrEqual { bound } => {
349                    // expr - slack = bound => (expr - slack - bound)^2
350                    if let Some(slack_var) = constraint.slack_variables.first() {
351                        let expr_with_slack =
352                            constraint.expression.clone() - Variable::new(slack_var.clone()).into();
353                        let diff = expr_with_slack - (*bound).into();
354                        diff.clone() * diff
355                    } else {
356                        // Penalty for violation: max(0, bound - expr)^2, via
357                        // an auxiliary slack variable.
358                        let (penalty, slack_name) =
359                            self.generate_inequality_penalty(&constraint.expression, *bound, false);
360                        self.constraints[idx].slack_variables.push(slack_name);
361                        penalty
362                    }
363                }
364                ConstraintType::Range { lower, upper } => {
365                    // Combine two inequality penalties, each with its own
366                    // auxiliary slack variable.
367                    let (lower_penalty, lower_slack) =
368                        self.generate_inequality_penalty(&constraint.expression, *lower, false);
369                    let (upper_penalty, upper_slack) =
370                        self.generate_inequality_penalty(&constraint.expression, *upper, true);
371                    self.constraints[idx].slack_variables.push(lower_slack);
372                    self.constraints[idx].slack_variables.push(upper_slack);
373                    lower_penalty + upper_penalty
374                }
375                ConstraintType::OneHot => {
376                    // (sum_i x_i - 1)^2
377                    let expr = constraint.expression.clone();
378                    expr.clone() * expr
379                }
380                ConstraintType::Cardinality { k: _ } => {
381                    // (sum_i x_i - k)^2
382                    let expr = constraint.expression.clone();
383                    expr.clone() * expr
384                }
385                ConstraintType::IntegerEncoding { .. } => {
386                    // Encoding constraints are handled separately
387                    Expression::zero()
388                }
389            };
390
391            total_penalty = total_penalty + weight * penalty_expr;
392        }
393
394        Ok(total_penalty)
395    }
396
397    /// Generate an exact quadratic inequality penalty using an auxiliary
398    /// slack variable, i.e. the same binary-expansion technique used by
399    /// [`Self::add_inequality`]: `expr <= bound` becomes the equality
400    /// `expr + slack = bound` (or `expr - slack = bound` for `>=`), penalized
401    /// as `(expr ± slack - bound)^2`. Returns the penalty expression along
402    /// with the name of the newly allocated slack variable so the caller can
403    /// register it against the owning constraint.
404    fn generate_inequality_penalty(
405        &mut self,
406        expression: &Expression,
407        bound: f64,
408        less_than: bool,
409    ) -> (Expression, String) {
410        let slack_name = self.create_slack_variable("ineq");
411        let slack_expr: Expression = Variable::new(slack_name.clone()).into();
412
413        let penalty = if less_than {
414            // expr + slack = bound  =>  (expr + slack - bound)^2
415            let diff = expression.clone() + slack_expr - bound.into();
416            diff.clone() * diff
417        } else {
418            // expr - slack = bound  =>  (expr - slack - bound)^2
419            let diff = expression.clone() - slack_expr - bound.into();
420            diff.clone() * diff
421        };
422
423        (penalty, slack_name)
424    }
425
426    /// Create slack variable
427    fn create_slack_variable(&mut self, constraint_name: &str) -> String {
428        let var_name = format!("_slack_{}_{}", constraint_name, self.slack_variable_counter);
429        self.slack_variable_counter += 1;
430        var_name
431    }
432
433    /// Get all variables including slack
434    pub fn get_all_variables(&self) -> Vec<String> {
435        let mut variables = Vec::new();
436
437        for constraint in &self.constraints {
438            variables.extend(constraint.variables.clone());
439            variables.extend(constraint.slack_variables.clone());
440        }
441
442        // Include integer encoding bit variables
443        for encoding in self.encoding_cache.values() {
444            variables.extend(encoding.bit_variables.clone());
445        }
446
447        // Remove duplicates
448        variables.sort();
449        variables.dedup();
450
451        variables
452    }
453
454    /// Decode integer value from bit assignment
455    pub fn decode_integer(
456        &self,
457        variable_name: &str,
458        assignment: &HashMap<String, bool>,
459    ) -> Option<i32> {
460        let encoding = self.encoding_cache.get(variable_name)?;
461
462        match encoding.encoding_type {
463            EncodingType::Binary => {
464                let mut value = 0;
465                for (i, bit_var) in encoding.bit_variables.iter().enumerate() {
466                    if *assignment.get(bit_var).unwrap_or(&false) {
467                        value += 1 << i;
468                    }
469                }
470                Some(encoding.min_value + value)
471            }
472            EncodingType::Unary => {
473                let mut count = 0;
474                for bit_var in &encoding.bit_variables {
475                    if *assignment.get(bit_var).unwrap_or(&false) {
476                        count += 1;
477                    } else {
478                        break;
479                    }
480                }
481                Some(encoding.min_value + count)
482            }
483            EncodingType::OneHot => {
484                for (i, bit_var) in encoding.bit_variables.iter().enumerate() {
485                    if *assignment.get(bit_var).unwrap_or(&false) {
486                        return Some(encoding.min_value + i as i32);
487                    }
488                }
489                None
490            }
491            EncodingType::Gray => {
492                // Convert Gray code to binary
493                let mut gray_value = 0;
494                for (i, bit_var) in encoding.bit_variables.iter().enumerate() {
495                    if *assignment.get(bit_var).unwrap_or(&false) {
496                        gray_value |= 1 << i;
497                    }
498                }
499
500                // Gray to binary conversion
501                let mut binary_value = gray_value;
502                binary_value ^= binary_value >> 16;
503                binary_value ^= binary_value >> 8;
504                binary_value ^= binary_value >> 4;
505                binary_value ^= binary_value >> 2;
506                binary_value ^= binary_value >> 1;
507
508                Some(encoding.min_value + binary_value)
509            }
510        }
511    }
512
513    /// Analyze constraint structure
514    pub fn analyze_constraints(&self) -> ConstraintAnalysis {
515        let total_constraints = self.constraints.len();
516        let total_variables = self.get_all_variables().len();
517
518        let mut type_counts = HashMap::new();
519        let mut avg_variables_per_constraint = 0.0;
520        let mut max_variables_in_constraint = 0;
521
522        for constraint in &self.constraints {
523            let type_name = match constraint.constraint_type {
524                ConstraintType::Equality { .. } => "equality",
525                ConstraintType::LessThanOrEqual { .. } => "less_than",
526                ConstraintType::GreaterThanOrEqual { .. } => "greater_than",
527                ConstraintType::Range { .. } => "range",
528                ConstraintType::OneHot => "one_hot",
529                ConstraintType::Cardinality { .. } => "cardinality",
530                ConstraintType::IntegerEncoding { .. } => "integer",
531            };
532
533            *type_counts.entry(type_name.to_string()).or_insert(0) += 1;
534
535            let var_count = constraint.variables.len();
536            avg_variables_per_constraint += var_count as f64;
537            max_variables_in_constraint = max_variables_in_constraint.max(var_count);
538        }
539
540        if total_constraints > 0 {
541            avg_variables_per_constraint /= total_constraints as f64;
542        }
543
544        ConstraintAnalysis {
545            total_constraints,
546            total_variables,
547            slack_variables: self.slack_variable_counter,
548            constraint_types: type_counts,
549            avg_variables_per_constraint,
550            max_variables_in_constraint,
551            encoding_info: self.encoding_cache.len(),
552        }
553    }
554}
555
556/// Constraint analysis results
557#[derive(Debug, Clone, Serialize, Deserialize)]
558pub struct ConstraintAnalysis {
559    pub total_constraints: usize,
560    pub total_variables: usize,
561    pub slack_variables: usize,
562    pub constraint_types: HashMap<String, usize>,
563    pub avg_variables_per_constraint: f64,
564    pub max_variables_in_constraint: usize,
565    pub encoding_info: usize,
566}
567
568// Helper trait implementations for Expression
569trait ExpressionExt {
570    fn zero() -> Self;
571    fn get_variables(&self) -> Vec<String>;
572}
573
574impl ExpressionExt for Expression {
575    fn zero() -> Self {
576        Self::Constant(0.0)
577    }
578
579    fn get_variables(&self) -> Vec<String> {
580        fn collect(expr: &Expression, vars: &mut Vec<String>) {
581            match expr {
582                Expression::Constant(_) => {}
583                Expression::Variable(name) => {
584                    if !vars.contains(name) {
585                        vars.push(name.clone());
586                    }
587                }
588                Expression::Add(lhs, rhs) | Expression::Multiply(lhs, rhs) => {
589                    collect(lhs, vars);
590                    collect(rhs, vars);
591                }
592            }
593        }
594
595        let mut vars = Vec::new();
596        collect(self, &mut vars);
597        vars
598    }
599}
600
601/// A named binary decision variable, used to build up [`Expression`]s.
602#[derive(Debug, Clone)]
603pub struct Variable {
604    name: String,
605}
606
607impl Variable {
608    pub const fn new(name: String) -> Self {
609        Self { name }
610    }
611}
612
613/// A small algebraic expression tree over binary decision `Variable`s,
614/// supporting the operations needed to build QUBO/HOBO penalty terms
615/// (constants, variable references, addition, and multiplication).
616#[derive(Debug, Clone, Serialize, Deserialize)]
617pub enum Expression {
618    Constant(f64),
619    Variable(String),
620    Add(Box<Self>, Box<Self>),
621    Multiply(Box<Self>, Box<Self>),
622}
623
624impl Expression {
625    /// Evaluate this expression against a variable assignment. Variables not
626    /// present in `assignment` are treated as `false` (i.e. `0.0`), matching
627    /// the convention used elsewhere in this crate for QUBO assignments.
628    #[must_use]
629    pub fn evaluate(&self, assignment: &HashMap<String, bool>) -> f64 {
630        match self {
631            Self::Constant(c) => *c,
632            Self::Variable(name) => {
633                if assignment.get(name).copied().unwrap_or(false) {
634                    1.0
635                } else {
636                    0.0
637                }
638            }
639            Self::Add(lhs, rhs) => lhs.evaluate(assignment) + rhs.evaluate(assignment),
640            Self::Multiply(lhs, rhs) => lhs.evaluate(assignment) * rhs.evaluate(assignment),
641        }
642    }
643}
644
645impl From<f64> for Expression {
646    fn from(value: f64) -> Self {
647        Self::Constant(value)
648    }
649}
650
651impl From<Variable> for Expression {
652    fn from(var: Variable) -> Self {
653        Self::Variable(var.name)
654    }
655}
656
657impl std::ops::Add for Expression {
658    type Output = Self;
659
660    fn add(self, rhs: Self) -> Self::Output {
661        Self::Add(Box::new(self), Box::new(rhs))
662    }
663}
664
665impl std::ops::Sub for Expression {
666    type Output = Self;
667
668    fn sub(self, rhs: Self) -> Self::Output {
669        Self::Add(
670            Box::new(self),
671            Box::new(Self::Multiply(
672                Box::new(Self::Constant(-1.0)),
673                Box::new(rhs),
674            )),
675        )
676    }
677}
678
679impl std::ops::Mul for Expression {
680    type Output = Self;
681
682    fn mul(self, rhs: Self) -> Self::Output {
683        Self::Multiply(Box::new(self), Box::new(rhs))
684    }
685}
686
687impl std::ops::Mul<Expression> for f64 {
688    type Output = Expression;
689
690    fn mul(self, rhs: Expression) -> Self::Output {
691        Expression::Multiply(Box::new(Expression::Constant(self)), Box::new(rhs))
692    }
693}
694
695#[cfg(test)]
696mod tests {
697    use super::*;
698
699    fn assignment(pairs: &[(&str, bool)]) -> HashMap<String, bool> {
700        pairs
701            .iter()
702            .map(|(name, value)| ((*name).to_string(), *value))
703            .collect()
704    }
705
706    #[test]
707    fn test_get_variables_collects_all_leaves() {
708        let expr: Expression = (Expression::from(Variable::new("x0".to_string()))
709            + Variable::new("x1".to_string()).into())
710            * Variable::new("x0".to_string()).into();
711
712        let mut vars = expr.get_variables();
713        vars.sort();
714        assert_eq!(vars, vec!["x0".to_string(), "x1".to_string()]);
715    }
716
717    #[test]
718    fn test_expression_evaluate() {
719        // (x0 + x1) * x0, with x0=true, x1=true -> (1+1)*1 = 2
720        let expr: Expression = (Expression::from(Variable::new("x0".to_string()))
721            + Variable::new("x1".to_string()).into())
722            * Variable::new("x0".to_string()).into();
723
724        let assign = assignment(&[("x0", true), ("x1", true)]);
725        assert_eq!(expr.evaluate(&assign), 2.0);
726
727        let assign = assignment(&[("x0", false), ("x1", true)]);
728        assert_eq!(expr.evaluate(&assign), 0.0);
729    }
730
731    #[test]
732    fn test_constraint_violation_equality() {
733        let mut handler = ConstraintHandler::new();
734        let expr: Expression = Variable::new("x0".to_string()).into();
735        handler
736            .add_equality("eq".to_string(), expr, 1.0)
737            .expect("add_equality should succeed");
738
739        let satisfied = assignment(&[("x0", true)]);
740        let violated = assignment(&[("x0", false)]);
741
742        assert_eq!(handler.constraints[0].violation(&satisfied), 0.0);
743        assert_eq!(handler.constraints[0].violation(&violated), -1.0);
744        // get_variables() must no longer be a fabricated empty Vec.
745        assert_eq!(handler.constraints[0].variables, vec!["x0".to_string()]);
746    }
747
748    #[test]
749    fn test_generate_penalty_terms_inequality_is_not_zero() {
750        let mut handler = ConstraintHandler::new();
751        // x0 <= 0, constructed directly (bypassing add_inequality) so no
752        // slack variable pre-exists, exercising generate_inequality_penalty.
753        let expr: Expression = Variable::new("x0".to_string()).into();
754        handler.add_constraint(Constraint {
755            name: "le".to_string(),
756            constraint_type: ConstraintType::LessThanOrEqual { bound: 0.0 },
757            expression: expr,
758            variables: vec!["x0".to_string()],
759            penalty_weight: None,
760            slack_variables: Vec::new(),
761        });
762
763        let penalty = handler
764            .generate_penalty_terms(&HashMap::new())
765            .expect("penalty generation should succeed");
766
767        // A real penalty term must reference the newly created slack
768        // variable, i.e. it must not simply be Expression::zero().
769        let vars = penalty.get_variables();
770        assert!(
771            vars.iter().any(|v| v.starts_with("_slack_")),
772            "expected a slack variable in generated penalty, got {vars:?}"
773        );
774
775        // With x0 = true (violates x0 <= 0), the optimal slack (>=0, but here
776        // slack is itself a free binary/continuous placeholder variable in
777        // {0,1}) cannot bring the penalty to zero, so the penalty must be
778        // strictly positive for some assignment reflecting the violation.
779        let violated = assignment(&[("x0", true)]);
780        assert!(penalty.evaluate(&violated) > 0.0);
781    }
782}