Skip to main content

ommx/instance/
convert.rs

1use super::*;
2use crate::{
3    constraint_type::{ConstraintCollection, ConstraintType},
4    ATol, Evaluate,
5};
6use std::{collections::BTreeMap, ops::Neg};
7
8fn convert_objective_pair(sense: &mut Sense, objective: &mut Function, target: Sense) -> bool {
9    if *sense == target {
10        false
11    } else {
12        *sense = target;
13        *objective = std::mem::take(objective).neg();
14        true
15    }
16}
17
18impl Instance {
19    /// Convert only the active, solver-facing objective to `target`.
20    ///
21    /// # Postconditions
22    ///
23    /// Only the active pair changes, while evaluation retains the entry output
24    /// semantics even if a later conversion makes both pairs structurally equal.
25    ///
26    /// ```
27    /// use ommx::{
28    ///     linear, v1::State, ATol, DecisionVariable, Evaluate, Function, Instance,
29    ///     Sampled, Sense, VariableID,
30    /// };
31    /// use std::collections::{BTreeMap, HashMap};
32    ///
33    /// let original = Function::from(linear!(1));
34    /// let mut instance = Instance::builder()
35    ///     .sense(Sense::Maximize)
36    ///     .objective(original.clone())
37    ///     .decision_variables(BTreeMap::from([(
38    ///         VariableID::from(1),
39    ///         DecisionVariable::binary(),
40    ///     )]))
41    ///     .constraints(BTreeMap::new())
42    ///     .build()
43    ///     .unwrap();
44    /// let state = State::from(HashMap::from([(1, 1.0)]));
45    ///
46    /// assert!(instance.convert_active_objective(Sense::Minimize));
47    /// assert_eq!(instance.sense(), Sense::Minimize);
48    /// assert_eq!(instance.objective().evaluate(&state, ATol::default()).unwrap(), -1.0);
49    /// assert!(!instance.convert_active_objective(Sense::Minimize));
50    ///
51    /// let solution = instance.evaluate(&state, ATol::default()).unwrap();
52    /// let sample_set = instance
53    ///     .evaluate_samples(&Sampled::from(state), ATol::default())
54    ///     .unwrap();
55    /// assert_eq!(*solution.sense(), Some(Sense::Maximize));
56    /// assert_eq!(*solution.objective(), 1.0);
57    /// assert_eq!(*sample_set.sense(), Sense::Maximize);
58    /// let sample_id = sample_set.sample_ids().into_iter().next().unwrap();
59    /// assert_eq!(sample_set.objectives().get(sample_id), Some(&1.0));
60    ///
61    /// assert!(instance.convert_active_objective(Sense::Maximize));
62    /// let output = instance.output_objective().unwrap();
63    /// assert_eq!(output.sense(), instance.sense());
64    /// assert_eq!(output.function(), instance.objective());
65    /// ```
66    pub fn convert_active_objective(&mut self, target: Sense) -> bool {
67        if self.sense == target {
68            return false;
69        }
70        self.capture_output_objective();
71        convert_objective_pair(&mut self.sense, &mut self.objective, target)
72    }
73
74    /// Convert the complete instance objective semantics to minimization.
75    ///
76    /// # Postconditions
77    ///
78    /// Both active and output objective semantics become minimization semantics.
79    /// An existing output objective remains explicit even if both pairs become
80    /// structurally equal.
81    ///
82    /// ```
83    /// use ommx::{
84    ///     linear, v1::State, ATol, DecisionVariable, Evaluate, Function, Instance,
85    ///     Sense, VariableID,
86    /// };
87    /// use std::collections::{BTreeMap, HashMap};
88    ///
89    /// let mut instance = Instance::builder()
90    ///     .sense(Sense::Maximize)
91    ///     .objective(Function::from(linear!(1)))
92    ///     .decision_variables(BTreeMap::from([(
93    ///         VariableID::from(1),
94    ///         DecisionVariable::binary(),
95    ///     )]))
96    ///     .constraints(BTreeMap::new())
97    ///     .build()
98    ///     .unwrap();
99    /// let state = State::from(HashMap::from([(1, 1.0)]));
100    ///
101    /// assert!(instance.convert_active_objective(Sense::Minimize));
102    /// assert!(instance.as_minimization_problem());
103    /// assert_eq!(instance.sense(), Sense::Minimize);
104    /// assert_eq!(instance.objective().evaluate(&state, ATol::default()).unwrap(), -1.0);
105    /// let output = instance.output_objective().unwrap();
106    /// assert_eq!(output.sense(), instance.sense());
107    /// assert_eq!(output.function(), instance.objective());
108    /// let solution = instance.evaluate(&state, ATol::default()).unwrap();
109    /// assert_eq!(*solution.sense(), Some(Sense::Minimize));
110    /// assert_eq!(*solution.objective(), -1.0);
111    /// assert!(!instance.as_minimization_problem());
112    /// ```
113    pub fn as_minimization_problem(&mut self) -> bool {
114        self.convert_problem_objective(Sense::Minimize)
115    }
116
117    /// Convert the complete instance objective semantics to maximization.
118    ///
119    /// # Postconditions
120    ///
121    /// Both active and output objective semantics become maximization semantics.
122    /// An existing output objective remains explicit even if both pairs become
123    /// structurally equal.
124    ///
125    /// ```
126    /// use ommx::{
127    ///     linear, v1::State, ATol, DecisionVariable, Evaluate, Function, Instance,
128    ///     Sense, VariableID,
129    /// };
130    /// use std::collections::{BTreeMap, HashMap};
131    ///
132    /// let mut instance = Instance::builder()
133    ///     .sense(Sense::Minimize)
134    ///     .objective(Function::from(linear!(1)))
135    ///     .decision_variables(BTreeMap::from([(
136    ///         VariableID::from(1),
137    ///         DecisionVariable::binary(),
138    ///     )]))
139    ///     .constraints(BTreeMap::new())
140    ///     .build()
141    ///     .unwrap();
142    /// let state = State::from(HashMap::from([(1, 1.0)]));
143    ///
144    /// assert!(instance.as_maximization_problem());
145    /// assert_eq!(instance.sense(), Sense::Maximize);
146    /// assert_eq!(instance.objective().evaluate(&state, ATol::default()).unwrap(), -1.0);
147    /// let solution = instance.evaluate(&state, ATol::default()).unwrap();
148    /// assert_eq!(*solution.sense(), Some(Sense::Maximize));
149    /// assert_eq!(*solution.objective(), -1.0);
150    /// assert!(!instance.as_maximization_problem());
151    /// ```
152    pub fn as_maximization_problem(&mut self) -> bool {
153        self.convert_problem_objective(Sense::Maximize)
154    }
155
156    fn convert_problem_objective(&mut self, target: Sense) -> bool {
157        let active_converted = convert_objective_pair(&mut self.sense, &mut self.objective, target);
158        let output_converted = if let Some(output) = &mut self.output_objective {
159            convert_objective_pair(&mut output.sense, &mut output.function, target)
160        } else {
161            false
162        };
163        active_converted || output_converted
164    }
165}
166
167impl From<Instance> for ParametricInstance {
168    fn from(
169        Instance {
170            sense,
171            objective,
172            output_objective,
173            decision_variables,
174            constraint_collection,
175            indicator_constraint_collection,
176            one_hot_constraint_collection,
177            sos1_constraint_collection,
178            decision_variable_dependency,
179            description,
180            annotations,
181            named_functions,
182            ..
183        }: Instance,
184    ) -> Self {
185        ParametricInstance {
186            sense,
187            objective,
188            output_objective,
189            decision_variables,
190            parameters: ParameterTable::default(),
191            constraint_collection,
192            indicator_constraint_collection,
193            one_hot_constraint_collection,
194            sos1_constraint_collection,
195            decision_variable_dependency,
196            description,
197            annotations,
198            named_functions,
199        }
200    }
201}
202
203fn materialize_constraint_collection_parameters<T: ConstraintType>(
204    collection: &mut ConstraintCollection<T>,
205    state: &crate::v1::State,
206    atol: ATol,
207) -> crate::Result<()> {
208    let mut active_replacements = BTreeMap::new();
209    for (&id, constraint) in collection.active() {
210        let mut constraint = constraint.clone();
211        constraint.partial_evaluate(state, atol).inspect_err(|e| {
212            tracing::error!(?id, error = %e, "failed to partial_evaluate active constraint");
213        })?;
214        active_replacements.insert(id, constraint);
215    }
216
217    let mut removed_replacements = BTreeMap::new();
218    for (&id, (constraint, _reason)) in collection.removed() {
219        let mut constraint = constraint.clone();
220        constraint.partial_evaluate(state, atol).inspect_err(|e| {
221            tracing::error!(?id, error = %e, "failed to partial_evaluate removed constraint");
222        })?;
223        removed_replacements.insert(id, constraint);
224    }
225
226    collection.replace_rows_preserving_lifecycle(active_replacements, removed_replacements)
227}
228
229impl ParametricInstance {
230    /// Materialize every parameter into an [`Instance`].
231    ///
232    /// # Postconditions
233    ///
234    /// Materialization removes parameter IDs from both active and output objectives.
235    /// An existing output objective remains explicit even if specialization
236    /// makes it structurally equal to the active objective.
237    ///
238    /// ```
239    /// use ommx::{
240    ///     linear, v1::{Parameters, State}, ATol, Constraint, ConstraintID,
241    ///     DecisionVariable, Evaluate, Function, Instance, Sense, VariableID,
242    /// };
243    /// use std::collections::{BTreeMap, HashMap};
244    ///
245    /// let variable = VariableID::from(1);
246    /// let source = Instance::builder()
247    ///     .sense(Sense::Minimize)
248    ///     .objective(Function::from(linear!(1)))
249    ///     .decision_variables(BTreeMap::from([(variable, DecisionVariable::binary())]))
250    ///     .constraints(BTreeMap::from([(
251    ///         ConstraintID::from(1),
252    ///         Constraint::equal_to_zero(Function::from(linear!(1))),
253    ///     )]))
254    ///     .build()
255    ///     .unwrap();
256    /// let parametric = source.uniform_penalty_method().unwrap();
257    /// let penalty = *parametric.parameters().keys().next().unwrap();
258    /// let mut parameters = Parameters::default();
259    /// parameters.entries.insert(penalty.into_inner(), 2.0);
260    /// let instance = parametric.with_parameters(parameters).unwrap();
261    ///
262    /// assert!(instance.objective().required_ids().contains(&variable));
263    /// assert!(!instance.objective().required_ids().contains(&penalty));
264    /// assert_eq!(instance.output_objective().unwrap().sense(), Sense::Minimize);
265    /// assert!(!instance.output_objective().unwrap().preserves_optimality());
266    /// let solution = instance
267    ///     .evaluate(&State::from(HashMap::from([(1, 0.0)])), ATol::default())
268    ///     .unwrap();
269    /// assert_eq!(*solution.objective(), 0.0);
270    /// ```
271    pub fn with_parameters(self, parameters: crate::v1::Parameters) -> crate::Result<Instance> {
272        use std::collections::BTreeSet;
273
274        // Convert v1::Parameters to BTreeMap for validation and processing
275        let param_map: BTreeMap<VariableID, f64> = parameters
276            .entries
277            .iter()
278            .map(|(k, v)| (VariableID::from(*k), *v))
279            .collect();
280
281        // Check that all required parameters are provided
282        let required_ids: BTreeSet<VariableID> = self.parameters.keys().cloned().collect();
283        let given_ids: BTreeSet<VariableID> = param_map.keys().cloned().collect();
284
285        if !required_ids.is_subset(&given_ids) {
286            let missing_ids: Vec<VariableID> =
287                required_ids.difference(&given_ids).cloned().collect();
288            crate::bail!(
289                { ?missing_ids },
290                "Missing parameters: required IDs {required_ids:?}, got {given_ids:?}",
291            );
292        }
293
294        // Create state from parameters
295        let state = crate::v1::State {
296            entries: parameters.entries.clone(),
297        };
298        let atol = ATol::default();
299
300        // Partially evaluate the active and output objectives, constraints,
301        // and named functions.
302        let mut objective = self.objective;
303        objective.partial_evaluate(&state, atol)?;
304        let mut output_objective = self.output_objective;
305        if let Some(output_objective) = &mut output_objective {
306            output_objective.function.partial_evaluate(&state, atol)?;
307        }
308
309        // Both active and removed regular constraint bodies need the parameter
310        // substitution applied — otherwise the resulting `Instance` would
311        // carry dangling parameter IDs in `removed_constraints`, violating
312        // its own invariants.
313        let mut constraint_collection = self.constraint_collection;
314        materialize_constraint_collection_parameters(&mut constraint_collection, &state, atol)?;
315
316        // Indicator constraint function bodies may also reference parameter
317        // IDs (the structural indicator variable does not, by construction).
318        // Apply the same substitution to active and removed maps.
319        let mut indicator_constraint_collection = self.indicator_constraint_collection;
320        materialize_constraint_collection_parameters(
321            &mut indicator_constraint_collection,
322            &state,
323            atol,
324        )?;
325
326        let mut named_functions = self.named_functions;
327        named_functions.partial_evaluate(&state, atol)?;
328
329        // Decision-variable dependency RHS expressions can also reference
330        // parameter IDs. Without substitution, dependent-variable
331        // expressions in the resulting `Instance` would carry dangling
332        // parameter references.
333        let mut decision_variable_dependency = self.decision_variable_dependency;
334        decision_variable_dependency.partial_evaluate(&state, atol)?;
335
336        Ok(Instance {
337            sense: self.sense,
338            objective,
339            output_objective,
340            decision_variables: self.decision_variables,
341            constraint_collection,
342            indicator_constraint_collection,
343            // OneHot / SOS1 constraints are purely structural — their
344            // variable sets are always real decision variables (the
345            // parametric builder rejects parameter IDs there), so there is
346            // nothing to substitute and the collections pass through
347            // unchanged.
348            one_hot_constraint_collection: self.one_hot_constraint_collection,
349            sos1_constraint_collection: self.sos1_constraint_collection,
350            named_functions,
351            decision_variable_dependency,
352            parameters: Some(parameters),
353            description: self.description,
354            annotations: self.annotations,
355        })
356    }
357}
358
359#[cfg(test)]
360mod output_objective_tests {
361    use super::*;
362    use crate::{linear, v1::State, ATol, DecisionVariable, Evaluate, Sampled};
363    use std::collections::{BTreeMap, HashMap};
364
365    fn maximizing_binary_instance() -> Instance {
366        Instance::builder()
367            .sense(Sense::Maximize)
368            .objective(Function::from(linear!(1)))
369            .decision_variables(BTreeMap::from([(
370                VariableID::from(1),
371                DecisionVariable::binary(),
372            )]))
373            .constraints(BTreeMap::new())
374            .build()
375            .unwrap()
376    }
377
378    fn assert_evaluation(instance: &Instance, sense: Sense, objective: f64) {
379        let state = State::from(HashMap::from([(1, 1.0)]));
380        let solution = instance.evaluate(&state, ATol::default()).unwrap();
381        assert_eq!(*solution.sense(), Some(sense));
382        assert_eq!(*solution.objective(), objective);
383
384        let sample_set = instance
385            .evaluate_samples(&Sampled::from(state), ATol::default())
386            .unwrap();
387        assert_eq!(*sample_set.sense(), sense);
388        let sample_id = sample_set.sample_ids().into_iter().next().unwrap();
389        assert_eq!(sample_set.objectives().get(sample_id), Some(&objective));
390    }
391
392    #[test]
393    fn conversions_preserve_false_optimality_transport() {
394        let mut instance = maximizing_binary_instance();
395        let original_objective = instance.objective().clone();
396        instance.output_objective = Some(OutputObjective::new(
397            Sense::Maximize,
398            original_objective.clone(),
399            false,
400        ));
401
402        assert!(instance.convert_active_objective(Sense::Minimize));
403        let output = instance.output_objective().unwrap();
404        assert_eq!(output.sense(), Sense::Maximize);
405        assert_eq!(output.function(), &original_objective);
406        assert!(!output.preserves_optimality());
407        assert_evaluation(&instance, Sense::Maximize, 1.0);
408
409        // The false flag is independent output semantics, so the sidecar must
410        // remain present even when the active and output pairs become identical.
411        assert!(instance.as_minimization_problem());
412        let output = instance.output_objective().unwrap();
413        assert_eq!(output.sense(), Sense::Minimize);
414        assert_eq!(output.function(), instance.objective());
415        assert!(!output.preserves_optimality());
416        assert_evaluation(&instance, Sense::Minimize, -1.0);
417
418        assert!(instance.as_maximization_problem());
419        let output = instance.output_objective().unwrap();
420        assert_eq!(output.sense(), Sense::Maximize);
421        assert_eq!(output.function(), instance.objective());
422        assert!(!output.preserves_optimality());
423        assert_evaluation(&instance, Sense::Maximize, 1.0);
424    }
425
426    #[test]
427    fn with_parameters_specializes_parameterized_output_objective() {
428        let output_function = Function::from((linear!(1) + linear!(100)).unwrap());
429        let mut parametric = ParametricInstance::new(
430            Sense::Minimize,
431            output_function.clone().neg(),
432            BTreeMap::from([(VariableID::from(1), DecisionVariable::continuous())]),
433            ParameterTable::from_ids([VariableID::from(100)].into_iter().collect()),
434            BTreeMap::new(),
435        )
436        .unwrap();
437        parametric.output_objective =
438            Some(OutputObjective::new(Sense::Maximize, output_function, true));
439
440        let materialized = parametric
441            .with_parameters(crate::v1::Parameters {
442                entries: HashMap::from([(100, 2.0)]),
443            })
444            .unwrap();
445
446        let output = materialized.output_objective().unwrap();
447        assert_eq!(
448            output.function(),
449            &Function::from((linear!(1) + crate::coeff!(2.0)).unwrap())
450        );
451        assert_eq!(
452            output.function().required_ids(),
453            VariableIDSet::from([VariableID::from(1)])
454        );
455        assert!(output.preserves_optimality());
456        let solution = materialized
457            .evaluate(&State::from(HashMap::from([(1, 3.0)])), ATol::default())
458            .unwrap();
459        assert_eq!(*solution.sense(), Some(Sense::Maximize));
460        assert_eq!(*solution.objective(), 5.0);
461    }
462
463    #[test]
464    fn with_parameters_preserves_output_objective_when_it_matches_active() {
465        let mut parametric = ParametricInstance::new(
466            Sense::Minimize,
467            Function::from(linear!(1)),
468            BTreeMap::from([(VariableID::from(1), DecisionVariable::continuous())]),
469            ParameterTable::from_ids([VariableID::from(100)].into_iter().collect()),
470            BTreeMap::new(),
471        )
472        .unwrap();
473        parametric.output_objective = Some(OutputObjective::new(
474            Sense::Minimize,
475            Function::from((linear!(1) + linear!(100)).unwrap()),
476            true,
477        ));
478
479        let materialized = parametric
480            .with_parameters(crate::v1::Parameters {
481                entries: HashMap::from([(100, 0.0)]),
482            })
483            .unwrap();
484
485        let output = materialized.output_objective().unwrap();
486        assert_eq!(output.sense(), materialized.sense());
487        assert_eq!(output.function(), materialized.objective());
488        assert!(output.preserves_optimality());
489        assert!(materialized.to_v1_bytes().is_err());
490    }
491}
492
493#[cfg(test)]
494mod with_parameters_tests {
495    use super::*;
496    use crate::{coeff, linear, Equality, Function};
497    use maplit::btreemap;
498
499    fn parameters(ids: impl IntoIterator<Item = VariableID>) -> ParameterTable {
500        ParameterTable::from_ids(ids.into_iter().collect())
501    }
502
503    /// Parameter substitution must apply to the right-hand-side of
504    /// `decision_variable_dependency` entries. The RHS is a `Function`
505    /// over defined decision-variable or parameter IDs, so a parameter
506    /// reference there would dangle in the resulting `Instance` without
507    /// explicit substitution.
508    #[test]
509    fn decision_variable_dependency_rhs_is_substituted() {
510        use crate::AcyclicAssignments;
511        let x = VariableID::from(1);
512        let dep = VariableID::from(2);
513        let p = VariableID::from(100);
514        // Dependency: dep_var = x + p (RHS references a parameter).
515        let assignments =
516            AcyclicAssignments::new(vec![(dep, Function::from(linear!(1) + linear!(100)))])
517                .unwrap();
518
519        let parametric = ParametricInstance::builder()
520            .sense(Sense::Minimize)
521            .objective(Function::Zero)
522            .decision_variables(btreemap! {
523                x => DecisionVariable::binary(),
524                dep => DecisionVariable::binary(),
525            })
526            .parameters(parameters([p]))
527            .constraints(BTreeMap::new())
528            .decision_variable_dependency(assignments)
529            .build()
530            .unwrap();
531
532        let params = crate::v1::Parameters {
533            entries: std::collections::HashMap::from([(100, 1.0)]),
534        };
535        let instance = parametric.with_parameters(params).unwrap();
536
537        let dep_rhs = instance
538            .decision_variable_dependency()
539            .get(&dep)
540            .expect("dependency entry survives materialization");
541        let rhs_required: VariableIDSet = dep_rhs.required_ids();
542        assert!(
543            !rhs_required.contains(&p),
544            "parameter id {p:?} survived in dependency RHS: {rhs_required:?}",
545        );
546        assert!(
547            rhs_required.contains(&x),
548            "decision variable id {x:?} should remain in dependency RHS: {rhs_required:?}",
549        );
550    }
551
552    /// Parameter substitution must apply to *removed* regular constraints
553    /// as well. `ParametricInstance` permits removed-constraint bodies to
554    /// reference parameters (function bodies are unrestricted), but the
555    /// resulting `Instance` has no parameters at all — so any parameter id
556    /// left in a removed body would dangle.
557    #[test]
558    fn removed_regular_constraint_body_is_substituted() {
559        let x = VariableID::from(1);
560        let p = VariableID::from(100);
561        let c_active = Constraint::equal_to_zero(Function::from(linear!(1)));
562        let c_removed = Constraint::equal_to_zero(Function::from(linear!(1) + linear!(100)));
563
564        let parametric = ParametricInstance::builder()
565            .sense(Sense::Minimize)
566            .objective(Function::Zero)
567            .decision_variables(btreemap! {
568                x => DecisionVariable::binary(),
569            })
570            .parameters(parameters([p]))
571            .constraints(btreemap! {
572                ConstraintID::from(0) => c_active,
573            })
574            .removed_constraints(btreemap! {
575                ConstraintID::from(1) => (
576                    c_removed,
577                    crate::constraint::RemovedReason {
578                        reason: "test".to_string(),
579                        parameters: Default::default(),
580                    },
581                ),
582            })
583            .build()
584            .unwrap();
585
586        let params = crate::v1::Parameters {
587            entries: std::collections::HashMap::from([(100, 1.0)]),
588        };
589        let instance = parametric.with_parameters(params).unwrap();
590
591        let (rc, _r) = instance
592            .removed_constraints()
593            .get(&ConstraintID::from(1))
594            .unwrap();
595        let body_required: VariableIDSet = rc.stage.function.required_ids();
596        assert!(
597            !body_required.contains(&p),
598            "parameter id {p:?} survived in removed-constraint body: {body_required:?}",
599        );
600    }
601
602    /// Parameter substitution must apply to *removed* indicator constraints
603    /// too — the parametric builder accepts a removed-indicator map and the
604    /// `convert_*` paths can populate it. Without substitution, a
605    /// parameter id in a removed indicator body would dangle in the
606    /// materialized `Instance`.
607    #[test]
608    fn removed_indicator_function_body_is_substituted() {
609        let y = VariableID::from(1);
610        let x = VariableID::from(2);
611        let p = VariableID::from(100);
612        let indicator = crate::IndicatorConstraint::new(
613            y,
614            Equality::EqualToZero,
615            Function::from(linear!(2) + linear!(100)),
616        );
617
618        let parametric = ParametricInstance::builder()
619            .sense(Sense::Minimize)
620            .objective(Function::Zero)
621            .decision_variables(btreemap! {
622                y => DecisionVariable::binary(),
623                x => DecisionVariable::binary(),
624            })
625            .parameters(parameters([p]))
626            .constraints(BTreeMap::new())
627            .removed_indicator_constraints(btreemap! {
628                crate::IndicatorConstraintID::from(0) => (
629                    indicator,
630                    crate::constraint::RemovedReason {
631                        reason: "test".to_string(),
632                        parameters: Default::default(),
633                    },
634                ),
635            })
636            .build()
637            .unwrap();
638
639        let params = crate::v1::Parameters {
640            entries: std::collections::HashMap::from([(100, 1.0)]),
641        };
642        let instance = parametric.with_parameters(params).unwrap();
643
644        let (ic, _r) = instance
645            .removed_indicator_constraints()
646            .get(&crate::IndicatorConstraintID::from(0))
647            .expect("removed indicator survives materialization");
648        let body_required: VariableIDSet = ic.stage.function.required_ids();
649        assert!(
650            !body_required.contains(&p),
651            "parameter id {p:?} survived in removed-indicator body: {body_required:?}",
652        );
653    }
654
655    /// `ParametricInstance::with_parameters` must substitute parameter IDs
656    /// inside *indicator* function bodies, not just the objective and
657    /// regular constraint bodies. Otherwise the resulting `Instance`
658    /// carries dangling parameter IDs in its active indicator collection
659    /// and breaks its own invariants.
660    #[test]
661    fn indicator_function_body_is_substituted() {
662        // Indicator: y = 1 ⇒ (x + p - 1) == 0, where p is a parameter.
663        // After substituting p = 1, the body should read x + 0 = x.
664        let y = VariableID::from(1);
665        let x = VariableID::from(2);
666        let p = VariableID::from(100);
667        let indicator = crate::IndicatorConstraint::new(
668            y,
669            Equality::EqualToZero,
670            Function::from(((linear!(2) + linear!(100)).unwrap() + coeff!(-1.0)).unwrap()),
671        );
672
673        let parametric = ParametricInstance::builder()
674            .sense(Sense::Minimize)
675            .objective(Function::Zero)
676            .decision_variables(btreemap! {
677                y => DecisionVariable::binary(),
678                x => DecisionVariable::binary(),
679            })
680            .parameters(parameters([p]))
681            .constraints(BTreeMap::new())
682            .indicator_constraints(btreemap! {
683                crate::IndicatorConstraintID::from(0) => indicator,
684            })
685            .build()
686            .unwrap();
687
688        let params = crate::v1::Parameters {
689            entries: std::collections::HashMap::from([(100, 1.0)]),
690        };
691        let instance = parametric.with_parameters(params).unwrap();
692
693        // After substitution, the indicator body must no longer reference
694        // the parameter id 100.
695        let materialized = instance
696            .indicator_constraints()
697            .get(&crate::IndicatorConstraintID::from(0))
698            .unwrap();
699        let body_required: VariableIDSet = materialized.stage.function.required_ids();
700        assert!(
701            !body_required.contains(&p),
702            "parameter id {p:?} survived in indicator body after with_parameters: {body_required:?}",
703        );
704        assert!(
705            body_required.contains(&x),
706            "decision variable id {x:?} should remain in indicator body: {body_required:?}",
707        );
708    }
709}