1use pounce_common::types::{Number, lower_bound_present, upper_bound_present};
49
50#[derive(Debug, Clone, Copy, PartialEq)]
52pub enum VarRecovery {
53 Kept(usize),
55 Constant(Number),
57 Affine {
60 rep: usize,
61 coeff: Number,
62 offset: Number,
63 },
64}
65
66#[derive(Debug, Clone, Copy, PartialEq)]
72pub struct ElimStep {
73 pub row: usize,
75 pub var: usize,
77 pub pivot: Number,
80}
81
82#[derive(Debug, Clone, Copy)]
84pub struct PlanConfig {
85 pub eq_tol: Number,
87 pub coeff_tol: Number,
90 pub feas_tol: Number,
95 pub max_passes: usize,
97}
98
99impl Default for PlanConfig {
100 fn default() -> Self {
101 Self {
102 eq_tol: 1e-12,
103 coeff_tol: 1e-12,
104 feas_tol: 1e-8,
105 max_passes: 50,
106 }
107 }
108}
109
110#[derive(Debug, Clone, Copy)]
112pub struct PlanInput<'a> {
113 pub n_vars: usize,
114 pub n_rows: usize,
115 pub rows: &'a [Vec<(usize, Number)>],
118 pub row_const: &'a [Number],
121 pub g_l: &'a [Number],
122 pub g_u: &'a [Number],
123 pub eligible: &'a [bool],
125 pub x_l: &'a [Number],
126 pub x_u: &'a [Number],
127}
128
129#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
132pub struct LinearEqElimReport {
133 pub n_constant_vars: usize,
136 pub n_aggregated_vars: usize,
138 pub n_rows_eliminated: usize,
140 pub n_redundant_rows: usize,
142 pub passes: usize,
144 pub pass_cap_hit: bool,
146 pub infeasible: bool,
148}
149
150#[derive(Debug, Clone, Default)]
153pub struct EliminationPlan {
154 pub n_full: usize,
155 pub m_full: usize,
156 pub recovery: Vec<VarRecovery>,
158 pub vars_kept: Vec<usize>,
160 pub row_kept: Vec<bool>,
162 pub rows_kept: Vec<usize>,
164 pub x_l_red: Vec<Number>,
167 pub x_u_red: Vec<Number>,
168 pub x_l_src: Vec<usize>,
183 pub x_u_src: Vec<usize>,
184 pub steps: Vec<ElimStep>,
186 pub parent: Vec<Option<(usize, Number)>>,
191 pub report: LinearEqElimReport,
192}
193
194impl EliminationPlan {
195 pub fn identity(n_vars: usize, n_rows: usize, x_l: &[Number], x_u: &[Number]) -> Self {
197 Self {
198 n_full: n_vars,
199 m_full: n_rows,
200 recovery: (0..n_vars).map(VarRecovery::Kept).collect(),
201 vars_kept: (0..n_vars).collect(),
202 row_kept: vec![true; n_rows],
203 rows_kept: (0..n_rows).collect(),
204 x_l_red: x_l.to_vec(),
205 x_u_red: x_u.to_vec(),
206 x_l_src: (0..n_vars).collect(),
207 x_u_src: (0..n_vars).collect(),
208 steps: Vec::new(),
209 parent: vec![None; n_vars],
210 report: LinearEqElimReport::default(),
211 }
212 }
213
214 pub fn is_identity(&self) -> bool {
217 self.steps.is_empty() && self.report.n_redundant_rows == 0
218 }
219
220 pub fn n_reduced_vars(&self) -> usize {
221 self.vars_kept.len()
222 }
223
224 pub fn n_reduced_rows(&self) -> usize {
225 self.rows_kept.len()
226 }
227
228 pub fn lift_x(&self, x_red: &[Number], out: &mut [Number]) {
230 debug_assert_eq!(out.len(), self.n_full);
231 for (red, &full) in self.vars_kept.iter().enumerate() {
235 out[full] = x_red[red];
236 }
237 for (i, rec) in self.recovery.iter().enumerate() {
238 match *rec {
239 VarRecovery::Kept(_) => {}
240 VarRecovery::Constant(c) => out[i] = c,
241 VarRecovery::Affine { rep, coeff, offset } => out[i] = coeff * out[rep] + offset,
242 }
243 }
244 }
245
246 pub fn project_x(&self, x_full: &[Number], out: &mut [Number]) {
248 for (red, &full) in self.vars_kept.iter().enumerate() {
249 out[red] = x_full[full];
250 }
251 }
252}
253
254struct Box2 {
257 lo: Vec<Number>,
258 hi: Vec<Number>,
259 lo_src: Vec<usize>,
264 hi_src: Vec<usize>,
265}
266
267impl Box2 {
268 fn from_declared(x_l: &[Number], x_u: &[Number]) -> Self {
269 Self {
270 lo_src: (0..x_l.len()).collect(),
271 hi_src: (0..x_u.len()).collect(),
272 lo: x_l
273 .iter()
274 .map(|&v| {
275 if lower_bound_present(v) {
276 v
277 } else {
278 Number::NEG_INFINITY
279 }
280 })
281 .collect(),
282 hi: x_u
283 .iter()
284 .map(|&v| {
285 if upper_bound_present(v) {
286 v
287 } else {
288 Number::INFINITY
289 }
290 })
291 .collect(),
292 }
293 }
294}
295
296struct Substitutions {
298 rep: Vec<usize>,
300 to_root: Vec<(Number, Number)>,
302 cluster_size: Vec<usize>,
304 root_const: Vec<Option<Number>>,
306}
307
308impl Substitutions {
309 fn new(n: usize) -> Self {
310 Self {
311 rep: (0..n).collect(),
312 to_root: vec![(1.0, 0.0); n],
313 cluster_size: vec![1; n],
314 root_const: vec![None; n],
315 }
316 }
317
318 fn find(&mut self, i: usize) -> (usize, Number, Number) {
321 let mut cur = i;
322 let mut path: Vec<usize> = Vec::new();
323 while self.rep[cur] != cur {
324 path.push(cur);
325 cur = self.rep[cur];
326 }
327 let root = cur;
328 let (mut acc_a, mut acc_b) = (1.0, 0.0);
329 for &node in path.iter().rev() {
330 let (a, b) = self.to_root[node];
331 let na = a * acc_a;
332 let nb = a * acc_b + b;
333 self.rep[node] = root;
334 self.to_root[node] = (na, nb);
335 acc_a = na;
336 acc_b = nb;
337 }
338 if i == root {
339 (root, 1.0, 0.0)
340 } else {
341 let (a, b) = self.to_root[i];
342 (root, a, b)
343 }
344 }
345}
346
347pub fn build_plan(input: &PlanInput<'_>, cfg: &PlanConfig) -> EliminationPlan {
350 let n = input.n_vars;
351 let m = input.n_rows;
352 let identity = || EliminationPlan::identity(n, m, input.x_l, input.x_u);
353 if n == 0 {
354 return identity();
355 }
356
357 let mut subs = Substitutions::new(n);
358 let mut bounds = Box2::from_declared(input.x_l, input.x_u);
359 let mut parent: Vec<Option<(usize, Number)>> = vec![None; n];
360 let mut steps: Vec<ElimStep> = Vec::new();
361 let mut row_consumed = vec![false; m];
362 let mut redundant_rows: Vec<usize> = Vec::new();
363 let mut report = LinearEqElimReport::default();
364
365 for j in 0..n {
370 let (lo, hi) = (bounds.lo[j], bounds.hi[j]);
371 if !lo.is_finite() || !hi.is_finite() {
372 continue;
373 }
374 if hi - lo <= cfg.eq_tol * lo.abs().max(hi.abs()).max(1.0) {
375 subs.root_const[j] = Some(0.5 * (lo + hi));
376 report.n_constant_vars += 1;
377 }
378 }
379
380 let mut candidates: Vec<usize> = (0..m)
382 .filter(|&r| {
383 input.eligible[r]
384 && lower_bound_present(input.g_l[r])
385 && upper_bound_present(input.g_u[r])
386 && (input.g_u[r] - input.g_l[r]).abs() <= cfg.eq_tol * input.g_l[r].abs().max(1.0)
387 })
388 .collect();
389 if candidates.is_empty() {
390 return finish(
391 n,
392 m,
393 input,
394 subs,
395 bounds,
396 parent,
397 steps,
398 redundant_rows,
399 report,
400 );
401 }
402
403 let mut terms: Vec<(usize, Number)> = Vec::new();
404 for pass in 0..cfg.max_passes.max(1) {
405 let mut changed = false;
406 report.passes = pass + 1;
407 for &r in &candidates {
408 if row_consumed[r] {
409 continue;
410 }
411 terms.clear();
413 let mut rhs = input.g_l[r] - input.row_const[r];
414 let mut row_scale: Number = 0.0;
415 let mut ok = true;
416 for &(j, a) in &input.rows[r] {
417 if a == 0.0 {
418 continue;
419 }
420 if j >= n {
421 ok = false;
422 break;
423 }
424 let (root, ra, rb) = subs.find(j);
425 row_scale = row_scale.max((a * ra).abs());
426 match subs.root_const[root] {
427 Some(c) => rhs -= a * (ra * c + rb),
428 None => {
429 rhs -= a * rb;
430 match terms.iter_mut().find(|(v, _)| *v == root) {
431 Some(slot) => slot.1 += a * ra,
432 None => terms.push((root, a * ra)),
433 }
434 }
435 }
436 }
437 if !ok || !rhs.is_finite() {
438 continue;
439 }
440 let drop_below = cfg.coeff_tol * row_scale.max(1.0);
441 terms.retain(|&(_, a)| a.abs() > drop_below);
442
443 match terms.len() {
444 0 => {
445 if rhs.abs() <= cfg.feas_tol * row_scale.max(1.0) {
449 row_consumed[r] = true;
450 redundant_rows.push(r);
451 report.n_redundant_rows += 1;
452 changed = true;
453 } else {
454 report.infeasible = true;
455 return abandoned(identity(), report);
456 }
457 }
458 1 => {
459 let (v, a) = terms[0];
460 let value = rhs / a;
461 if !value.is_finite() {
462 continue;
463 }
464 match clamp_into_box(value, bounds.lo[v], bounds.hi[v], cfg.feas_tol) {
465 Some(pinned) => {
466 subs.root_const[v] = Some(pinned);
467 bounds.lo[v] = pinned;
468 bounds.hi[v] = pinned;
469 row_consumed[r] = true;
470 steps.push(ElimStep {
471 row: r,
472 var: v,
473 pivot: a,
474 });
475 report.n_constant_vars += 1;
476 report.n_rows_eliminated += 1;
477 changed = true;
478 }
479 None => {
480 report.infeasible = true;
481 return abandoned(identity(), report);
482 }
483 }
484 }
485 2 => {
486 let (v0, a0) = terms[0];
487 let (v1, a1) = terms[1];
488 let (elim, keep, a_elim, a_keep) = if a0.abs() > 4.0 * a1.abs() {
495 (v0, v1, a0, a1)
496 } else if a1.abs() > 4.0 * a0.abs() {
497 (v1, v0, a1, a0)
498 } else if subs.cluster_size[v0] <= subs.cluster_size[v1] {
499 (v0, v1, a0, a1)
500 } else {
501 (v1, v0, a1, a0)
502 };
503 let alpha = -a_keep / a_elim;
504 let beta = rhs / a_elim;
505 if !alpha.is_finite() || !beta.is_finite() || alpha == 0.0 {
506 continue;
507 }
508 if !transfer_bounds(&mut bounds, elim, keep, alpha, beta, cfg.feas_tol) {
510 report.infeasible = true;
511 return abandoned(identity(), report);
512 }
513 subs.rep[elim] = keep;
514 subs.to_root[elim] = (alpha, beta);
515 subs.cluster_size[keep] += subs.cluster_size[elim];
516 parent[elim] = Some((keep, alpha));
517 row_consumed[r] = true;
518 steps.push(ElimStep {
519 row: r,
520 var: elim,
521 pivot: a_elim,
522 });
523 report.n_aggregated_vars += 1;
524 report.n_rows_eliminated += 1;
525 changed = true;
526 }
527 _ => {}
528 }
529 }
530 candidates.retain(|&r| !row_consumed[r]);
531 if !changed || candidates.is_empty() {
532 break;
533 }
534 if pass + 1 == cfg.max_passes.max(1) {
535 report.pass_cap_hit = true;
536 }
537 }
538
539 finish(
540 n,
541 m,
542 input,
543 subs,
544 bounds,
545 parent,
546 steps,
547 redundant_rows,
548 report,
549 )
550}
551
552fn abandoned(mut plan: EliminationPlan, report: LinearEqElimReport) -> EliminationPlan {
553 plan.report = LinearEqElimReport {
554 infeasible: report.infeasible,
555 passes: report.passes,
556 ..LinearEqElimReport::default()
557 };
558 plan
559}
560
561fn clamp_into_box(value: Number, lo: Number, hi: Number, tol: Number) -> Option<Number> {
570 let scale = value
571 .abs()
572 .max(lo.abs().min(1e19))
573 .max(hi.abs().min(1e19))
574 .max(1.0);
575 if lo.is_finite() && value < lo {
576 if lo - value > tol * scale {
577 return None;
578 }
579 return Some(lo);
580 }
581 if hi.is_finite() && value > hi {
582 if value - hi > tol * scale {
583 return None;
584 }
585 return Some(hi);
586 }
587 Some(value)
588}
589
590fn transfer_bounds(
593 bounds: &mut Box2,
594 elim: usize,
595 keep: usize,
596 alpha: Number,
597 beta: Number,
598 tol: Number,
599) -> bool {
600 let (lo_e, hi_e) = (bounds.lo[elim], bounds.hi[elim]);
601 let a = (lo_e - beta) / alpha;
604 let b = (hi_e - beta) / alpha;
605 let (mut derived_lo, mut derived_hi) = if alpha > 0.0 { (a, b) } else { (b, a) };
606 if !derived_lo.is_finite() {
607 derived_lo = Number::NEG_INFINITY;
608 }
609 if !derived_hi.is_finite() {
610 derived_hi = Number::INFINITY;
611 }
612 let (src_lo, src_hi) = if alpha > 0.0 {
617 (bounds.lo_src[elim], bounds.hi_src[elim])
618 } else {
619 (bounds.hi_src[elim], bounds.lo_src[elim])
620 };
621 if derived_lo > bounds.lo[keep] {
624 bounds.lo[keep] = derived_lo;
625 bounds.lo_src[keep] = src_lo;
626 }
627 if derived_hi < bounds.hi[keep] {
628 bounds.hi[keep] = derived_hi;
629 bounds.hi_src[keep] = src_hi;
630 }
631 let (lo, hi) = (bounds.lo[keep], bounds.hi[keep]);
632 if lo.is_finite() && hi.is_finite() && lo > hi {
633 let scale = lo.abs().max(hi.abs()).max(1.0);
634 if lo - hi > tol * scale {
635 return false;
636 }
637 let mid = 0.5 * (lo + hi);
640 bounds.lo[keep] = mid;
641 bounds.hi[keep] = mid;
642 }
643 true
644}
645
646#[allow(clippy::too_many_arguments)]
647fn finish(
648 n: usize,
649 m: usize,
650 input: &PlanInput<'_>,
651 mut subs: Substitutions,
652 bounds: Box2,
653 parent: Vec<Option<(usize, Number)>>,
654 steps: Vec<ElimStep>,
655 redundant_rows: Vec<usize>,
656 report: LinearEqElimReport,
657) -> EliminationPlan {
658 if steps.is_empty() && redundant_rows.is_empty() {
659 let mut plan = EliminationPlan::identity(n, m, input.x_l, input.x_u);
660 plan.report = report;
661 return plan;
662 }
663
664 let mut recovery = vec![VarRecovery::Kept(usize::MAX); n];
666 let mut vars_kept: Vec<usize> = Vec::new();
667 let mut reduced_of = vec![usize::MAX; n];
668 for (j, slot) in reduced_of.iter_mut().enumerate() {
669 let (root, _, _) = subs.find(j);
670 if root == j && subs.root_const[j].is_none() {
671 *slot = vars_kept.len();
672 vars_kept.push(j);
673 }
674 }
675 if vars_kept.is_empty() {
676 let mut plan = EliminationPlan::identity(n, m, input.x_l, input.x_u);
680 plan.report = LinearEqElimReport {
681 passes: report.passes,
682 ..LinearEqElimReport::default()
683 };
684 return plan;
685 }
686 for j in 0..n {
687 let (root, a, b) = subs.find(j);
688 recovery[j] = match subs.root_const[root] {
689 Some(c) => VarRecovery::Constant(a * c + b),
690 None if root == j => VarRecovery::Kept(reduced_of[j]),
691 None => VarRecovery::Affine {
692 rep: root,
693 coeff: a,
694 offset: b,
695 },
696 };
697 }
698
699 let mut row_kept = vec![true; m];
700 for s in &steps {
701 row_kept[s.row] = false;
702 }
703 for &r in &redundant_rows {
704 row_kept[r] = false;
705 }
706 let rows_kept: Vec<usize> = (0..m).filter(|&r| row_kept[r]).collect();
707
708 let mut x_l_red = Vec::with_capacity(vars_kept.len());
711 let mut x_u_red = Vec::with_capacity(vars_kept.len());
712 let mut x_l_src = Vec::with_capacity(vars_kept.len());
713 let mut x_u_src = Vec::with_capacity(vars_kept.len());
714 for &j in &vars_kept {
715 if bounds.lo[j].is_finite() {
716 x_l_red.push(bounds.lo[j]);
717 x_l_src.push(bounds.lo_src[j]);
718 } else {
719 x_l_red.push(input.x_l[j]);
720 x_l_src.push(j);
721 }
722 if bounds.hi[j].is_finite() {
723 x_u_red.push(bounds.hi[j]);
724 x_u_src.push(bounds.hi_src[j]);
725 } else {
726 x_u_red.push(input.x_u[j]);
727 x_u_src.push(j);
728 }
729 }
730
731 EliminationPlan {
732 n_full: n,
733 m_full: m,
734 recovery,
735 vars_kept,
736 row_kept,
737 rows_kept,
738 x_l_red,
739 x_u_red,
740 x_l_src,
741 x_u_src,
742 steps,
743 parent,
744 report,
745 }
746}
747
748#[cfg(test)]
749mod tests {
750 use super::*;
751
752 struct Fixture {
753 rows: Vec<Vec<(usize, Number)>>,
754 row_const: Vec<Number>,
755 g_l: Vec<Number>,
756 g_u: Vec<Number>,
757 eligible: Vec<bool>,
758 x_l: Vec<Number>,
759 x_u: Vec<Number>,
760 n: usize,
761 }
762
763 impl Fixture {
764 fn new(n: usize) -> Self {
765 Self {
766 rows: Vec::new(),
767 row_const: Vec::new(),
768 g_l: Vec::new(),
769 g_u: Vec::new(),
770 eligible: Vec::new(),
771 x_l: vec![-1e19; n],
772 x_u: vec![1e19; n],
773 n,
774 }
775 }
776 fn eq(mut self, entries: &[(usize, Number)], b: Number) -> Self {
778 self.rows.push(entries.to_vec());
779 self.row_const.push(0.0);
780 self.g_l.push(b);
781 self.g_u.push(b);
782 self.eligible.push(true);
783 self
784 }
785 fn opaque(mut self, entries: &[(usize, Number)], lo: Number, hi: Number) -> Self {
787 self.rows.push(entries.to_vec());
788 self.row_const.push(0.0);
789 self.g_l.push(lo);
790 self.g_u.push(hi);
791 self.eligible.push(false);
792 self
793 }
794 fn bounds(mut self, j: usize, lo: Number, hi: Number) -> Self {
795 self.x_l[j] = lo;
796 self.x_u[j] = hi;
797 self
798 }
799 fn plan(&self) -> EliminationPlan {
800 build_plan(
801 &PlanInput {
802 n_vars: self.n,
803 n_rows: self.rows.len(),
804 rows: &self.rows,
805 row_const: &self.row_const,
806 g_l: &self.g_l,
807 g_u: &self.g_u,
808 eligible: &self.eligible,
809 x_l: &self.x_l,
810 x_u: &self.x_u,
811 },
812 &PlanConfig::default(),
813 )
814 }
815 }
816
817 fn assert_rows_hold(f: &Fixture, plan: &EliminationPlan, y: &[Number]) {
820 let mut x = vec![0.0; f.n];
821 plan.lift_x(y, &mut x);
822 for (r, entries) in f.rows.iter().enumerate() {
823 if plan.row_kept[r] || !f.eligible[r] {
824 continue;
825 }
826 let lhs: Number =
827 entries.iter().map(|&(j, a)| a * x[j]).sum::<Number>() + f.row_const[r];
828 assert!(
829 (lhs - f.g_l[r]).abs() < 1e-9,
830 "dropped row {r} violated: {lhs} != {}",
831 f.g_l[r]
832 );
833 }
834 }
835
836 #[test]
837 fn singleton_row_pins_its_variable() {
838 let f = Fixture::new(2).eq(&[(0, 2.0)], 6.0);
839 let p = f.plan();
840 assert_eq!(p.recovery[0], VarRecovery::Constant(3.0));
841 assert_eq!(p.recovery[1], VarRecovery::Kept(0));
842 assert_eq!(p.vars_kept, vec![1]);
843 assert_eq!(p.rows_kept, Vec::<usize>::new());
844 assert_eq!(p.report.n_constant_vars, 1);
845 assert_rows_hold(&f, &p, &[7.5]);
846 }
847
848 #[test]
849 fn free_free_pair_aggregates_with_no_anchor() {
850 let f = Fixture::new(2).eq(&[(0, 1.0), (1, -1.0)], 0.0);
853 let p = f.plan();
854 assert_eq!(p.n_reduced_vars(), 1);
855 assert_eq!(p.n_reduced_rows(), 0);
856 assert_eq!(p.report.n_aggregated_vars, 1);
857 assert_rows_hold(&f, &p, &[4.25]);
858 let mut x = vec![0.0; 2];
859 p.lift_x(&[4.25], &mut x);
860 assert!((x[0] - x[1]).abs() < 1e-12);
861 }
862
863 #[test]
864 fn chains_propagate_regardless_of_row_order() {
865 let f = Fixture::new(5)
869 .eq(&[(2, 1.0), (3, -1.0)], 0.0)
870 .eq(&[(1, 1.0), (2, -1.0)], 0.0)
871 .eq(&[(0, 1.0), (1, -1.0)], 0.0)
872 .eq(&[(3, 2.0)], 8.0);
873 let p = f.plan();
874 assert_eq!(p.vars_kept, vec![4]);
876 assert_eq!(p.n_reduced_rows(), 0);
877 let mut x = vec![0.0; 5];
878 p.lift_x(&[9.0], &mut x);
879 for (j, v) in x.iter().take(4).enumerate() {
880 assert!((v - 4.0).abs() < 1e-12, "x{j} = {v}");
881 }
882 assert_rows_hold(&f, &p, &[9.0]);
883 }
884
885 #[test]
886 fn a_fully_determined_model_stands_down() {
887 let f = Fixture::new(2)
890 .eq(&[(0, 1.0), (1, -1.0)], 0.0)
891 .eq(&[(1, 2.0)], 8.0);
892 let p = f.plan();
893 assert!(p.is_identity());
894 assert_eq!(p.n_reduced_vars(), 2);
895 assert_eq!(p.n_reduced_rows(), 2);
896 }
897
898 #[test]
899 fn chain_with_a_free_tail_collapses_to_one_column() {
900 let f = Fixture::new(4)
903 .eq(&[(0, 1.0), (1, -1.0)], 0.0)
904 .eq(&[(1, 1.0), (2, -1.0)], 0.0)
905 .eq(&[(2, 1.0), (3, -1.0)], 0.0);
906 let p = f.plan();
907 assert_eq!(p.n_reduced_vars(), 1);
908 assert_eq!(p.n_reduced_rows(), 0);
909 let mut x = vec![0.0; 4];
910 p.lift_x(&[2.5], &mut x);
911 for v in &x {
912 assert!((v - 2.5).abs() < 1e-12, "{x:?}");
913 }
914 assert_rows_hold(&f, &p, &[2.5]);
915 }
916
917 #[test]
918 fn every_recovery_representative_is_a_survivor() {
919 let f = Fixture::new(5)
923 .eq(&[(0, 1.0), (1, -2.0)], 1.0)
924 .eq(&[(1, 1.0), (2, -3.0)], 2.0)
925 .eq(&[(2, 1.0), (3, -4.0)], 3.0);
926 let p = f.plan();
927 for rec in &p.recovery {
928 if let VarRecovery::Affine { rep, coeff, .. } = *rec {
929 assert!(
930 matches!(p.recovery[rep], VarRecovery::Kept(_)),
931 "representative {rep} is not a survivor"
932 );
933 assert!(coeff != 0.0);
934 }
935 }
936 assert_rows_hold(&f, &p, &vec![1.0; p.n_reduced_vars()]);
937 }
938
939 #[test]
940 fn a_fixed_variable_exposes_a_two_term_row() {
941 let f = Fixture::new(3)
944 .eq(&[(0, 1.0), (1, 1.0), (2, 1.0)], 10.0)
945 .bounds(2, 4.0, 4.0);
946 let p = f.plan();
947 assert_eq!(p.recovery[2], VarRecovery::Constant(4.0));
948 assert_eq!(p.n_reduced_vars(), 1);
949 let mut x = vec![0.0; 3];
950 p.lift_x(&[1.5], &mut x);
951 assert!((x[0] + x[1] + x[2] - 10.0).abs() < 1e-12, "{x:?}");
952 }
953
954 #[test]
955 fn bounds_transfer_onto_the_survivor() {
956 let f = Fixture::new(2)
958 .eq(&[(0, 1.0), (1, -2.0)], 0.0)
959 .bounds(0, 4.0, 10.0);
960 let p = f.plan();
961 assert_eq!(p.vars_kept, vec![1]);
962 assert!((p.x_l_red[0] - 2.0).abs() < 1e-12, "{:?}", p.x_l_red);
963 assert!((p.x_u_red[0] - 5.0).abs() < 1e-12, "{:?}", p.x_u_red);
964 }
965
966 #[test]
967 fn negative_coefficient_flips_the_transferred_bounds() {
968 let f = Fixture::new(2)
970 .eq(&[(0, 1.0), (1, 1.0)], 0.0)
971 .bounds(0, 1.0, 3.0);
972 let p = f.plan();
973 assert_eq!(p.vars_kept, vec![1]);
974 assert!((p.x_l_red[0] + 3.0).abs() < 1e-12, "{:?}", p.x_l_red);
975 assert!((p.x_u_red[0] + 1.0).abs() < 1e-12, "{:?}", p.x_u_red);
976 }
977
978 #[test]
981 fn a_transferred_bound_names_the_column_it_came_from() {
982 let f = Fixture::new(2)
985 .eq(&[(0, 1.0), (1, -2.0)], 0.0)
986 .bounds(0, -1e19, 1.0)
987 .bounds(1, -4.0, 1e19);
988 let p = f.plan();
989 assert_eq!(p.vars_kept, vec![1]);
990 assert!((p.x_u_red[0] - 0.5).abs() < 1e-12, "{:?}", p.x_u_red);
991 assert_eq!(p.x_u_src, vec![0], "the upper bound is x0's");
992 assert_eq!(p.x_l_src, vec![1], "the lower bound is x1's own");
993 }
994
995 #[test]
998 fn a_negative_coefficient_flips_which_side_the_provenance_lands_on() {
999 let f = Fixture::new(2)
1001 .eq(&[(0, 1.0), (1, 2.0)], 0.0)
1002 .bounds(0, -1e19, 1.0);
1003 let p = f.plan();
1004 assert_eq!(p.vars_kept, vec![1]);
1005 assert!((p.x_l_red[0] + 0.5).abs() < 1e-12, "{:?}", p.x_l_red);
1006 assert_eq!(p.x_l_src, vec![0], "x1's lower bound is x0's upper bound");
1007 assert_eq!(p.x_u_src, vec![1], "nothing tightened x1 from above");
1008 }
1009
1010 #[test]
1013 fn provenance_survives_a_chain_of_transfers() {
1014 let f = Fixture::new(3)
1019 .eq(&[(0, 1.0), (1, 1.0)], 0.0)
1020 .eq(&[(1, 1.0), (2, 0.1)], 0.0)
1021 .bounds(0, -1e19, 1.0);
1022 let p = f.plan();
1023 assert_eq!(p.vars_kept, vec![2]);
1024 assert!((p.x_u_red[0] - 10.0).abs() < 1e-12, "{:?}", p.x_u_red);
1025 assert_eq!(p.x_u_src, vec![0]);
1026 assert_eq!(p.x_l_src, vec![2], "nothing tightened x2 from below");
1027 assert_eq!(
1030 p.recovery[0],
1031 VarRecovery::Affine {
1032 rep: 2,
1033 coeff: 0.1,
1034 offset: 0.0
1035 }
1036 );
1037 }
1038
1039 #[test]
1042 fn a_tied_transfer_leaves_the_provenance_on_the_survivor() {
1043 let f = Fixture::new(2)
1045 .eq(&[(0, 1.0), (1, -2.0)], 0.0)
1046 .bounds(0, -1e19, 1.0)
1047 .bounds(1, -1e19, 0.5);
1048 let p = f.plan();
1049 assert_eq!(p.vars_kept, vec![1]);
1050 assert!((p.x_u_red[0] - 0.5).abs() < 1e-12, "{:?}", p.x_u_red);
1051 assert_eq!(p.x_u_src, vec![1]);
1052 }
1053
1054 #[test]
1057 fn provenance_and_the_recovery_map_agree_on_every_reduced_bound() {
1058 let f = Fixture::new(4)
1059 .eq(&[(0, 1.0), (1, 3.0)], 6.0)
1060 .eq(&[(1, 2.0), (2, -0.5)], 1.0)
1061 .opaque(&[(2, 1.0), (3, 1.0)], 0.0, 10.0)
1062 .bounds(0, -2.0, 7.0)
1063 .bounds(1, -5.0, 5.0)
1064 .bounds(2, -20.0, 20.0);
1065 let p = f.plan();
1066 assert!(!p.is_identity());
1067 for (red, &kept) in p.vars_kept.iter().enumerate() {
1068 for (src, red_bound, upper) in [
1069 (p.x_l_src[red], p.x_l_red[red], false),
1070 (p.x_u_src[red], p.x_u_red[red], true),
1071 ] {
1072 if src == kept || !red_bound.is_finite() || red_bound.abs() >= 1e19 {
1073 continue;
1074 }
1075 let VarRecovery::Affine { rep, coeff, offset } = p.recovery[src] else {
1076 panic!(
1077 "provenance {src} is not an affine image: {:?}",
1078 p.recovery[src]
1079 );
1080 };
1081 assert_eq!(rep, kept, "provenance {src} names a different survivor");
1082 let origin = if upper != (coeff < 0.0) {
1085 f.x_u[src]
1086 } else {
1087 f.x_l[src]
1088 };
1089 let lifted = coeff * red_bound + offset;
1090 assert!(
1091 (lifted - origin).abs() < 1e-12,
1092 "reduced bound {red_bound} lifts to {lifted}, not {src}'s {origin}"
1093 );
1094 }
1095 }
1096 }
1097
1098 #[test]
1099 fn absent_bounds_keep_the_callers_sentinel() {
1100 let f = Fixture::new(2).eq(&[(0, 1.0), (1, -1.0)], 0.0);
1101 let p = f.plan();
1102 assert_eq!(p.x_l_red[0], -1e19);
1103 assert_eq!(p.x_u_red[0], 1e19);
1104 }
1105
1106 #[test]
1107 fn redundant_row_after_substitution_is_dropped() {
1108 let f = Fixture::new(3)
1110 .eq(&[(0, 1.0), (1, -1.0)], 0.0)
1111 .eq(&[(1, 1.0), (2, -1.0)], 0.0)
1112 .eq(&[(0, 1.0), (2, -1.0)], 0.0);
1113 let p = f.plan();
1114 assert_eq!(p.n_reduced_vars(), 1);
1115 assert_eq!(p.n_reduced_rows(), 0);
1116 assert_eq!(p.report.n_redundant_rows, 1);
1117 }
1118
1119 #[test]
1120 fn contradiction_abandons_the_whole_plan() {
1121 let f = Fixture::new(2)
1123 .eq(&[(0, 1.0), (1, -1.0)], 0.0)
1124 .eq(&[(0, 1.0), (1, -1.0)], 1.0);
1125 let p = f.plan();
1126 assert!(p.report.infeasible);
1127 assert!(
1128 p.is_identity(),
1129 "a contradictory model must be handed back whole"
1130 );
1131 assert_eq!(p.n_reduced_vars(), 2);
1132 assert_eq!(p.n_reduced_rows(), 2);
1133 }
1134
1135 #[test]
1136 fn a_singleton_outside_its_box_abandons_the_plan() {
1137 let f = Fixture::new(2).eq(&[(0, 1.0)], 5.0).bounds(0, 0.0, 1.0);
1138 let p = f.plan();
1139 assert!(p.report.infeasible);
1140 assert!(p.is_identity());
1141 }
1142
1143 #[test]
1144 fn a_float_noise_excursion_clamps_instead_of_abandoning() {
1145 let f = Fixture::new(2)
1148 .eq(&[(0, 1.0)], 0.1 + 0.2)
1149 .bounds(0, 0.0, 0.3);
1150 let p = f.plan();
1151 assert!(!p.report.infeasible);
1152 assert_eq!(p.recovery[0], VarRecovery::Constant(0.3));
1153 }
1154
1155 #[test]
1156 fn an_emptied_survivor_box_abandons_the_plan() {
1157 let f = Fixture::new(2)
1159 .eq(&[(0, 1.0), (1, -1.0)], 0.0)
1160 .bounds(0, 5.0, 6.0)
1161 .bounds(1, 1.0, 2.0);
1162 let p = f.plan();
1163 assert!(p.report.infeasible);
1164 assert!(p.is_identity());
1165 }
1166
1167 #[test]
1168 fn ineligible_rows_are_never_consumed() {
1169 let f = Fixture::new(2).opaque(&[(0, 1.0), (1, -1.0)], 0.0, 0.0);
1170 let p = f.plan();
1171 assert!(p.is_identity());
1172 assert_eq!(p.n_reduced_vars(), 2);
1173 }
1174
1175 #[test]
1176 fn an_inequality_row_is_never_consumed() {
1177 let mut f = Fixture::new(2);
1178 f.rows.push(vec![(0, 1.0), (1, -1.0)]);
1179 f.row_const.push(0.0);
1180 f.g_l.push(0.0);
1181 f.g_u.push(1.0);
1182 f.eligible.push(true);
1183 let p = f.plan();
1184 assert!(p.is_identity());
1185 }
1186
1187 #[test]
1188 fn a_one_sided_row_at_the_sentinel_is_not_an_equality() {
1189 let mut f = Fixture::new(2);
1192 f.rows.push(vec![(0, 1.0), (1, -1.0)]);
1193 f.row_const.push(0.0);
1194 f.g_l.push(-1e19);
1195 f.g_u.push(-1e19);
1196 f.eligible.push(true);
1197 let p = f.plan();
1198 assert!(p.is_identity());
1199 }
1200
1201 #[test]
1202 fn the_row_constant_is_honoured() {
1203 let mut f = Fixture::new(2);
1205 f.rows.push(vec![(0, 1.0), (1, -1.0)]);
1206 f.row_const.push(3.0);
1207 f.g_l.push(0.0);
1208 f.g_u.push(0.0);
1209 f.eligible.push(true);
1210 let p = f.plan();
1211 let mut x = vec![0.0; 2];
1212 p.lift_x(&[2.0], &mut x);
1213 assert!((x[0] - x[1] + 3.0).abs() < 1e-12, "{x:?}");
1214 }
1215
1216 #[test]
1217 fn three_term_rows_are_left_alone() {
1218 let f = Fixture::new(3).eq(&[(0, 1.0), (1, 1.0), (2, 1.0)], 1.0);
1219 let p = f.plan();
1220 assert!(p.is_identity());
1221 }
1222
1223 #[test]
1224 fn steps_are_recorded_in_application_order_with_live_pivots() {
1225 let f = Fixture::new(3)
1226 .eq(&[(0, 2.0), (1, -1.0)], 0.0)
1227 .eq(&[(1, 3.0), (2, -1.0)], 0.0);
1228 let p = f.plan();
1229 assert_eq!(p.steps.len(), 2);
1230 assert_eq!(p.steps[0].row, 0);
1231 assert!(p.steps[0].pivot != 0.0);
1232 assert_eq!(p.steps[1].row, 1);
1233 for s in &p.steps {
1235 assert!(!matches!(p.recovery[s.var], VarRecovery::Kept(_)));
1236 }
1237 }
1238
1239 #[test]
1240 fn parent_edges_point_at_later_or_never_eliminated_columns() {
1241 let f = Fixture::new(4)
1245 .eq(&[(0, 1.0), (1, -1.0)], 0.0)
1246 .eq(&[(1, 1.0), (2, -1.0)], 0.0)
1247 .eq(&[(2, 1.0), (3, -1.0)], 0.0);
1248 let p = f.plan();
1249 let mut step_of = [usize::MAX; 4];
1250 for (t, s) in p.steps.iter().enumerate() {
1251 step_of[s.var] = t;
1252 }
1253 for (i, edge) in p.parent.iter().enumerate() {
1254 if let Some((parent, _)) = *edge {
1255 let ti = step_of[i];
1256 let tp = step_of[parent];
1257 assert!(ti != usize::MAX);
1258 assert!(tp == usize::MAX || tp > ti, "{i} -> {parent}");
1259 }
1260 }
1261 }
1262
1263 #[test]
1264 fn identity_plan_round_trips() {
1265 let p = EliminationPlan::identity(3, 2, &[-1.0, -1.0, -1.0], &[1.0, 1.0, 1.0]);
1266 assert!(p.is_identity());
1267 let mut x = vec![0.0; 3];
1268 p.lift_x(&[1.0, 2.0, 3.0], &mut x);
1269 assert_eq!(x, vec![1.0, 2.0, 3.0]);
1270 let mut y = vec![0.0; 3];
1271 p.project_x(&x, &mut y);
1272 assert_eq!(y, vec![1.0, 2.0, 3.0]);
1273 }
1274
1275 #[test]
1276 fn a_long_alias_chain_stays_shallow() {
1277 let mut f = Fixture::new(400);
1281 for j in 0..399 {
1282 f = f.eq(&[(j, 1.0), (j + 1, -1.0)], 0.0);
1283 }
1284 let p = f.plan();
1285 assert_eq!(p.n_reduced_vars(), 1);
1286 let mut depth = 0usize;
1287 for i in 0..400 {
1288 let mut d = 0usize;
1289 let mut cur = i;
1290 while let Some((parent, _)) = p.parent[cur] {
1291 cur = parent;
1292 d += 1;
1293 }
1294 depth = depth.max(d);
1295 }
1296 assert!(
1297 depth <= 32,
1298 "elimination forest depth {depth} is not shallow"
1299 );
1300 let mut x = vec![0.0; 400];
1301 p.lift_x(&[7.0], &mut x);
1302 for v in &x {
1303 assert!((v - 7.0).abs() < 1e-12);
1304 }
1305 }
1306}