Skip to main content

oximo_core/reformulation/
sos.rs

1//! SOS-to-MILP reformulation implementation.
2
3use std::cell::Ref;
4use std::ops::Deref;
5
6use oximo_expr::{Expr, VarId};
7use smol_str::SmolStr;
8use thiserror::Error;
9
10use crate::constraint::{ConstraintId, Relate};
11use crate::domain::Domain;
12use crate::model::Model;
13use crate::sos::{SosConstraint, SosConstraintId, SosMember, SosType};
14use crate::var::Variable;
15
16/// Settings for an SOS-to-MILP reformulation.
17#[derive(Copy, Clone, Debug, Default, PartialEq)]
18pub struct SosReformulationOptions {
19    fallback_big_m: Option<f64>,
20}
21
22impl SosReformulationOptions {
23    /// Use `big_m` for an SOS member side whose variable bound is infinite.
24    /// Finite variable bounds are always preferred.
25    #[must_use]
26    pub const fn with_fallback_big_m(mut self, big_m: f64) -> Self {
27        self.fallback_big_m = Some(big_m);
28        self
29    }
30
31    #[must_use]
32    pub const fn fallback_big_m(self) -> Option<f64> {
33        self.fallback_big_m
34    }
35}
36
37/// Why an explicit model reformulation could not be constructed.
38#[derive(Clone, Debug, Error, PartialEq)]
39pub enum ReformulationError {
40    #[error("SOS constraint #{0} does not exist on this model")]
41    UnknownSosConstraint(usize),
42    #[error("fallback Big-M must be finite and positive, got {0}")]
43    InvalidFallbackBigM(f64),
44    #[error(
45        "cannot reformulate SOS constraint {constraint:?}: variable {variable:?} has no finite \
46         {side} bound; provide SosReformulationOptions::with_fallback_big_m(...)"
47    )]
48    MissingFiniteBound { constraint: SmolStr, variable: SmolStr, side: &'static str },
49    #[error("indicator constraint #{0} does not exist on this model")]
50    UnknownIndicatorConstraint(usize),
51    #[error(
52        "cannot reformulate indicator constraint {constraint:?}: its body depends on a parameter"
53    )]
54    ParameterDependentIndicator { constraint: SmolStr },
55    #[error(
56        "cannot reformulate indicator constraint {constraint:?}: body is not a finite affine expression"
57    )]
58    InvalidIndicatorExpression { constraint: SmolStr },
59    #[error(
60        "cannot reformulate indicator constraint {constraint:?}: lower bound is +infinity or upper bound is -infinity"
61    )]
62    InvalidIndicatorBounds { constraint: SmolStr },
63    #[error(
64        "cannot derive finite Big-M for indicator constraint {constraint:?} {side} side; provide IndicatorReformulationOptions::with_fallback_big_m(...)"
65    )]
66    MissingIndicatorBigM { constraint: SmolStr, side: &'static str },
67}
68
69/// IDs appended while replacing one source SOS constraint.
70#[derive(Clone, Debug, PartialEq, Eq)]
71pub struct SosReformulationArtifacts {
72    pub source: SosConstraintId,
73    pub variables: Vec<VarId>,
74    pub constraints: Vec<ConstraintId>,
75}
76
77/// An independent transformed model plus source-to-generated provenance.
78#[derive(Debug)]
79pub struct ReformulatedModel {
80    pub(crate) model: Model,
81}
82
83impl ReformulatedModel {
84    #[must_use]
85    pub fn model(&self) -> &Model {
86        &self.model
87    }
88
89    /// Complete SOS reformulation history carried by this transformed model.
90    /// The returned guard dereferences to a slice of artifacts.
91    #[must_use]
92    pub fn sos_reformulations(&self) -> Ref<'_, [SosReformulationArtifacts]> {
93        // The history lives on the model so a later clone-and-reformulate
94        // operation maintains traceability from earlier transformations.
95        Ref::map(self.model.sos_reformulations.borrow(), Vec::as_slice)
96    }
97
98    #[must_use]
99    pub fn into_model(self) -> Model {
100        self.model
101    }
102}
103
104impl Deref for ReformulatedModel {
105    type Target = Model;
106
107    fn deref(&self) -> &Self::Target {
108        &self.model
109    }
110}
111
112impl AsRef<Model> for ReformulatedModel {
113    fn as_ref(&self) -> &Model {
114        &self.model
115    }
116}
117
118#[derive(Copy, Clone, Debug)]
119struct PlannedMember {
120    member_index: usize,
121    member: SosMember,
122    lower: f64,
123    upper: f64,
124}
125
126impl PlannedMember {
127    fn gate_count(self) -> usize {
128        usize::from(self.lower < 0.0) + usize::from(self.upper > 0.0)
129    }
130}
131
132#[derive(Debug)]
133enum PlannedSosForm {
134    Trivial,
135    Sos1 { members: Vec<PlannedMember> },
136    Sos2 { members: Vec<PlannedMember> },
137}
138
139#[derive(Debug)]
140struct PlannedSos {
141    source: SosConstraintId,
142    form: PlannedSosForm,
143}
144
145#[derive(Debug)]
146struct SosReformulationPlan {
147    entries: Vec<PlannedSos>,
148    additional_variables: usize,
149    additional_constraints: usize,
150    additional_expr_nodes: usize,
151}
152
153impl Model {
154    /// Clone this model and replace one active SOS constraint with an MILP
155    /// formulation. An already-inactive constraint is copied unchanged. The
156    /// source model remains mutable and unchanged.
157    ///
158    /// # Errors
159    ///
160    /// Returns [`ReformulationError`] for an unknown ID, invalid fallback
161    /// Big-M, or an unbounded member side without a fallback.
162    pub fn to_reformulated_sos_constraint_model(
163        &self,
164        id: SosConstraintId,
165        options: SosReformulationOptions,
166    ) -> Result<ReformulatedModel, ReformulationError> {
167        let plan = SosReformulationPlan::for_one(self, id, options)?;
168        let model = self.clone_preserving_ids_with_capacity(
169            plan.additional_variables,
170            plan.additional_constraints,
171            plan.additional_expr_nodes,
172        );
173        plan.apply(&model);
174        Ok(ReformulatedModel { model })
175    }
176
177    /// Replace one active SOS constraint on this model without cloning it.
178    /// Existing variable and parameter expression handles remain valid because
179    /// registries are only appended and all existing IDs are preserved.
180    ///
181    /// Returns `Ok(None)` when the source SOS is already inactive.
182    /// Bounds of all source members become immutable after reformulation because
183    /// their current values are embedded in the generated Big-M rows.
184    ///
185    /// # Errors
186    ///
187    /// Returns [`ReformulationError`] before modifying the model when the ID,
188    /// fallback Big-M, or required member bounds are invalid.
189    pub fn reformulate_sos_constraint(
190        &self,
191        id: SosConstraintId,
192        options: SosReformulationOptions,
193    ) -> Result<Option<SosReformulationArtifacts>, ReformulationError> {
194        let plan = SosReformulationPlan::for_one(self, id, options)?;
195        Ok(plan.apply(self).pop())
196    }
197
198    /// Clone this model and replace every active SOS constraint with an MILP
199    /// formulation, in stable [`SosConstraintId`] order. The source model
200    /// remains mutable and unchanged.
201    ///
202    /// # Errors
203    ///
204    /// Returns [`ReformulationError`] when the fallback Big-M is invalid or an
205    /// active member has an unbounded side without a fallback. No partial
206    /// transformed model is returned.
207    pub fn to_reformulated_sos_model(
208        &self,
209        options: SosReformulationOptions,
210    ) -> Result<ReformulatedModel, ReformulationError> {
211        let plan = SosReformulationPlan::for_all(self, options)?;
212        let model = self.clone_preserving_ids_with_capacity(
213            plan.additional_variables,
214            plan.additional_constraints,
215            plan.additional_expr_nodes,
216        );
217        plan.apply(&model);
218        Ok(ReformulatedModel { model })
219    }
220
221    /// Replace every active SOS constraint on this model without cloning it.
222    /// This is the memory-efficient alternative to
223    /// [`Self::to_reformulated_sos_model`]
224    /// when the original native-SOS form is no longer needed.
225    ///
226    /// # Errors
227    ///
228    /// Returns [`ReformulationError`] before modifying the model when the
229    /// fallback Big-M or any required member bound is invalid.
230    ///
231    /// Reformulation history remains available through
232    /// [`Self::sos_reformulations`] after the returned artifacts are dropped.
233    pub fn reformulate_sos(
234        &self,
235        options: SosReformulationOptions,
236    ) -> Result<Vec<SosReformulationArtifacts>, ReformulationError> {
237        let plan = SosReformulationPlan::for_all(self, options)?;
238        Ok(plan.apply(self))
239    }
240
241    /// Complete SOS reformulation history accumulated by this model.
242    ///
243    /// The returned guard dereferences to a slice of artifacts. This is useful
244    /// after an in-place reformulation when the `Vec` returned by
245    /// [`Self::reformulate_sos`] is no longer available.
246    #[must_use]
247    pub fn sos_reformulations(&self) -> Ref<'_, [SosReformulationArtifacts]> {
248        Ref::map(self.sos_reformulations.borrow(), Vec::as_slice)
249    }
250}
251
252fn validate_options(options: SosReformulationOptions) -> Result<(), ReformulationError> {
253    if let Some(big_m) = options.fallback_big_m
254        && (!big_m.is_finite() || big_m <= 0.0)
255    {
256        return Err(ReformulationError::InvalidFallbackBigM(big_m));
257    }
258    Ok(())
259}
260
261impl SosReformulationPlan {
262    fn for_one(
263        model: &Model,
264        id: SosConstraintId,
265        options: SosReformulationOptions,
266    ) -> Result<Self, ReformulationError> {
267        validate_options(options)?;
268        let variables = model.variables.borrow();
269        let constraints = model.sos_constraints.borrow();
270        let source = constraints
271            .get(id.index())
272            .ok_or(ReformulationError::UnknownSosConstraint(id.index()))?;
273        let entries = if source.active {
274            vec![plan_one(id, source, &variables, options)?]
275        } else {
276            Vec::new()
277        };
278        Ok(Self::new(entries))
279    }
280
281    fn for_all(
282        model: &Model,
283        options: SosReformulationOptions,
284    ) -> Result<Self, ReformulationError> {
285        validate_options(options)?;
286        let variables = model.variables.borrow();
287        let constraints = model.sos_constraints.borrow();
288        let mut entries =
289            Vec::with_capacity(constraints.iter().filter(|constraint| constraint.active).count());
290        for (index, source) in constraints.iter().enumerate() {
291            if !source.active {
292                continue;
293            }
294            let id = SosConstraintId(
295                u32::try_from(index).expect("SOS registration guarantees IDs fit in u32"),
296            );
297            entries.push(plan_one(id, source, &variables, options)?);
298        }
299        Ok(Self::new(entries))
300    }
301
302    fn new(entries: Vec<PlannedSos>) -> Self {
303        let mut additional_variables = 0;
304        let mut additional_constraints = 0;
305        let mut additional_expr_nodes = 0;
306        for entry in &entries {
307            let (variables, constraints, expr_nodes) = entry.capacity();
308            additional_variables += variables;
309            additional_constraints += constraints;
310            additional_expr_nodes += expr_nodes;
311        }
312        Self { entries, additional_variables, additional_constraints, additional_expr_nodes }
313    }
314
315    fn apply(self, model: &Model) -> Vec<SosReformulationArtifacts> {
316        model.variables.borrow_mut().reserve_exact(self.additional_variables);
317        model.var_names.borrow_mut().reserve(self.additional_variables);
318        model.constraints.borrow_mut().reserve_exact(self.additional_constraints);
319        model.constraint_names.borrow_mut().reserve(self.additional_constraints);
320        model.arena.borrow_mut().__reserve_nodes(self.additional_expr_nodes);
321
322        let mut artifacts = Vec::with_capacity(self.entries.len());
323        for entry in self.entries {
324            artifacts.push(entry.apply(model));
325        }
326        if !artifacts.is_empty() {
327            model.invalidate_kind();
328        }
329        model.sos_reformulations.borrow_mut().extend(artifacts.iter().cloned());
330        artifacts
331    }
332}
333
334impl PlannedSos {
335    fn capacity(&self) -> (usize, usize, usize) {
336        let (activation_count, members, sos2) = match &self.form {
337            PlannedSosForm::Trivial => return (0, 0, 0),
338            PlannedSosForm::Sos1 { members } => (members.len(), members.as_slice(), false),
339            PlannedSosForm::Sos2 { members } => (members.len() - 1, members.as_slice(), true),
340        };
341        let gate_count: usize = members.iter().copied().map(PlannedMember::gate_count).sum();
342        let gated_members = members.iter().filter(|member| member.gate_count() > 0).count();
343        let adjacent_sums = if sos2 {
344            members
345                .iter()
346                .enumerate()
347                .filter(|(index, member)| {
348                    *index > 0 && *index + 1 < members.len() && member.gate_count() > 0
349                })
350                .count()
351        } else {
352            0
353        };
354        let constraints = 1 + gate_count;
355        let expr_nodes = activation_count
356            + activation_count.saturating_sub(1)
357            + gated_members
358            + 4 * gate_count
359            + adjacent_sums;
360        (activation_count, constraints, expr_nodes)
361    }
362
363    fn apply(self, model: &Model) -> SosReformulationArtifacts {
364        let (variable_capacity, constraint_capacity, _) = self.capacity();
365        let mut generated_variables = Vec::with_capacity(variable_capacity);
366        let mut generated_constraints = Vec::with_capacity(constraint_capacity);
367        match self.form {
368            PlannedSosForm::Trivial => {}
369            PlannedSosForm::Sos1 { members } => {
370                let activations = add_binary_activations(
371                    model,
372                    self.source,
373                    members.len(),
374                    "member",
375                    &mut generated_variables,
376                );
377                add_at_most_one(model, self.source, &activations, &mut generated_constraints);
378                for (member, activation) in members.iter().zip(activations.iter().copied()) {
379                    add_planned_member_gates(
380                        model,
381                        self.source,
382                        member,
383                        activation,
384                        &mut generated_constraints,
385                    );
386                }
387            }
388            PlannedSosForm::Sos2 { members } => {
389                let intervals = add_binary_activations(
390                    model,
391                    self.source,
392                    members.len() - 1,
393                    "interval",
394                    &mut generated_variables,
395                );
396                add_at_most_one(model, self.source, &intervals, &mut generated_constraints);
397                for (index, member) in members.iter().enumerate() {
398                    if member.gate_count() == 0 {
399                        continue;
400                    }
401                    let activation = match index {
402                        0 => intervals[0],
403                        i if i + 1 == members.len() => intervals[i - 1],
404                        i => intervals[i - 1] + intervals[i],
405                    };
406                    add_planned_member_gates(
407                        model,
408                        self.source,
409                        member,
410                        activation,
411                        &mut generated_constraints,
412                    );
413                }
414            }
415        }
416        model.sos_constraints.borrow_mut()[self.source.index()].active = false;
417        SosReformulationArtifacts {
418            source: self.source,
419            variables: generated_variables,
420            constraints: generated_constraints,
421        }
422    }
423}
424
425fn plan_one(
426    id: SosConstraintId,
427    source: &SosConstraint,
428    variables: &[Variable],
429    options: SosReformulationOptions,
430) -> Result<PlannedSos, ReformulationError> {
431    let form = match source.sos_type {
432        SosType::Sos1
433            if source.members.len() >= 2
434                && potential_nonzero_count(variables, &source.members) >= 2 =>
435        {
436            let members = source
437                .members
438                .iter()
439                .copied()
440                .enumerate()
441                .filter(|(_, member)| {
442                    raw_effective_bounds(&variables[member.variable.index()]) != (0.0, 0.0)
443                })
444                .map(|(member_index, member)| {
445                    let (lower, upper) = effective_bounds(
446                        &variables[member.variable.index()],
447                        &source.name,
448                        options,
449                    )?;
450                    Ok(PlannedMember { member_index, member, lower, upper })
451                })
452                .collect::<Result<Vec<_>, ReformulationError>>()?;
453            PlannedSosForm::Sos1 { members }
454        }
455        SosType::Sos2 if source.members.len() >= 3 => {
456            let mut ordered = source.members.clone();
457            ordered.sort_by(|left, right| left.weight.total_cmp(&right.weight));
458            if sos2_requires_reformulation(variables, &ordered) {
459                let members = ordered
460                    .into_iter()
461                    .enumerate()
462                    .map(|(member_index, member)| {
463                        let (lower, upper) = effective_bounds(
464                            &variables[member.variable.index()],
465                            &source.name,
466                            options,
467                        )?;
468                        Ok(PlannedMember { member_index, member, lower, upper })
469                    })
470                    .collect::<Result<Vec<_>, ReformulationError>>()?;
471                PlannedSosForm::Sos2 { members }
472            } else {
473                PlannedSosForm::Trivial
474            }
475        }
476        SosType::Sos1 | SosType::Sos2 => PlannedSosForm::Trivial,
477    };
478    Ok(PlannedSos { source: id, form })
479}
480
481fn add_binary_activations<'a>(
482    model: &'a Model,
483    sos_id: SosConstraintId,
484    count: usize,
485    label: &str,
486    generated: &mut Vec<VarId>,
487) -> Vec<Expr<'a>> {
488    (0..count)
489        .map(|index| {
490            let base = format!("__oximo_sos{}_{}_{}", sos_id.index(), label, index);
491            let name = unique_variable_name(model, &base);
492            let expression = model.__var(name).binary().build();
493            generated.push(expression.var_id().expect("new activation is a variable"));
494            expression
495        })
496        .collect()
497}
498
499fn add_at_most_one(
500    model: &Model,
501    sos_id: SosConstraintId,
502    activations: &[Expr<'_>],
503    generated: &mut Vec<ConstraintId>,
504) {
505    let sum = activations
506        .iter()
507        .copied()
508        .reduce(|left, right| left + right)
509        .expect("nontrivial SOS has at least one activation");
510    let name = unique_constraint_name(model, &format!("__oximo_sos{}_select", sos_id.index()));
511    generated.push(model.__add_constraint(name, sum.le(1.0)).id());
512}
513
514fn add_planned_member_gates(
515    model: &Model,
516    sos_id: SosConstraintId,
517    planned: &PlannedMember,
518    activation: Expr<'_>,
519    generated: &mut Vec<ConstraintId>,
520) {
521    if planned.gate_count() == 0 {
522        return;
523    }
524    let variable = Expr::from_var(&model.arena, planned.member.variable);
525    // Native variable bounds already enforce each gate while the activation is
526    // one. A Big-M row is only needed to force the member toward zero while the
527    // activation is zero, so sign-definite members need one row instead of two,
528    // and a fixed-zero member needs none.
529    if planned.lower < 0.0 {
530        let lower_name = unique_constraint_name(
531            model,
532            &format!("__oximo_sos{}_member_{}_lower", sos_id.index(), planned.member_index),
533        );
534        generated
535            .push(model.__add_constraint(lower_name, variable.ge(planned.lower * activation)).id());
536    }
537    if planned.upper > 0.0 {
538        let upper_name = unique_constraint_name(
539            model,
540            &format!("__oximo_sos{}_member_{}_upper", sos_id.index(), planned.member_index),
541        );
542        generated
543            .push(model.__add_constraint(upper_name, variable.le(planned.upper * activation)).id());
544    }
545}
546
547fn effective_bounds(
548    variable: &Variable,
549    constraint_name: &SmolStr,
550    options: SosReformulationOptions,
551) -> Result<(f64, f64), ReformulationError> {
552    let (lower, upper) = raw_effective_bounds(variable);
553    let lower = finite_or_fallback(lower, -1.0, options, constraint_name, &variable.name, "lower")?;
554    let upper = finite_or_fallback(upper, 1.0, options, constraint_name, &variable.name, "upper")?;
555    Ok((lower, upper))
556}
557
558fn raw_effective_bounds(variable: &Variable) -> (f64, f64) {
559    match variable.domain {
560        Domain::SemiContinuous { threshold } | Domain::SemiInteger { threshold } => {
561            (threshold.min(0.0), variable.ub.max(0.0))
562        }
563        Domain::Real | Domain::Integer | Domain::Binary => (variable.lb, variable.ub),
564    }
565}
566
567fn potential_nonzero_count(variables: &[Variable], members: &[SosMember]) -> usize {
568    members
569        .iter()
570        .filter(|member| raw_effective_bounds(&variables[member.variable.index()]) != (0.0, 0.0))
571        .count()
572}
573
574fn sos2_requires_reformulation(variables: &[Variable], ordered_members: &[SosMember]) -> bool {
575    let mut potential = ordered_members.iter().enumerate().filter_map(|(index, member)| {
576        (raw_effective_bounds(&variables[member.variable.index()]) != (0.0, 0.0)).then_some(index)
577    });
578    let Some(first) = potential.next() else {
579        return false;
580    };
581    let Some(second) = potential.next() else {
582        return false;
583    };
584    potential.next().is_some() || second != first + 1
585}
586
587fn finite_or_fallback(
588    bound: f64,
589    sign: f64,
590    options: SosReformulationOptions,
591    constraint: &SmolStr,
592    variable: &SmolStr,
593    side: &'static str,
594) -> Result<f64, ReformulationError> {
595    if bound.is_finite() {
596        Ok(bound)
597    } else if let Some(big_m) = options.fallback_big_m {
598        Ok(sign * big_m)
599    } else {
600        Err(ReformulationError::MissingFiniteBound {
601            constraint: constraint.clone(),
602            variable: variable.clone(),
603            side,
604        })
605    }
606}
607
608fn unique_variable_name(model: &Model, base: &str) -> SmolStr {
609    unique_name(base, |candidate| model.variable_id(candidate).is_some())
610}
611
612fn unique_constraint_name(model: &Model, base: &str) -> SmolStr {
613    unique_name(base, |candidate| model.constraint_id(candidate).is_some())
614}
615
616fn unique_name(base: &str, exists: impl Fn(&str) -> bool) -> SmolStr {
617    if !exists(base) {
618        return base.into();
619    }
620    for suffix in 1_u64.. {
621        let candidate = format!("{base}_{suffix}");
622        if !exists(&candidate) {
623            return candidate.into();
624        }
625    }
626    unreachable!("u64 name suffix space exhausted")
627}