Skip to main content

pounce_presolve/
warm.rs

1//! Warm-start-aware presolve, retain the transformation, map warm points.
2//!
3//! Seeds live in original space but the solver sees the reduced space
4//! (tightened bounds, dropped rows, aux-clamped variables). This module
5//! bridges the two: [`PresolveMap`] snapshots the transformation,
6//! [`PresolveFingerprint`] detects when it went stale, and
7//! [`project_warm_point`] maps a seed into reduced space.
8//!
9//! Dropped-row duals are dropped and reported
10//! ([`WarmProjectionReport::dropped_dual_l1`]), not lost: redundant rows
11//! carry 0 at the optimum and aux-eliminated rows are re-derived from KKT
12//! stationarity at postsolve.
13//!
14//! The fingerprint does not see FBBT tapes (they arrive via
15//! `ExpressionProvider`) or nonlinear data read only by auxiliary Phase 0 —
16//! call [`PresolveTnlp::invalidate`](crate::PresolveTnlp::invalidate) after
17//! changing those.
18
19use std::cell::RefCell;
20use std::rc::Rc;
21
22use pounce_common::types::{Index, Number};
23use pounce_nlp::tnlp::{BoundsInfo, IndexStyle, Linearity, SparsityRequest, TNLP};
24
25use crate::linear_eq_plan::EliminationPlan;
26use crate::options::PresolveOptions;
27
28// FNV-1a over bit patterns: change detection, not a hash table.
29fn mix(h: u64, v: u64) -> u64 {
30    const PRIME: u64 = 0x0000_0100_0000_01B3;
31    (h ^ v).wrapping_mul(PRIME)
32}
33
34fn hash_usize(h: u64, v: usize) -> u64 {
35    mix(h, v as u64)
36}
37
38fn hash_f64(h: u64, v: Number) -> u64 {
39    // -0.0 and 0.0 hash alike; NaNs hash by payload.
40    let bits = if v == 0.0 { 0u64 } else { v.to_bits() };
41    mix(h, bits)
42}
43
44/// Clamp `v` into `[lo, hi]`, returning the clamped value, or `None`
45/// when there is nothing valid to do: `v` already inside, any of
46/// `v`/`lo`/`hi` NaN, or the box inverted (`lo > hi`).
47pub fn clamp_seed(v: Number, lo: Number, hi: Number) -> Option<Number> {
48    if v.is_nan() || lo.is_nan() || hi.is_nan() || lo > hi {
49        return None;
50    }
51    let c = v.clamp(lo, hi);
52    if c == v { None } else { Some(c) }
53}
54
55// Original-space seed (`x`/`z_l`/`z_u` length `n`, `lambda` length `m`).
56// `mu` overrides the barrier seed; `None` threads the previous `final_mu`.
57#[derive(Debug, Clone, Default)]
58pub struct WarmPoint {
59    /// Primal guess (length `n`).
60    pub x: Vec<Number>,
61    /// Constraint multipliers (length `m`, original row order).
62    pub lambda: Vec<Number>,
63    /// Lower-bound multipliers (length `n`).
64    pub z_l: Vec<Number>,
65    /// Upper-bound multipliers (length `n`).
66    pub z_u: Vec<Number>,
67    /// Barrier seed override. `None` threads the previous `final_mu`.
68    pub mu: Option<Number>,
69}
70
71impl WarmPoint {
72    /// True when every vector has the shape of an `(n, m)` problem.
73    pub fn is_shaped(&self, n: usize, m: usize) -> bool {
74        self.x.len() == n && self.z_l.len() == n && self.z_u.len() == n && self.lambda.len() == m
75    }
76}
77
78// Retained transformation snapshot (see `PresolveTnlp::transformation`).
79
80/// Everything needed to map an original-space warm point into reduced space
81/// without holding the wrapper borrowed.
82#[derive(Debug, Clone, Default)]
83pub struct PresolveMap {
84    /// Original variable count (unchanged by Phases 0–5).
85    pub n_inner: usize,
86    /// Original constraint count.
87    pub m_inner: usize,
88    /// Reduced constraint count (`rows_kept.len()`).
89    pub m_outer: usize,
90    /// `rows_kept[outer] = inner`: kept-row index map.
91    pub rows_kept: Vec<usize>,
92    /// Tightened variable lower bounds (reduced box).
93    pub x_l: Vec<Number>,
94    /// Tightened variable upper bounds (reduced box).
95    pub x_u: Vec<Number>,
96    /// Aux-fixed variable indices (union over the reduction stack).
97    pub fixed_vars: Vec<usize>,
98    /// Their solved values, aligned with `fixed_vars`.
99    pub fixed_values: Vec<Number>,
100}
101
102impl PresolveMap {
103    /// Identity map: presolve disabled or changed nothing observable.
104    pub fn identity(n: usize, m: usize, x_l: Vec<Number>, x_u: Vec<Number>) -> Self {
105        Self {
106            n_inner: n,
107            m_inner: m,
108            m_outer: m,
109            rows_kept: (0..m).collect(),
110            x_l,
111            x_u,
112            fixed_vars: Vec::new(),
113            fixed_values: Vec::new(),
114        }
115    }
116
117    /// True for [`Self::identity`]-shaped maps with no fixed variables.
118    pub fn is_identity(&self) -> bool {
119        self.fixed_vars.is_empty()
120            && self.m_inner == self.m_outer
121            && self.rows_kept.iter().enumerate().all(|(o, &i)| o == i)
122    }
123}
124
125// What projection did to a warm point.
126
127/// Counts for callers tuning a sweep or asserting the warm path engages.
128#[derive(Debug, Clone, Copy, Default, PartialEq)]
129pub struct WarmProjectionReport {
130    /// Reduced problem's missing rows (warm dual mass dropped).
131    pub n_dropped_rows: usize,
132    /// `Σ|λ|` over dropped rows.
133    pub dropped_dual_l1: Number,
134    /// Warm primals clamped into the tightened box.
135    pub x_clamped_count: usize,
136    /// Warm primals overridden with aux-fixed values.
137    pub x_fixed_overridden_count: usize,
138}
139
140// Reduced-space seed: what the solver consumes.
141#[derive(Debug, Clone)]
142pub struct ProjectedWarm {
143    /// Primal in reduced space (length `n_inner` from
144    /// [`project_warm_point`]; shorter after
145    /// [`project_warm_point_full`] gathers elimination survivors).
146    pub x: Vec<Number>,
147    /// Multipliers on kept rows (length `m_outer`).
148    pub lambda: Vec<Number>,
149    /// Lower-bound multipliers (length `n_inner`).
150    pub z_l: Vec<Number>,
151    /// Upper-bound multipliers (length `n_inner`).
152    pub z_u: Vec<Number>,
153    /// What the projection did.
154    pub report: WarmProjectionReport,
155}
156
157/// Map `warm` (original space) through `map` into reduced space.
158///
159/// Returns `None` on a shape mismatch — the caller falls back to cold.
160/// This is the presolve layer only; when a linear-eq elimination is
161/// stacked outside presolve, use [`project_warm_point_full`] so the
162/// report describes the whole chain the solver consumes.
163pub fn project_warm_point(map: &PresolveMap, warm: &WarmPoint) -> Option<ProjectedWarm> {
164    if !warm.is_shaped(map.n_inner, map.m_inner) {
165        return None;
166    }
167    let n = map.n_inner;
168    let mut x = warm.x.clone();
169    let mut report = WarmProjectionReport {
170        n_dropped_rows: map.m_inner.saturating_sub(map.m_outer),
171        ..WarmProjectionReport::default()
172    };
173    // Aux-fixed variables are clamped in the reduced problem; the warm
174    // primal there is stale, so override it (counting actual moves).
175    for (k, &i) in map.fixed_vars.iter().enumerate() {
176        if i < n && k < map.fixed_values.len() {
177            if x[i] != map.fixed_values[k] {
178                report.x_fixed_overridden_count += 1;
179            }
180            x[i] = map.fixed_values[k];
181        }
182    }
183    // Clamp into the tightened box.
184    for (i, v) in x.iter_mut().enumerate().take(n) {
185        if i < map.x_l.len() && i < map.x_u.len() {
186            if let Some(c) = clamp_seed(*v, map.x_l[i], map.x_u[i]) {
187                report.x_clamped_count += 1;
188                *v = c;
189            }
190        }
191    }
192    // Mask multipliers to kept rows; report dropped mass.
193    let mut lambda = vec![0.0; map.m_outer];
194    for (outer, &inner) in map.rows_kept.iter().enumerate() {
195        if inner < warm.lambda.len() && outer < lambda.len() {
196            lambda[outer] = warm.lambda[inner];
197        }
198    }
199    if map.m_outer < map.m_inner {
200        let mut kept = vec![false; map.m_inner];
201        for &i in &map.rows_kept {
202            if i < kept.len() {
203                kept[i] = true;
204            }
205        }
206        report.dropped_dual_l1 = warm
207            .lambda
208            .iter()
209            .enumerate()
210            .filter(|(i, _)| !kept[*i])
211            .map(|(_, &v)| v.abs())
212            .sum();
213    }
214    Some(ProjectedWarm {
215        x,
216        lambda,
217        z_l: warm.z_l.clone(),
218        z_u: warm.z_u.clone(),
219        report,
220    })
221}
222
223/// Map `warm` through the presolve layer and the linear-eq
224/// elimination stacked outside it, mirroring what the solver
225/// consumes.
226pub fn project_warm_point_full(
227    map: &PresolveMap,
228    elim: Option<&EliminationPlan>,
229    warm: &WarmPoint,
230) -> Option<ProjectedWarm> {
231    let mut proj = project_warm_point(map, warm)?;
232    let plan = match elim {
233        None => return Some(proj),
234        Some(p) => p,
235    };
236    if proj.x.len() != plan.n_full || proj.lambda.len() != plan.m_full {
237        return None;
238    }
239    let mut x = vec![0.0; plan.vars_kept.len()];
240    let mut z_l = vec![0.0; plan.vars_kept.len()];
241    let mut z_u = vec![0.0; plan.vars_kept.len()];
242    for (red, &full) in plan.vars_kept.iter().enumerate() {
243        if full < proj.x.len() && red < x.len() {
244            x[red] = proj.x[full];
245            z_l[red] = proj.z_l[full];
246            z_u[red] = proj.z_u[full];
247        }
248    }
249    let mut lambda = vec![0.0; plan.rows_kept.len()];
250    for (red, &full) in plan.rows_kept.iter().enumerate() {
251        if full < proj.lambda.len() && red < lambda.len() {
252            lambda[red] = proj.lambda[full];
253        }
254    }
255    let mut kept = vec![false; plan.m_full];
256    for &f in &plan.rows_kept {
257        if f < kept.len() {
258            kept[f] = true;
259        }
260    }
261    for (i, &is_kept) in kept.iter().enumerate() {
262        if !is_kept {
263            proj.report.n_dropped_rows += 1;
264            proj.report.dropped_dual_l1 += proj.lambda[i].abs();
265        }
266    }
267    proj.x = x;
268    proj.lambda = lambda;
269    proj.z_l = z_l;
270    proj.z_u = z_u;
271    Some(proj)
272}
273
274// What the transformation was computed from. The session rebuilds the
275// wrapper on any change; bounds and linear-row values are hashed because
276// tightening and redundancy read them.
277
278/// Recomputed from the live TNLP before each solve; a match reuses the
279/// retained wrapper.
280#[derive(Debug, Clone, PartialEq, Eq)]
281pub struct PresolveFingerprint {
282    /// Combined hash; compared as a whole.
283    hash: u64,
284}
285
286impl PresolveFingerprint {
287    /// The combined hash (for logging/diagnostics).
288    pub fn hash(&self) -> u64 {
289        self.hash
290    }
291}
292
293/// Compute the fingerprint of `inner` under `opts`.
294///
295/// `None` on any failed query means "always rebuild". A declined
296/// Hessian-structure call just records absence.
297pub fn compute_fingerprint(
298    inner: &Rc<RefCell<dyn TNLP>>,
299    opts: &PresolveOptions,
300) -> Option<PresolveFingerprint> {
301    const SEED: u64 = 0xcbf2_9ce4_8422_2325;
302    let mut h = SEED;
303
304    let info = inner.borrow_mut().get_nlp_info()?;
305    let n = info.n.max(0) as usize;
306    let m = info.m.max(0) as usize;
307    let nnz = info.nnz_jac_g.max(0) as usize;
308    let nnz_h = info.nnz_h_lag.max(0) as usize;
309    h = hash_usize(h, n);
310    h = hash_usize(h, m);
311    h = hash_usize(h, nnz);
312    h = hash_usize(h, nnz_h);
313    h = hash_usize(h, info.index_style as usize);
314
315    // Bounds: tightening/redundancy read them.
316    let mut x_l = vec![0.0; n];
317    let mut x_u = vec![0.0; n];
318    let mut g_l = vec![0.0; m];
319    let mut g_u = vec![0.0; m];
320    if !inner.borrow_mut().get_bounds_info(BoundsInfo {
321        x_l: &mut x_l,
322        x_u: &mut x_u,
323        g_l: &mut g_l,
324        g_u: &mut g_u,
325    }) {
326        return None;
327    }
328    for v in x_l
329        .iter()
330        .chain(x_u.iter())
331        .chain(g_l.iter())
332        .chain(g_u.iter())
333    {
334        h = hash_f64(h, *v);
335    }
336
337    // Linearity tags gate every phase's row eligibility.
338    let mut lin = vec![Linearity::NonLinear; m];
339    let have_lin = if m > 0 {
340        inner.borrow_mut().get_constraints_linearity(&mut lin)
341    } else {
342        true
343    };
344    if m > 0 {
345        if have_lin {
346            for t in &lin {
347                h = hash_usize(
348                    h,
349                    match t {
350                        Linearity::Linear => 1,
351                        Linearity::NonLinear => 2,
352                    },
353                );
354            }
355        } else {
356            h = hash_usize(h, 0);
357        }
358    }
359
360    if n > 0 {
361        let mut var_lin = vec![Linearity::NonLinear; n];
362        let have_var_lin = {
363            let mut inner = inner.borrow_mut();
364            inner.get_objective_variables_linearity(&mut var_lin)
365                || inner.get_variables_linearity(&mut var_lin)
366        };
367        h = hash_usize(h, have_var_lin as usize);
368        if have_var_lin {
369            for t in &var_lin {
370                h = hash_usize(
371                    h,
372                    match t {
373                        Linearity::Linear => 1,
374                        Linearity::NonLinear => 2,
375                    },
376                );
377            }
378        }
379    }
380
381    // Jacobian structure + values at the probe. Linear rows are constant,
382    // so any accepted probe gives the exact coefficients presolve uses.
383    let mut irow = vec![0 as Index; nnz];
384    let mut jcol = vec![0 as Index; nnz];
385    if nnz > 0 {
386        if !inner.borrow_mut().eval_jac_g(
387            None,
388            false,
389            SparsityRequest::Structure {
390                irow: &mut irow,
391                jcol: &mut jcol,
392            },
393        ) {
394            return None;
395        }
396        for v in irow.iter().chain(jcol.iter()) {
397            h = mix(h, *v as u64);
398        }
399    }
400    // Probe point for values.
401    let mut x_probe = vec![0.0; n];
402    let mut zl_probe = vec![0.0; n];
403    let mut zu_probe = vec![0.0; n];
404    let mut lam_probe = vec![0.0; m];
405    if !inner
406        .borrow_mut()
407        .get_starting_point(pounce_nlp::tnlp::StartingPoint {
408            init_x: true,
409            x: &mut x_probe,
410            init_z: false,
411            z_l: &mut zl_probe,
412            z_u: &mut zu_probe,
413            init_lambda: false,
414            lambda: &mut lam_probe,
415        })
416    {
417        return None;
418    }
419    if nnz > 0 {
420        let mut values = vec![0.0; nnz];
421        if !inner.borrow_mut().eval_jac_g(
422            Some(&x_probe),
423            true,
424            SparsityRequest::Values {
425                values: &mut values,
426            },
427        ) {
428            return None;
429        }
430        let base = match info.index_style {
431            IndexStyle::C => 0 as Index,
432            IndexStyle::Fortran => 1 as Index,
433        };
434        for (k, v) in values.iter().enumerate() {
435            // Row of entry `k`, converted out of the TNLP's index style.
436            let row = if k < irow.len() {
437                irow[k] - base
438            } else {
439                -1 as Index
440            };
441            let linear = have_lin
442                && row >= 0
443                && (row as usize) < m
444                && lin[row as usize] == Linearity::Linear;
445            if linear || !have_lin || row < 0 {
446                h = hash_f64(h, *v);
447            }
448        }
449    }
450
451    // Hessian structure presence: the elimination pass keys shape off it.
452    if nnz_h > 0 {
453        let mut hi = vec![0 as Index; nnz_h];
454        let mut hj = vec![0 as Index; nnz_h];
455        let ok = inner.borrow_mut().eval_h(
456            None,
457            false,
458            1.0,
459            None,
460            false,
461            SparsityRequest::Structure {
462                irow: &mut hi,
463                jcol: &mut hj,
464            },
465        );
466        h = hash_usize(h, if ok { 1000 + nnz_h } else { 999 });
467        if ok {
468            for v in hi.iter().chain(hj.iter()) {
469                h = mix(h, *v as u64);
470            }
471        }
472    }
473
474    // Options: any knob change rebuilds.
475    h = hash_presolve_options(h, opts);
476
477    Some(PresolveFingerprint { hash: h })
478}
479
480fn hash_presolve_options(mut h: u64, opts: &PresolveOptions) -> u64 {
481    h = hash_usize(h, opts.enabled as usize);
482    h = hash_f64(h, opts.certify_tol);
483    h = hash_usize(h, opts.bound_tightening as usize);
484    h = hash_usize(h, opts.redundant_constraint_removal as usize);
485    h = hash_usize(h, opts.linear_eq_reduction as usize);
486    h = hash_usize(h, opts.licq_check as usize);
487    h = hash_usize(h, opts.print_level as usize);
488    h = hash_usize(h, opts.max_passes as usize);
489    h = hash_usize(
490        h,
491        match opts.licq_action {
492            crate::options::LicqAction::Warn => 1,
493            crate::options::LicqAction::AutoL1 => 2,
494        },
495    );
496    h = hash_usize(h, opts.warm_z_bounds as usize);
497    h = hash_f64(h, opts.bound_mult_init_val);
498    h = hash_usize(h, opts.auxiliary as usize);
499    h = hash_f64(h, opts.auxiliary_tol);
500    h = hash_usize(h, opts.auxiliary_max_block_dim as usize);
501    h = hash_f64(h, opts.auxiliary_wall_time_fraction);
502    h = hash_usize(
503        h,
504        match opts.auxiliary_coupling {
505            crate::options::AuxiliaryCouplingPolicy::None => 1,
506            crate::options::AuxiliaryCouplingPolicy::Safe => 2,
507            crate::options::AuxiliaryCouplingPolicy::Aggressive => 3,
508        },
509    );
510    h = hash_usize(h, opts.auxiliary_diagnostics as usize);
511    h = hash_usize(h, opts.fbbt as usize);
512    h = hash_f64(h, opts.fbbt_tol);
513    h = hash_usize(h, opts.fbbt_max_iter as usize);
514    h = hash_usize(h, opts.fbbt_max_constraints as usize);
515    h
516}
517
518#[cfg(test)]
519mod tests {
520    use super::*;
521    use pounce_nlp::tnlp::{
522        BoundsInfo, IndexStyle, IpoptCq, IpoptData, NlpInfo, Solution, SparsityRequest,
523        StartingPoint,
524    };
525
526    /// min x^2 s.t. x >= 1 and (redundant) x <= 100.
527    struct Mini {
528        x_l: f64,
529        x_u: f64,
530    }
531
532    impl TNLP for Mini {
533        fn get_nlp_info(&mut self) -> Option<NlpInfo> {
534            Some(NlpInfo {
535                n: 1,
536                m: 2,
537                nnz_jac_g: 2,
538                nnz_h_lag: 1,
539                index_style: IndexStyle::C,
540            })
541        }
542        fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
543            b.x_l[0] = self.x_l;
544            b.x_u[0] = self.x_u;
545            b.g_l[0] = 1.0;
546            b.g_u[0] = 1e19;
547            b.g_l[1] = -1e19;
548            b.g_u[1] = 100.0;
549            true
550        }
551        fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
552            sp.x[0] = 0.0;
553            true
554        }
555        fn eval_f(&mut self, x: &[Number], _n: bool) -> Option<Number> {
556            Some(x[0] * x[0])
557        }
558        fn eval_grad_f(&mut self, x: &[Number], _n: bool, g: &mut [Number]) -> bool {
559            g[0] = 2.0 * x[0];
560            true
561        }
562        fn eval_g(&mut self, x: &[Number], _n: bool, g: &mut [Number]) -> bool {
563            g[0] = x[0];
564            g[1] = x[0];
565            true
566        }
567        fn eval_jac_g(
568            &mut self,
569            _x: Option<&[Number]>,
570            _n: bool,
571            mode: SparsityRequest<'_>,
572        ) -> bool {
573            match mode {
574                SparsityRequest::Structure { irow, jcol } => {
575                    irow.copy_from_slice(&[0, 1]);
576                    jcol.copy_from_slice(&[0, 0]);
577                }
578                SparsityRequest::Values { values } => {
579                    values.copy_from_slice(&[1.0, 1.0]);
580                }
581            }
582            true
583        }
584        fn get_constraints_linearity(&mut self, types: &mut [Linearity]) -> bool {
585            types.fill(Linearity::Linear);
586            true
587        }
588        fn finalize_solution(&mut self, _s: Solution<'_>, _d: &IpoptData, _q: &IpoptCq) {}
589    }
590
591    fn mini() -> Rc<RefCell<dyn TNLP>> {
592        Rc::new(RefCell::new(Mini {
593            x_l: -10.0,
594            x_u: 10.0,
595        })) as Rc<RefCell<dyn TNLP>>
596    }
597
598    #[test]
599    fn projection_masks_dropped_lambda_and_reports_mass() {
600        let map = PresolveMap {
601            n_inner: 1,
602            m_inner: 2,
603            m_outer: 1,
604            rows_kept: vec![0],
605            x_l: vec![1.0],
606            x_u: vec![10.0],
607            fixed_vars: vec![],
608            fixed_values: vec![],
609        };
610        let warm = WarmPoint {
611            x: vec![0.0], // below tightened box -> clamped
612            lambda: vec![2.0, 5.0],
613            z_l: vec![0.5],
614            z_u: vec![0.0],
615            mu: None,
616        };
617        let proj = project_warm_point(&map, &warm).expect("shaped");
618        assert_eq!(proj.x, vec![1.0]);
619        assert_eq!(proj.lambda, vec![2.0]);
620        assert_eq!(proj.report.n_dropped_rows, 1);
621        assert!((proj.report.dropped_dual_l1 - 5.0).abs() < 1e-12);
622        assert_eq!(proj.report.x_clamped_count, 1);
623        assert_eq!(proj.report.x_fixed_overridden_count, 0);
624    }
625
626    #[test]
627    fn projection_overrides_fixed_vars() {
628        let map = PresolveMap {
629            n_inner: 2,
630            m_inner: 1,
631            m_outer: 1,
632            rows_kept: vec![0],
633            x_l: vec![-10.0, 3.0],
634            x_u: vec![10.0, 3.0],
635            fixed_vars: vec![1],
636            fixed_values: vec![3.0],
637        };
638        let warm = WarmPoint {
639            x: vec![1.0, 99.0],
640            lambda: vec![1.0],
641            z_l: vec![0.0, 0.0],
642            z_u: vec![0.0, 0.0],
643            mu: None,
644        };
645        let proj = project_warm_point(&map, &warm).expect("shaped");
646        assert_eq!(proj.x, vec![1.0, 3.0]);
647        assert_eq!(proj.report.x_fixed_overridden_count, 1);
648    }
649
650    #[test]
651    fn projection_rejects_misshaped_warm() {
652        let map = PresolveMap::identity(2, 1, vec![-10.0, -10.0], vec![10.0, 10.0]);
653        let warm = WarmPoint {
654            x: vec![0.0], // wrong n
655            lambda: vec![0.0],
656            z_l: vec![0.0, 0.0],
657            z_u: vec![0.0, 0.0],
658            mu: None,
659        };
660        assert!(project_warm_point(&map, &warm).is_none());
661    }
662
663    #[test]
664    fn fingerprint_stable_then_moves_with_bounds() {
665        let inner = mini();
666        let opts = PresolveOptions::defaults();
667        let a = compute_fingerprint(&inner, &opts).expect("fingerprint");
668        let b = compute_fingerprint(&inner, &opts).expect("fingerprint");
669        assert_eq!(a, b);
670        let inner2: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(Mini {
671            x_l: -5.0,
672            x_u: 10.0,
673        }));
674        let c = compute_fingerprint(&inner2, &opts).expect("fingerprint");
675        assert_ne!(a, c);
676    }
677
678    #[test]
679    fn fingerprint_moves_with_options() {
680        let inner = mini();
681        let mut opts = PresolveOptions::defaults();
682        let a = compute_fingerprint(&inner, &opts).expect("fingerprint");
683        opts.redundant_constraint_removal = false;
684        let b = compute_fingerprint(&inner, &opts).expect("fingerprint");
685        assert_ne!(a, b);
686    }
687
688    /// One linear row (`lin_coef * x == 1`), one nonlinear row (`x^2 <= h`)
689    /// whose Jacobian value moves with the starting point.
690    struct NonlinMini {
691        x0: f64,
692        lin_coef: f64,
693    }
694
695    impl TNLP for NonlinMini {
696        fn get_nlp_info(&mut self) -> Option<NlpInfo> {
697            Some(NlpInfo {
698                n: 1,
699                m: 2,
700                nnz_jac_g: 2,
701                nnz_h_lag: 0,
702                index_style: IndexStyle::C,
703            })
704        }
705        fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
706            b.x_l[0] = -10.0;
707            b.x_u[0] = 10.0;
708            b.g_l[0] = 1.0;
709            b.g_u[0] = 1.0;
710            b.g_l[1] = -1e19;
711            b.g_u[1] = 100.0;
712            true
713        }
714        fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
715            sp.x[0] = self.x0;
716            true
717        }
718        fn eval_f(&mut self, x: &[Number], _n: bool) -> Option<Number> {
719            Some(x[0] * x[0])
720        }
721        fn eval_grad_f(&mut self, x: &[Number], _n: bool, g: &mut [Number]) -> bool {
722            g[0] = 2.0 * x[0];
723            true
724        }
725        fn eval_g(&mut self, x: &[Number], _n: bool, g: &mut [Number]) -> bool {
726            g[0] = self.lin_coef * x[0];
727            g[1] = x[0] * x[0];
728            true
729        }
730        fn eval_jac_g(
731            &mut self,
732            x: Option<&[Number]>,
733            _n: bool,
734            mode: SparsityRequest<'_>,
735        ) -> bool {
736            match mode {
737                SparsityRequest::Structure { irow, jcol } => {
738                    irow.copy_from_slice(&[0, 1]);
739                    jcol.copy_from_slice(&[0, 0]);
740                }
741                SparsityRequest::Values { values } => {
742                    let at = x.map(|v| v[0]).unwrap_or(self.x0);
743                    values.copy_from_slice(&[self.lin_coef, 2.0 * at]);
744                }
745            }
746            true
747        }
748        fn get_constraints_linearity(&mut self, types: &mut [Linearity]) -> bool {
749            types[0] = Linearity::Linear;
750            types[1] = Linearity::NonLinear;
751            true
752        }
753        fn finalize_solution(&mut self, _s: Solution<'_>, _d: &IpoptData, _q: &IpoptCq) {}
754    }
755
756    #[test]
757    fn fingerprint_ignores_nonlinear_operating_point() {
758        let opts = PresolveOptions::defaults();
759        let mk = |x0: f64, lin_coef: f64| -> Rc<RefCell<dyn TNLP>> {
760            Rc::new(RefCell::new(NonlinMini { x0, lin_coef })) as Rc<RefCell<dyn TNLP>>
761        };
762        let a = compute_fingerprint(&mk(0.0, 1.0), &opts).expect("fingerprint");
763        // Same linear data, new operating point (nonlinear Jacobian value
764        // 0.0 -> 10.0): must reuse, not rebuild.
765        let b = compute_fingerprint(&mk(5.0, 1.0), &opts).expect("fingerprint");
766        assert_eq!(a, b, "nonlinear operating point must not rebuild");
767        // Same operating point, moved linear coefficient: must rebuild.
768        let c = compute_fingerprint(&mk(0.0, 2.0), &opts).expect("fingerprint");
769        assert_ne!(a, c, "linear coefficient change must rebuild");
770    }
771
772    #[test]
773    fn full_projection_folds_elim_gather() {
774        use crate::linear_eq_plan::EliminationPlan;
775
776        let map = PresolveMap {
777            n_inner: 2,
778            m_inner: 2,
779            m_outer: 2,
780            rows_kept: vec![0, 1],
781            x_l: vec![-10.0, -10.0],
782            x_u: vec![10.0, 10.0],
783            fixed_vars: vec![],
784            fixed_values: vec![],
785        };
786        let warm = WarmPoint {
787            x: vec![1.0, 2.0],
788            lambda: vec![3.0, 4.0],
789            z_l: vec![0.5, 0.25],
790            z_u: vec![0.0, 0.0],
791            mu: None,
792        };
793        let mut plan = EliminationPlan::identity(2, 2, &[-10.0, -10.0], &[10.0, 10.0]);
794        plan.vars_kept = vec![1];
795        plan.rows_kept = vec![1];
796        let proj = project_warm_point_full(&map, Some(&plan), &warm).expect("shaped");
797        assert_eq!(proj.x, vec![2.0]);
798        assert_eq!(proj.lambda, vec![4.0]);
799        assert_eq!(proj.z_l, vec![0.25]);
800        assert_eq!(proj.report.n_dropped_rows, 1);
801        assert!((proj.report.dropped_dual_l1 - 3.0).abs() < 1e-12);
802        let single = project_warm_point(&map, &warm).expect("shaped");
803        let passthrough = project_warm_point_full(&map, None, &warm).expect("shaped");
804        assert_eq!(passthrough.x, single.x);
805        assert_eq!(passthrough.lambda, single.lambda);
806        assert_eq!(passthrough.report, single.report);
807    }
808
809    #[test]
810    fn full_projection_rejects_elim_shape_mismatch() {
811        use crate::linear_eq_plan::EliminationPlan;
812
813        let map = PresolveMap::identity(2, 1, vec![-10.0, -10.0], vec![10.0, 10.0]);
814        let warm = WarmPoint {
815            x: vec![0.0, 0.0],
816            lambda: vec![0.0],
817            z_l: vec![0.0, 0.0],
818            z_u: vec![0.0, 0.0],
819            mu: None,
820        };
821        let plan = EliminationPlan::identity(3, 1, &[-10.0, -10.0, -10.0], &[10.0, 10.0, 10.0]);
822        assert!(project_warm_point_full(&map, Some(&plan), &warm).is_none());
823    }
824}