Skip to main content

pounce_presolve/
linear_eq_plan.rs

1//! Phase 6, planning half — decide which variables a model's linear
2//! equality rows determine (issue #487).
3//!
4//! This is the "aggregation" pass option (b) of gh#487: one
5//! implementation in `pounce-presolve` so every frontend — CLI, GAMS, the
6//! C interface, Pyomo — gets the same reduction, rather than borrowing
7//! Pyomo's NL-v2 writer presolve for the Pyomo path alone.
8//!
9//! # What it recognises
10//!
11//! A work list over three shapes, iterated to a fixed point so chains
12//! propagate (matching what Pyomo's NL-v2 linear presolve does):
13//!
14//! 1. **Variables fixed by equal bounds** (`x_l == x_u`) become
15//!    constants, so rows mentioning them shed a term.
16//! 2. **Singleton linear equality rows** `a·x = b` fix their variable at
17//!    `x := b/a`, with a bounds check.
18//! 3. **Two-variable linear equality rows** `a₁·x + a₂·y = b` substitute
19//!    one variable for the other, `x := α·y + β`, with **no anchoring
20//!    requirement** — free/free pairs (arc equalities, `Reference`
21//!    aliases, unit-conversion links) aggregate away. This is the shape
22//!    [`crate::auxiliary`]'s determined-block pipeline cannot reach, and
23//!    the one that closes the gap gh#487 measured.
24//!
25//! Rows that collapse to `0 = 0` under the accumulated substitutions are
26//! structurally redundant and are dropped too (they carry `λ = 0`; see
27//! [`crate::linear_eq_elim`] for the dual story).
28//!
29//! # What it produces
30//!
31//! An [`EliminationPlan`]: an affine map `x_full = A·y + c` in which every
32//! eliminated variable is `α·y_rep + β` for a **single** surviving
33//! variable `rep` (or an outright constant). That one-nonzero-per-row
34//! shape is what keeps the derivative transforms in
35//! [`crate::linear_eq_elim`] to a scaled gather rather than a sparse
36//! matrix product.
37//!
38//! # Failing closed
39//!
40//! Any contradiction found on the way — a singleton value outside its
41//! variable's box, a bound transfer that empties the survivor's box, a row
42//! reduced to `0 = b` with `b ≠ 0` — abandons the **whole** plan and
43//! returns the identity. Presolve's own certification path (which knows
44//! how to withdraw a verdict a witness refutes) then sees the model
45//! untouched and decides for itself. An elimination pass is the wrong
46//! place to be the first and only voice calling a model infeasible.
47
48use pounce_common::types::{Number, lower_bound_present, upper_bound_present};
49
50/// How one full-space variable is recovered from the reduced solution.
51#[derive(Debug, Clone, Copy, PartialEq)]
52pub enum VarRecovery {
53    /// Survives, at the given index in the reduced variable vector.
54    Kept(usize),
55    /// Eliminated to a constant.
56    Constant(Number),
57    /// Eliminated to `coeff * x[rep] + offset`, where `rep` is a
58    /// **surviving** full-space variable index and `coeff` is non-zero.
59    Affine {
60        rep: usize,
61        coeff: Number,
62        offset: Number,
63    },
64}
65
66/// One accepted elimination, in application order.
67///
68/// The postsolve multiplier recovery in [`crate::linear_eq_elim`] walks
69/// these in reverse, which is what makes the dual system triangular; see
70/// that module's `recover_dropped_multipliers` for why.
71#[derive(Debug, Clone, Copy, PartialEq)]
72pub struct ElimStep {
73    /// Full-space row consumed by this step.
74    pub row: usize,
75    /// Full-space variable it determined.
76    pub var: usize,
77    /// The row's coefficient on `var` **in the partially substituted
78    /// problem at the moment of the step** — i.e. the pivot. Non-zero.
79    pub pivot: Number,
80}
81
82/// Tunables for [`build_plan`].
83#[derive(Debug, Clone, Copy)]
84pub struct PlanConfig {
85    /// `|g_u - g_l|` at or below this makes a row an equality.
86    pub eq_tol: Number,
87    /// An accumulated coefficient at or below `coeff_tol * row_scale` is
88    /// treated as structurally absent.
89    pub coeff_tol: Number,
90    /// How far a derived value may sit outside a declared bound before the
91    /// plan is abandoned as contradictory. Below this the value is clamped
92    /// into the box instead — the same "float noise is not an empty set"
93    /// reading `PresolveTnlp` applies to sub-margin crossings.
94    pub feas_tol: Number,
95    /// Cap on fixed-point sweeps over the candidate rows.
96    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/// Everything [`build_plan`] reads about the problem.
111#[derive(Debug, Clone, Copy)]
112pub struct PlanInput<'a> {
113    pub n_vars: usize,
114    pub n_rows: usize,
115    /// Per-row `(column, coefficient)` lists. Only rows flagged linear and
116    /// equality are consulted; the caller may leave the rest empty.
117    pub rows: &'a [Vec<(usize, Number)>],
118    /// Per-row constant term `c` in `g_r(x) = Σ a_j x_j + c`, so the row
119    /// reads `Σ a_j x_j = g_l[r] - c`.
120    pub row_const: &'a [Number],
121    pub g_l: &'a [Number],
122    pub g_u: &'a [Number],
123    /// `true` where the row is linear **and** eligible to be consumed.
124    pub eligible: &'a [bool],
125    pub x_l: &'a [Number],
126    pub x_u: &'a [Number],
127}
128
129/// A summary of what the pass achieved, for the `Presolve:` console line
130/// and for tests.
131#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
132pub struct LinearEqElimReport {
133    /// Variables pinned to a constant (by an equal-bounds pair, or by a
134    /// singleton row).
135    pub n_constant_vars: usize,
136    /// Variables folded onto another variable by a two-term row.
137    pub n_aggregated_vars: usize,
138    /// Rows consumed to determine a variable.
139    pub n_rows_eliminated: usize,
140    /// Rows that collapsed to `0 = 0` and were dropped as redundant.
141    pub n_redundant_rows: usize,
142    /// Fixed-point sweeps actually performed.
143    pub passes: usize,
144    /// The sweep hit [`PlanConfig::max_passes`] with work still to do.
145    pub pass_cap_hit: bool,
146    /// A contradiction was found; the returned plan is the identity.
147    pub infeasible: bool,
148}
149
150/// The affine reduction `x_full = A·y + c` plus the bookkeeping postsolve
151/// needs.
152#[derive(Debug, Clone, Default)]
153pub struct EliminationPlan {
154    pub n_full: usize,
155    pub m_full: usize,
156    /// One entry per full-space variable.
157    pub recovery: Vec<VarRecovery>,
158    /// Full-space index of each surviving variable, ascending.
159    pub vars_kept: Vec<usize>,
160    /// `true` where the full-space row survives into the reduced problem.
161    pub row_kept: Vec<bool>,
162    /// Full-space index of each surviving row, ascending.
163    pub rows_kept: Vec<usize>,
164    /// Reduced variable bounds, aligned with [`Self::vars_kept`]. Carries
165    /// every bound transferred off an eliminated variable.
166    pub x_l_red: Vec<Number>,
167    pub x_u_red: Vec<Number>,
168    /// Provenance of each reduced bound: the **full-space column whose own
169    /// declared bound** the reduced bound came from, aligned with
170    /// [`Self::x_l_red`] / [`Self::x_u_red`]. Equal to `vars_kept[i]` when
171    /// the survivor's own bound won, and some eliminated column of the same
172    /// cluster when a transferred bound did.
173    ///
174    /// Postsolve reads this to hand a reduced bound's multiplier back to the
175    /// column that actually owns the bound; see
176    /// [`crate::linear_eq_elim`]'s `attribute_bound_multiplier` (issue
177    /// #493). Which *side* of the origin column's box a reduced bound
178    /// corresponds to is not recorded separately: it is the origin's own
179    /// side when the composed coefficient `α` of `x_src = α·x_kept + β` is
180    /// positive and the opposite side when it is negative, which is exactly
181    /// the flip [`transfer_bounds`] performs on the way in.
182    pub x_l_src: Vec<usize>,
183    pub x_u_src: Vec<usize>,
184    /// Accepted eliminations in application order.
185    pub steps: Vec<ElimStep>,
186    /// Elimination forest: `parent[i] = Some((p, α))` when `i` was folded
187    /// onto `p` with `x_i = α·x_p + β` **at that moment** (`p` may itself
188    /// be eliminated later). `None` for survivors and for variables pinned
189    /// to a constant.
190    pub parent: Vec<Option<(usize, Number)>>,
191    pub report: LinearEqElimReport,
192}
193
194impl EliminationPlan {
195    /// The do-nothing plan: every variable and row survives.
196    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    /// True when the plan removes nothing, so every wrapper method can take
215    /// its forwarding fast path.
216    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    /// Splice a reduced primal back into full space.
229    pub fn lift_x(&self, x_red: &[Number], out: &mut [Number]) {
230        debug_assert_eq!(out.len(), self.n_full);
231        // Survivors first, so the `Affine` arm below can read its
232        // representative's value straight out of `out` regardless of index
233        // order (`rep` is always a survivor, never another eliminated var).
234        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    /// Project a full-space primal down, by reading the survivors.
247    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
254/// Bounds held with `±f64::INFINITY` for "absent", so arithmetic on them
255/// behaves; converted back to the caller's sentinels on the way out.
256struct Box2 {
257    lo: Vec<Number>,
258    hi: Vec<Number>,
259    /// Which full-space column's own declared bound each entry came from.
260    /// Starts as the identity and follows the bound through every transfer,
261    /// so a survivor's final box knows where each of its two sides was born
262    /// (gh#493).
263    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
296/// Union-find over variables, carrying the affine map to the current root.
297struct Substitutions {
298    /// `rep[i]` is `i` for a root, else a (possibly stale) ancestor.
299    rep: Vec<usize>,
300    /// `x_i = to_root[i].0 * x_{rep[i]} + to_root[i].1`.
301    to_root: Vec<(Number, Number)>,
302    /// Members in each root's cluster, for the union-by-size tie-break.
303    cluster_size: Vec<usize>,
304    /// Set once a root is pinned to a value.
305    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    /// Resolve `i` to its current root, path-compressing on the way back
319    /// down. Returns `(root, a, b)` with `x_i = a·x_root + b`.
320    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
347/// Build the reduction. Never panics on a contradictory model: see the
348/// module docs on failing closed.
349pub 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    // Shape 1: variables the declared box already pins. Folding them in
366    // here (rather than leaving them to the algorithm's fixed-variable
367    // classification) is what lets a three-term row with one fixed
368    // variable become a two-term row the aggregation can consume.
369    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    // Shapes 2 and 3, swept to a fixed point.
381    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            // Re-express the row over the *current* representatives.
412            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                    // `0 = rhs`. Zero (to tolerance) means the row carries no
446                    // information left and can go; anything else is a
447                    // contradiction, and the whole plan stands down.
448                    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                    // Pivot on the larger coefficient so the substitution
489                    // multiplier |a_other / a_pivot| never exceeds 1. When the
490                    // two are comparable — the `x - y = 0` alias case, which is
491                    // most of them — break the tie by cluster size so the
492                    // elimination forest stays shallow; postsolve walks its
493                    // ancestor chains once per dropped-row nonzero.
494                    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                    // Transfer the eliminated variable's box onto the survivor.
509                    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
561/// Accept `value` as the pinned value of a variable whose box is
562/// `[lo, hi]`, or refuse when it sits outside by more than `tol`.
563///
564/// A sub-tolerance excursion is clamped rather than refused, for the same
565/// reason `PresolveTnlp` collapses a sub-margin crossed box to a point:
566/// binary float noise around a bound is not an empty feasible set, and
567/// treating it as one flips a model POUNCE solves cleanly into a
568/// contradiction verdict.
569fn 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
590/// Push `elim`'s box through `x_elim = α·x_keep + β` onto `keep`'s box.
591/// Returns `false` when the intersection is empty beyond `tol`.
592fn 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    // x_elim ∈ [lo_e, hi_e]  ⟺  x_keep ∈ [(lo_e-β)/α, (hi_e-β)/α] (α>0)
602    //                            x_keep ∈ [(hi_e-β)/α, (lo_e-β)/α] (α<0)
603    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    // The same flip the interval carries: with α < 0 it is `elim`'s *upper*
613    // bound that becomes the survivor's lower bound. Provenance rides along
614    // (gh#493), so a bound that has hopped several times still names the
615    // column that declared it.
616    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    // Strict comparisons, so a tie leaves the incumbent — including the
622    // survivor's own declared bound — in place.
623    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        // Float-noise crossing: collapse to a point rather than call the
638        // model empty.
639        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    // Roots that were never pinned survive.
665    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        // Every column gone. The reduced problem has no degrees of freedom
677        // left, which the IPM has no useful shape for; hand the model back
678        // untouched rather than invent one.
679        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    // Reduced box: keep the caller's own sentinel where nothing was
709    // derived, so an "absent" bound stays spelled the way it arrived.
710    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        /// `Σ a_j x_j = b`, linear and eligible.
777        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        /// A row the pass must not consume (nonlinear, or an inequality).
786        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    /// Round-trip: lifting the reduced point must reproduce a full-space
818    /// point that satisfies every consumed row.
819    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        // The shape the determined-block pipeline cannot reach: both
851        // columns free and interior, no bound pinning either one.
852        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        // Written back-to-front, so a single forward sweep cannot see the
866        // pin until it has already walked past the rows that need it. Only
867        // iterating to a fixed point collapses all four columns.
868        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        // x0..x3 all pin to 4; x4 is untouched and is the only survivor.
875        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        // Every column determined would leave the IPM a zero-variable
888        // problem. Hand the square system back whole instead.
889        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        // x0 = x1, x1 = x2, x2 = x3 with nothing pinning them: four
901        // columns become one.
902        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        // Path compression must leave no `Affine` pointing at a column that
920        // was itself eliminated — the wrapper's `lift_x` reads `out[rep]`
921        // directly and would otherwise read an unwritten slot.
922        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        // x2 is pinned by its own box, so the three-term row becomes a
942        // two-term one and x0 folds onto x1.
943        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        // x0 = 2·x1 with x0 ∈ [4, 10] pins x1 into [2, 5].
957        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        // x0 = -x1 with x0 ∈ [1, 3] pins x1 into [-3, -1].
969        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    /// A transferred bound records the column that declared it, so postsolve
979    /// can hand that column's multiplier back (gh#493).
980    #[test]
981    fn a_transferred_bound_names_the_column_it_came_from() {
982        // x0 = 2·x1 with x0 ∈ [-inf, 1] pins x1 ≤ 0.5; x1's own lower bound
983        // survives untouched.
984        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    /// The provenance carries the same flip the interval does: with α < 0 the
996    /// survivor's *lower* bound is the eliminated column's *upper* one.
997    #[test]
998    fn a_negative_coefficient_flips_which_side_the_provenance_lands_on() {
999        // x0 = -2·x1 with x0 ∈ [-inf, 1] pins x1 ≥ -0.5.
1000        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    /// Provenance follows a chain: a bound that hops twice still names the
1011    /// column that declared it, and each hop composes the sign flip.
1012    #[test]
1013    fn provenance_survives_a_chain_of_transfers() {
1014        // x0 = -x1 (row 0), then x1 = -0.1·x2 (row 1 — the lopsided
1015        // coefficients make x2 the pivot, so the transfer chains rather than
1016        // fanning in). x0 ≤ 1 becomes x1 ≥ -1 becomes x2 ≤ 10, still owned by
1017        // x0's *upper* bound.
1018        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        // x0 = (-1)·(-0.1)·x2, so the composed α is positive: two flips put
1028        // the side back where it started, and 0.1·10 is x0's own bound.
1029        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    /// A tie leaves the incumbent alone, which is what keeps the degenerate
1040    /// both-bounds-active case attributed to the survivor.
1041    #[test]
1042    fn a_tied_transfer_leaves_the_provenance_on_the_survivor() {
1043        // x0 = 2·x1, x0 ≤ 1 and x1 ≤ 0.5 are the same constraint.
1044        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    /// Every reduced bound must be the origin's own bound pulled back through
1055    /// the recovery map — the identity postsolve's rescaling relies on.
1056    #[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                // Which side of the origin's box: its own when α > 0, the
1083                // other one when α < 0.
1084                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        // x0 = x1 and x1 = x2 make x0 = x2 vacuous.
1109        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        // x0 = x1 and x0 - x1 = 1 cannot both hold.
1122        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        // x0 = 0.1 + 0.2 with x0 ≤ 0.3: infeasible by 5.5e-17, which is
1146        // binary float noise, not an empty set.
1147        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        // x0 = x1 with disjoint boxes.
1158        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        // `g_l = g_u = 1e19` is a one-sided row spelled with the absent
1190        // sentinel on both ends, not an equality at 1e19 (#396 family).
1191        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        // g(x) = x0 - x1 + 3, constrained to 0 ⇒ x0 - x1 = -3.
1204        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        // Each step's variable is distinct and never a survivor.
1234        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        // The postsolve recovery relies on this: a node's parent is a root
1242        // at the moment of the merge, so it is eliminated strictly later
1243        // (or not at all). That is what makes the reverse sweep triangular.
1244        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        // 400 aliases with equal coefficients: the union-by-size tie-break
1278        // must keep the elimination forest logarithmic, because postsolve
1279        // walks ancestor chains per dropped-row nonzero.
1280        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}