1use 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
28fn 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 let bits = if v == 0.0 { 0u64 } else { v.to_bits() };
41 mix(h, bits)
42}
43
44pub 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#[derive(Debug, Clone, Default)]
58pub struct WarmPoint {
59 pub x: Vec<Number>,
61 pub lambda: Vec<Number>,
63 pub z_l: Vec<Number>,
65 pub z_u: Vec<Number>,
67 pub mu: Option<Number>,
69}
70
71impl WarmPoint {
72 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#[derive(Debug, Clone, Default)]
83pub struct PresolveMap {
84 pub n_inner: usize,
86 pub m_inner: usize,
88 pub m_outer: usize,
90 pub rows_kept: Vec<usize>,
92 pub x_l: Vec<Number>,
94 pub x_u: Vec<Number>,
96 pub fixed_vars: Vec<usize>,
98 pub fixed_values: Vec<Number>,
100}
101
102impl PresolveMap {
103 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 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#[derive(Debug, Clone, Copy, Default, PartialEq)]
129pub struct WarmProjectionReport {
130 pub n_dropped_rows: usize,
132 pub dropped_dual_l1: Number,
134 pub x_clamped_count: usize,
136 pub x_fixed_overridden_count: usize,
138}
139
140#[derive(Debug, Clone)]
142pub struct ProjectedWarm {
143 pub x: Vec<Number>,
147 pub lambda: Vec<Number>,
149 pub z_l: Vec<Number>,
151 pub z_u: Vec<Number>,
153 pub report: WarmProjectionReport,
155}
156
157pub 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 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 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 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
223pub 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#[derive(Debug, Clone, PartialEq, Eq)]
281pub struct PresolveFingerprint {
282 hash: u64,
284}
285
286impl PresolveFingerprint {
287 pub fn hash(&self) -> u64 {
289 self.hash
290 }
291}
292
293pub 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 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 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 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 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 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 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 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 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], 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], 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 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 let b = compute_fingerprint(&mk(5.0, 1.0), &opts).expect("fingerprint");
766 assert_eq!(a, b, "nonlinear operating point must not rebuild");
767 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}