1use crate::registry::{ConstructionError, FieldInfo, ProblemSchemaEntry, VariantDimension};
8use crate::traits::{EvaluationError, Problem};
9use crate::types::{
10 i64_to_exact_f64, Extremum, NumericArithmeticError, NumericSize, WeightElement,
11};
12use serde::{Deserialize, Deserializer, Serialize};
13use std::fmt::Debug;
14use std::marker::PhantomData;
15
16inventory::submit! {
17 ProblemSchemaEntry {
18 name: "ILP",
19 display_name: "ILP",
20 aliases: &[],
21 dimensions: &[
22 VariantDimension::new("variable", "bool", &["bool", "i64"]),
23 VariantDimension::new("coefficient", "i64", &["i64", "f64"]),
24 VariantDimension::new("bounds", "general", &["general", "bounded"]),
25 ],
26 category: crate::registry::ProblemCategory::Algebraic,
27 module_path: module_path!(),
28 description: "Optimize a linear objective over bounded or unbounded integer variables",
29 fields: &[
30 FieldInfo { name: "variables", type_name: "Vec<IntegerVariable>", description: "Integer variable bounds; null means an unbounded side" },
31 FieldInfo { name: "constraints", type_name: "Vec<LinearConstraint<C>>", description: "Sparse finite linear constraints" },
32 FieldInfo { name: "objective", type_name: "Vec<(usize, C)>", description: "Sparse finite objective coefficients" },
33 FieldInfo { name: "sense", type_name: "ObjectiveSense", description: "Optimization direction" },
34 ],
35 }
36}
37
38pub trait VariableDomain: 'static + Clone + Debug + Send + Sync {
40 const NAME: &'static str;
42
43 fn default_variable() -> IntegerVariable;
45
46 fn validate_variables(variables: &[IntegerVariable]) -> Result<(), ConstructionError>;
48}
49
50pub trait BoundsPolicy: 'static + Clone + Debug + Send + Sync {
52 const NAME: &'static str;
54 fn validate_variables(variables: &[IntegerVariable]) -> Result<(), ConstructionError>;
56}
57
58#[derive(Debug, Clone, Copy)]
60pub struct General;
61
62impl BoundsPolicy for General {
63 const NAME: &'static str = "general";
64
65 fn validate_variables(_variables: &[IntegerVariable]) -> Result<(), ConstructionError> {
66 Ok(())
67 }
68}
69
70#[derive(Debug, Clone, Copy)]
72pub struct Bounded;
73
74impl BoundsPolicy for Bounded {
75 const NAME: &'static str = "bounded";
76
77 fn validate_variables(variables: &[IntegerVariable]) -> Result<(), ConstructionError> {
78 if variables
79 .iter()
80 .any(|variable| variable.lower_bound().is_none() || variable.upper_bound().is_none())
81 {
82 return Err(ConstructionError::Conversion(
83 "bounded ILP requires finite lower and upper bounds for every variable".into(),
84 ));
85 }
86 Ok(())
87 }
88}
89
90pub trait ILPCoefficient:
92 NumericSize + WeightElement<Sum = Self> + Copy + Debug + Send + Sync
93{
94 const NAME: &'static str;
96
97 fn from_integer(value: i64) -> Result<Self, EvaluationError>;
99
100 fn satisfies(lhs: Self, comparison: Comparison, rhs: Self) -> bool;
102}
103
104impl ILPCoefficient for i64 {
105 const NAME: &'static str = "i64";
106
107 fn from_integer(value: i64) -> Result<Self, EvaluationError> {
108 Ok(value)
109 }
110
111 fn satisfies(lhs: Self, comparison: Comparison, rhs: Self) -> bool {
112 match comparison {
113 Comparison::Le => lhs <= rhs,
114 Comparison::Ge => lhs >= rhs,
115 Comparison::Eq => lhs == rhs,
116 }
117 }
118}
119
120impl ILPCoefficient for f64 {
121 const NAME: &'static str = "f64";
122
123 fn from_integer(value: i64) -> Result<Self, EvaluationError> {
124 i64_to_exact_f64(value).map_err(|_| {
125 EvaluationError::InexactFloatConversion(
126 "transporting an integer variable into an f64 ILP expression".into(),
127 )
128 })
129 }
130
131 fn satisfies(lhs: Self, comparison: Comparison, rhs: Self) -> bool {
132 let tolerance = 1e-9 * lhs.abs().max(rhs.abs()).max(1.0);
133 match comparison {
134 Comparison::Le => lhs <= rhs + tolerance,
135 Comparison::Ge => lhs >= rhs - tolerance,
136 Comparison::Eq => (lhs - rhs).abs() <= tolerance,
137 }
138 }
139}
140
141impl VariableDomain for bool {
142 const NAME: &'static str = "bool";
143
144 fn default_variable() -> IntegerVariable {
145 IntegerVariable::binary()
146 }
147
148 fn validate_variables(variables: &[IntegerVariable]) -> Result<(), ConstructionError> {
149 if variables
150 .iter()
151 .any(|variable| variable.lower_bound != Some(0) || variable.upper_bound != Some(1))
152 {
153 return Err(ConstructionError::Conversion(
154 "binary ILP variables must have bounds [0, 1]".into(),
155 ));
156 }
157 Ok(())
158 }
159}
160
161impl VariableDomain for i64 {
162 const NAME: &'static str = "i64";
163
164 fn default_variable() -> IntegerVariable {
165 IntegerVariable::nonnegative()
166 }
167
168 fn validate_variables(_variables: &[IntegerVariable]) -> Result<(), ConstructionError> {
169 Ok(())
170 }
171}
172
173#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
175pub struct IntegerVariable {
176 lower_bound: Option<i64>,
177 upper_bound: Option<i64>,
178}
179
180impl<'de> Deserialize<'de> for IntegerVariable {
181 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
182 where
183 D: Deserializer<'de>,
184 {
185 #[derive(Deserialize)]
186 struct Bounds {
187 lower_bound: Option<i64>,
188 upper_bound: Option<i64>,
189 }
190
191 let bounds = Bounds::deserialize(deserializer)?;
192 Self::new(bounds.lower_bound, bounds.upper_bound).map_err(serde::de::Error::custom)
193 }
194}
195
196impl IntegerVariable {
197 pub fn new(
200 lower_bound: Option<i64>,
201 upper_bound: Option<i64>,
202 ) -> Result<Self, ConstructionError> {
203 if lower_bound
204 .zip(upper_bound)
205 .is_some_and(|(lower, upper)| lower > upper)
206 {
207 return Err(ConstructionError::Conversion(
208 "integer variable lower bound exceeds its upper bound".into(),
209 ));
210 }
211 Ok(Self {
212 lower_bound,
213 upper_bound,
214 })
215 }
216
217 pub const fn binary() -> Self {
219 Self {
220 lower_bound: Some(0),
221 upper_bound: Some(1),
222 }
223 }
224
225 pub const fn nonnegative() -> Self {
227 Self {
228 lower_bound: Some(0),
229 upper_bound: None,
230 }
231 }
232
233 pub const fn free() -> Self {
235 Self {
236 lower_bound: None,
237 upper_bound: None,
238 }
239 }
240
241 pub const fn lower_bound(self) -> Option<i64> {
243 self.lower_bound
244 }
245
246 pub const fn upper_bound(self) -> Option<i64> {
248 self.upper_bound
249 }
250
251 pub fn contains(self, value: i64) -> bool {
253 self.lower_bound.is_none_or(|lower| value >= lower)
254 && self.upper_bound.is_none_or(|upper| value <= upper)
255 }
256}
257
258#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
260pub enum Comparison {
261 Le,
263 Ge,
265 Eq,
267}
268
269#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
271pub struct LinearConstraint<C = i64> {
272 terms: Vec<(usize, C)>,
273 comparison: Comparison,
274 rhs: C,
275}
276
277impl<C> LinearConstraint<C> {
278 fn new(terms: Vec<(usize, C)>, comparison: Comparison, rhs: C) -> Self {
279 Self {
280 terms,
281 comparison,
282 rhs,
283 }
284 }
285
286 pub fn le(terms: Vec<(usize, C)>, rhs: C) -> Self {
288 Self::new(terms, Comparison::Le, rhs)
289 }
290
291 pub fn ge(terms: Vec<(usize, C)>, rhs: C) -> Self {
293 Self::new(terms, Comparison::Ge, rhs)
294 }
295
296 pub fn eq(terms: Vec<(usize, C)>, rhs: C) -> Self {
298 Self::new(terms, Comparison::Eq, rhs)
299 }
300
301 pub fn terms(&self) -> &[(usize, C)] {
303 &self.terms
304 }
305
306 pub const fn comparison(&self) -> Comparison {
308 self.comparison
309 }
310
311 pub fn variables(&self) -> impl Iterator<Item = usize> + '_ {
313 self.terms.iter().map(|&(variable, _)| variable)
314 }
315}
316
317impl<C: ILPCoefficient> LinearConstraint<C> {
318 pub fn rhs(&self) -> C {
320 self.rhs
321 }
322
323 pub fn evaluate_lhs(&self, values: &[i64]) -> Result<C, EvaluationError> {
325 self.terms
326 .iter()
327 .try_fold(C::zero(), |sum, &(variable, coefficient)| {
328 let value = values.get(variable).copied().ok_or_else(|| {
329 EvaluationError::InvalidConfiguration(format!(
330 "an ILP constraint references variable {variable}, but the assignment has {} values",
331 values.len()
332 ))
333 })?;
334 let value = C::from_integer(value)?;
335 let product = C::checked_mul_sum(
336 coefficient,
337 value,
338 "multiplying a term in an ILP constraint",
339 )?;
340 C::checked_add_to_sum(sum, product, "summing an ILP constraint")
341 })
342 }
343
344 pub fn is_satisfied(&self, values: &[i64]) -> Result<bool, EvaluationError> {
346 let lhs = self.evaluate_lhs(values)?;
347 Ok(C::satisfies(lhs, self.comparison, self.rhs))
348 }
349}
350
351#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
353pub enum ObjectiveSense {
354 Maximize,
356 Minimize,
358}
359
360#[derive(Debug, Clone, Serialize)]
362pub struct ILP<V: VariableDomain = bool, C: ILPCoefficient = i64, B: BoundsPolicy = General> {
363 variables: Vec<IntegerVariable>,
364 constraints: Vec<LinearConstraint<C>>,
365 objective: Vec<(usize, C)>,
366 sense: ObjectiveSense,
367 #[serde(skip)]
368 marker: PhantomData<(V, B)>,
369}
370
371#[derive(Deserialize)]
372struct ILPData<C> {
373 variables: Vec<IntegerVariable>,
374 constraints: Vec<LinearConstraint<C>>,
375 objective: Vec<(usize, C)>,
376 sense: ObjectiveSense,
377}
378
379impl<'de, V, C, B> Deserialize<'de> for ILP<V, C, B>
380where
381 V: VariableDomain,
382 C: ILPCoefficient + Deserialize<'de>,
383 B: BoundsPolicy,
384{
385 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
386 where
387 D: Deserializer<'de>,
388 {
389 let data = ILPData::<C>::deserialize(deserializer)?;
390 Self::with_variables(data.variables, data.constraints, data.objective, data.sense)
391 .map_err(serde::de::Error::custom)
392 }
393}
394
395impl<V: VariableDomain, C: ILPCoefficient, B: BoundsPolicy> ILP<V, C, B> {
396 pub fn new(
400 num_variables: usize,
401 constraints: Vec<LinearConstraint<C>>,
402 objective: Vec<(usize, C)>,
403 sense: ObjectiveSense,
404 ) -> Result<Self, ConstructionError> {
405 Self::with_variables(
406 vec![V::default_variable(); num_variables],
407 constraints,
408 objective,
409 sense,
410 )
411 }
412
413 pub fn with_variables(
415 variables: Vec<IntegerVariable>,
416 constraints: Vec<LinearConstraint<C>>,
417 objective: Vec<(usize, C)>,
418 sense: ObjectiveSense,
419 ) -> Result<Self, ConstructionError> {
420 V::validate_variables(&variables)?;
421 B::validate_variables(&variables)?;
422 let num_variables = variables.len();
423 let constraints = constraints
424 .into_iter()
425 .enumerate()
426 .map(|(index, constraint)| normalize_constraint(constraint, num_variables, index))
427 .collect::<Result<_, _>>()?;
428 let objective = normalize_objective(objective, num_variables)?;
429 Ok(Self {
430 variables,
431 constraints,
432 objective,
433 sense,
434 marker: PhantomData,
435 })
436 }
437
438 pub fn empty() -> Self {
440 Self::new(0, vec![], vec![], ObjectiveSense::Minimize)
441 .expect("the empty ILP satisfies all construction invariants")
442 }
443
444 pub fn variables(&self) -> &[IntegerVariable] {
446 &self.variables
447 }
448
449 pub fn constraints(&self) -> &[LinearConstraint<C>] {
451 &self.constraints
452 }
453
454 pub fn objective(&self) -> &[(usize, C)] {
456 &self.objective
457 }
458
459 pub const fn sense(&self) -> ObjectiveSense {
461 self.sense
462 }
463
464 pub fn num_variables(&self) -> usize {
466 self.variables.len()
467 }
468
469 pub fn num_vars(&self) -> usize {
471 self.num_variables()
472 }
473
474 pub fn num_constraints(&self) -> usize {
476 self.constraints.len()
477 }
478
479 pub fn num_nonzeros(&self) -> usize {
481 self.constraints
482 .iter()
483 .map(|constraint| constraint.terms.len())
484 .sum()
485 }
486
487 pub fn max_constraint_magnitude_bits(&self) -> u64 {
495 let row_bits =
496 crate::types::max_numeric_magnitude_bits(self.constraints.iter().flat_map(|row| {
497 std::iter::once(row.rhs)
498 .chain(row.terms.iter().map(|&(_, coefficient)| coefficient))
499 }));
500 let endpoint_bits = crate::types::max_numeric_magnitude_bits(
501 self.variables
502 .iter()
503 .flat_map(|variable| [variable.lower_bound, variable.upper_bound])
504 .flatten(),
505 );
506 row_bits.max(endpoint_bits)
507 }
508
509 pub fn evaluate_objective(&self, values: &[i64]) -> Result<C, EvaluationError> {
511 self.objective
512 .iter()
513 .try_fold(C::zero(), |sum, &(variable, coefficient)| {
514 let integer = values.get(variable).copied().ok_or_else(|| {
515 EvaluationError::InvalidConfiguration(format!(
516 "the ILP objective references variable {variable}, but the assignment has {} values",
517 values.len()
518 ))
519 })?;
520 let value = C::from_integer(integer)?;
521 let product = C::checked_mul_sum(
522 coefficient,
523 value,
524 "multiplying a term in the ILP objective",
525 )?;
526 C::checked_add_to_sum(
527 sum,
528 product,
529 "summing the ILP objective",
530 )
531 })
532 }
533
534 pub fn is_feasible(&self, values: &[i64]) -> Result<bool, EvaluationError> {
536 if values.len() != self.variables.len() {
537 return Err(EvaluationError::InvalidConfiguration(
538 "variable assignment length does not match the ILP".into(),
539 ));
540 }
541 if self
542 .variables
543 .iter()
544 .zip(values)
545 .any(|(&variable, &value)| !variable.contains(value))
546 {
547 return Ok(false);
548 }
549 for constraint in &self.constraints {
550 if !constraint.is_satisfied(values)? {
551 return Ok(false);
552 }
553 }
554 Ok(true)
555 }
556}
557
558fn construction_arithmetic_error(
559 error: NumericArithmeticError,
560 context: String,
561) -> ConstructionError {
562 match error {
563 NumericArithmeticError::IntegerOverflow => ConstructionError::IntegerOverflow(context),
564 NumericArithmeticError::NonFiniteResult => ConstructionError::NonFiniteFloat(context),
565 }
566}
567
568fn normalize_constraint<C: ILPCoefficient>(
569 constraint: LinearConstraint<C>,
570 num_variables: usize,
571 constraint_index: usize,
572) -> Result<LinearConstraint<C>, ConstructionError> {
573 let mut terms = constraint.terms;
574 constraint
575 .rhs
576 .validate_element("ILP constraint right-hand side")?;
577 for &(variable, coefficient) in &terms {
578 if variable >= num_variables {
579 return Err(ConstructionError::Conversion(format!(
580 "ILP constraint {constraint_index} references variable {variable}, but the model has {num_variables} variables"
581 )));
582 }
583 coefficient.validate_element("ILP constraint coefficient")?;
584 }
585 terms.sort_by_key(|&(variable, _)| variable);
586 let mut normalized: Vec<(usize, C)> = Vec::with_capacity(terms.len());
587 for (variable, coefficient) in terms {
588 if let Some((previous_variable, previous_coefficient)) = normalized.last_mut() {
589 if *previous_variable == variable {
590 *previous_coefficient = previous_coefficient
591 .checked_add_value(coefficient)
592 .map_err(|error| {
593 construction_arithmetic_error(
594 error,
595 format!(
596 "merging duplicate variable {variable} in ILP constraint {constraint_index}"
597 ),
598 )
599 })?;
600 continue;
601 }
602 }
603 normalized.push((variable, coefficient));
604 }
605 normalized.retain(|&(_, coefficient)| !coefficient.is_zero());
606 Ok(LinearConstraint::new(
607 normalized,
608 constraint.comparison,
609 constraint.rhs,
610 ))
611}
612
613fn normalize_objective<C: ILPCoefficient>(
614 mut objective: Vec<(usize, C)>,
615 num_variables: usize,
616) -> Result<Vec<(usize, C)>, ConstructionError> {
617 for &(variable, coefficient) in &objective {
618 if variable >= num_variables {
619 return Err(ConstructionError::Conversion(format!(
620 "ILP objective references variable {variable}, but the model has {num_variables} variables"
621 )));
622 }
623 coefficient.validate_element("ILP objective coefficient")?;
624 }
625 objective.sort_by_key(|&(variable, _)| variable);
626 let mut normalized: Vec<(usize, C)> = Vec::with_capacity(objective.len());
627 for (variable, coefficient) in objective {
628 if let Some((previous_variable, previous_coefficient)) = normalized.last_mut() {
629 if *previous_variable == variable {
630 *previous_coefficient = previous_coefficient
631 .checked_add_value(coefficient)
632 .map_err(|error| {
633 construction_arithmetic_error(
634 error,
635 format!("merged objective coefficient of variable {variable}"),
636 )
637 })?;
638 continue;
639 }
640 }
641 normalized.push((variable, coefficient));
642 }
643 normalized.retain(|&(_, coefficient)| !coefficient.is_zero());
644 Ok(normalized)
645}
646
647impl<V: VariableDomain, C: ILPCoefficient, B: BoundsPolicy> Problem for ILP<V, C, B> {
648 const NAME: &'static str = "ILP";
649 type Solution = Vec<i64>;
650 type Value = Extremum<C>;
651
652 crate::problem_parameters![
653 (
654 "max_constraint_magnitude_bits",
655 max_constraint_magnitude_bits
656 ),
657 ("num_constraints", num_constraints),
658 ("num_nonzeros", num_nonzeros),
659 ("num_vars", num_vars),
660 ];
661
662 fn evaluate(&self, solution: &Self::Solution) -> Result<Self::Value, EvaluationError> {
663 if !self.is_feasible(solution)? {
664 return Ok(match self.sense {
665 ObjectiveSense::Maximize => Extremum::maximize(None),
666 ObjectiveSense::Minimize => Extremum::minimize(None),
667 });
668 }
669 let objective = self.evaluate_objective(solution)?;
670 Ok(match self.sense {
671 ObjectiveSense::Maximize => Extremum::maximize(Some(objective)),
672 ObjectiveSense::Minimize => Extremum::minimize(Some(objective)),
673 })
674 }
675
676 fn variant() -> Vec<(&'static str, &'static str)> {
677 vec![
678 ("variable", V::NAME),
679 ("coefficient", C::NAME),
680 ("bounds", B::NAME),
681 ]
682 }
683}
684
685crate::declare_variants! {
686 default ILP<bool, i64> => "2^num_vars",
687 ILP<i64, i64> => "num_vars^num_vars",
688 ILP<i64, i64, Bounded> => "num_vars^num_vars",
689 ILP<bool, f64> => "2^num_vars",
690 ILP<i64, f64> => "num_vars^num_vars",
691}
692
693#[cfg(feature = "example-db")]
694pub(crate) fn canonical_model_example_specs() -> Vec<crate::example_db::specs::ModelExampleSpec> {
695 vec![
696 crate::example_db::specs::ModelExampleSpec {
697 id: "ilp",
698 instance: Box::new(
699 ILP::<i64, i64>::new(
700 2,
701 vec![
702 LinearConstraint::le(vec![(0, 1), (1, 1)], 5),
703 LinearConstraint::le(vec![(0, 4), (1, 7)], 28),
704 ],
705 vec![(0, -5), (1, -6)],
706 ObjectiveSense::Minimize,
707 )
708 .expect("canonical ILP construction must succeed"),
709 ),
710 optimal_config: serde_json::json!(vec![3, 2]),
711 optimal_value: serde_json::json!({
712 "sense": "Minimize",
713 "value": -27,
714 }),
715 },
716 crate::example_db::specs::ModelExampleSpec {
717 id: "bounded_ilp",
718 instance: Box::new(
719 ILP::<i64, i64, Bounded>::with_variables(
720 vec![IntegerVariable::new(Some(-2), Some(3)).unwrap()],
721 vec![LinearConstraint::le(vec![(0, 1)], 1)],
722 vec![(0, 3)],
723 ObjectiveSense::Maximize,
724 )
725 .unwrap(),
726 ),
727 optimal_config: serde_json::json!([1]),
728 optimal_value: serde_json::json!({"sense": "Maximize", "value": 3}),
729 },
730 ]
731}
732
733#[cfg(test)]
734#[path = "../../unit_tests/models/algebraic/ilp.rs"]
735mod tests;