Skip to main content

pounce_presolve/
options.rs

1//! Option-table integration for `pounce-presolve`.
2//!
3//! Mirrors the `presolve_*` option family naming used by CBC, GLPK,
4//! and HiGHS so cross-solver muscle memory transfers. All keys are
5//! registered into the standard `RegisteredOptions` registry; the
6//! `IpoptApplication` reads them via the usual `OptionsList` path.
7
8use pounce_common::exception::SolverException;
9use pounce_common::options_list::OptionsList;
10use pounce_common::reg_options::RegisteredOptions;
11use pounce_common::types::{Index, Number};
12
13/// Resolved presolve options, materialised from an `OptionsList`.
14///
15/// Keeping this as a plain `Copy` struct makes Phase 0's no-op path
16/// branch-free; later phases that grow more state can promote it to
17/// a `Clone` struct without API churn.
18#[derive(Debug, Clone, Copy)]
19pub struct PresolveOptions {
20    /// Master switch (`presolve`). When `false`, the wrapper is a
21    /// no-op and `wrap_with_presolve` returns the inner TNLP.
22    pub enabled: bool,
23    /// `presolve_bound_tightening` — Phase 1.
24    pub bound_tightening: bool,
25    /// `presolve_redundant_constraint_removal` — Phase 2.
26    pub redundant_constraint_removal: bool,
27    /// `presolve_linear_eq_reduction` — Phase ≥2, off by default.
28    pub linear_eq_reduction: bool,
29    /// `presolve_licq_check` — Phase 3.
30    pub licq_check: bool,
31    /// `presolve_print_level` — 0 silent, 5 per-pass, 8 per-xform.
32    pub print_level: Index,
33    /// `presolve_max_passes` — fixed-point iteration cap.
34    pub max_passes: Index,
35    /// `presolve_licq_action` — what to do on degenerate equality
36    /// rows. Phase 3 honours `auto_l1`; Phase 0 just stores it.
37    pub licq_action: LicqAction,
38    /// `presolve_warm_z_bounds` — Phase 4: hint bound-multiplier
39    /// warm starts for variables whose bounds were tightened.
40    pub warm_z_bounds: bool,
41    /// `presolve_bound_mult_init_val` — value used for those hints.
42    pub bound_mult_init_val: Number,
43    /// `presolve_auxiliary` — master switch for the Phase-0
44    /// auxiliary-equality preprocessing pass (issue #53). When
45    /// `false` (the default), Phase 0 is a no-op even if the outer
46    /// `enabled` master switch is on.
47    pub auxiliary: bool,
48    /// `presolve_auxiliary_tol` — residual tolerance for accepting a
49    /// candidate block solve (full-space KKT residual after the
50    /// reduction must stay within this).
51    pub auxiliary_tol: Number,
52    /// `presolve_auxiliary_max_block_dim` — the lightweight Newton
53    /// solver only handles blocks up to this dimension. PR 11 lifts
54    /// this by injecting an IPM-backed fallback.
55    pub auxiliary_max_block_dim: Index,
56    /// `presolve_auxiliary_wall_time_fraction` — fraction of the
57    /// solver's overall wall-time budget Phase 0 is allowed to spend.
58    pub auxiliary_wall_time_fraction: Number,
59    /// `presolve_auxiliary_coupling` — which coupling classes are
60    /// eligible for elimination.
61    pub auxiliary_coupling: AuxiliaryCouplingPolicy,
62    /// `presolve_auxiliary_diagnostics` — emit the diagnostics struct
63    /// via the journalist.
64    pub auxiliary_diagnostics: bool,
65    /// `presolve_fbbt` — Feasibility-Based Bound Tightening over
66    /// nonlinear constraint expression DAGs (issue #62). Off by
67    /// default until benchmark evidence justifies a flip.
68    pub fbbt: bool,
69    /// `fbbt_tol` — minimum bound improvement to keep iterating.
70    pub fbbt_tol: Number,
71    /// `fbbt_max_iter` — outer sweep cap.
72    pub fbbt_max_iter: Index,
73    /// `fbbt_max_constraints` — cap on constraints examined per
74    /// sweep; `0` means unlimited.
75    pub fbbt_max_constraints: Index,
76}
77
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub enum LicqAction {
80    /// Just report — solver proceeds as usual.
81    Warn,
82    /// Set `l1_exact_penalty_barrier=yes` so the ℓ₁ wrapper handles
83    /// the rank-deficient block. Interlocks with pounce#10.
84    AutoL1,
85}
86
87/// Coupling-class gate for the auxiliary preprocessing pass. Mirrors
88/// ripopt's defaults: `Safe` eliminates only pure-equality blocks;
89/// `Aggressive` adds objective-coupled blocks as post-solve
90/// candidates; `None` disables elimination entirely (Phase 0 still
91/// runs and collects diagnostics).
92#[derive(Debug, Clone, Copy, PartialEq, Eq)]
93pub enum AuxiliaryCouplingPolicy {
94    /// No elimination; useful for collecting diagnostics only.
95    None,
96    /// PureEquality blocks only.
97    Safe,
98    /// PureEquality + ObjectiveCoupled (post-solve only).
99    Aggressive,
100}
101
102impl PresolveOptions {
103    /// All-default settings, matching the registered defaults.
104    pub fn defaults() -> Self {
105        Self {
106            enabled: false,
107            bound_tightening: true,
108            redundant_constraint_removal: true,
109            linear_eq_reduction: false,
110            licq_check: true,
111            print_level: 0,
112            max_passes: 3,
113            licq_action: LicqAction::Warn,
114            warm_z_bounds: true,
115            bound_mult_init_val: 1.0,
116            auxiliary: false,
117            auxiliary_tol: 1e-8,
118            auxiliary_max_block_dim: 8,
119            auxiliary_wall_time_fraction: 0.1,
120            auxiliary_coupling: AuxiliaryCouplingPolicy::Safe,
121            auxiliary_diagnostics: false,
122            fbbt: false,
123            fbbt_tol: 1e-6,
124            fbbt_max_iter: 10,
125            fbbt_max_constraints: 0,
126        }
127    }
128
129    /// Read every `presolve_*` key out of an `OptionsList`, falling
130    /// back to registered defaults where unset.
131    pub fn from_options_list(opts: &OptionsList) -> Result<Self, SolverException> {
132        let enabled = opts.get_bool_value("presolve", "")?.0;
133        let bound_tightening = opts.get_bool_value("presolve_bound_tightening", "")?.0;
134        let redundant_constraint_removal = opts
135            .get_bool_value("presolve_redundant_constraint_removal", "")?
136            .0;
137        let linear_eq_reduction = opts.get_bool_value("presolve_linear_eq_reduction", "")?.0;
138        let licq_check = opts.get_bool_value("presolve_licq_check", "")?.0;
139        let print_level = opts.get_integer_value("presolve_print_level", "")?.0;
140        let max_passes = opts.get_integer_value("presolve_max_passes", "")?.0;
141        let licq_action = match opts
142            .get_string_value("presolve_licq_action", "")?
143            .0
144            .as_str()
145        {
146            "auto_l1" => LicqAction::AutoL1,
147            // "warn" or anything else (including the registered default).
148            _ => LicqAction::Warn,
149        };
150        let warm_z_bounds = opts.get_bool_value("presolve_warm_z_bounds", "")?.0;
151        let bound_mult_init_val = opts
152            .get_numeric_value("presolve_bound_mult_init_val", "")?
153            .0;
154        let auxiliary = opts.get_bool_value("presolve_auxiliary", "")?.0;
155        let auxiliary_tol = opts.get_numeric_value("presolve_auxiliary_tol", "")?.0;
156        let auxiliary_max_block_dim = opts
157            .get_integer_value("presolve_auxiliary_max_block_dim", "")?
158            .0;
159        let auxiliary_wall_time_fraction = opts
160            .get_numeric_value("presolve_auxiliary_wall_time_fraction", "")?
161            .0;
162        let auxiliary_coupling = match opts
163            .get_string_value("presolve_auxiliary_coupling", "")?
164            .0
165            .as_str()
166        {
167            "none" => AuxiliaryCouplingPolicy::None,
168            "aggressive" => AuxiliaryCouplingPolicy::Aggressive,
169            // "safe" or anything else (including the registered default).
170            _ => AuxiliaryCouplingPolicy::Safe,
171        };
172        let auxiliary_diagnostics = opts.get_bool_value("presolve_auxiliary_diagnostics", "")?.0;
173        let fbbt = opts.get_bool_value("presolve_fbbt", "")?.0;
174        let fbbt_tol = opts.get_numeric_value("fbbt_tol", "")?.0;
175        let fbbt_max_iter = opts.get_integer_value("fbbt_max_iter", "")?.0;
176        let fbbt_max_constraints = opts.get_integer_value("fbbt_max_constraints", "")?.0;
177        Ok(Self {
178            enabled,
179            bound_tightening,
180            redundant_constraint_removal,
181            linear_eq_reduction,
182            licq_check,
183            print_level,
184            max_passes,
185            licq_action,
186            warm_z_bounds,
187            bound_mult_init_val,
188            auxiliary,
189            auxiliary_tol,
190            auxiliary_max_block_dim,
191            auxiliary_wall_time_fraction,
192            auxiliary_coupling,
193            auxiliary_diagnostics,
194            fbbt,
195            fbbt_tol,
196            fbbt_max_iter,
197            fbbt_max_constraints,
198        })
199    }
200}
201
202/// Register every `presolve_*` key into `reg`. Called once from
203/// `IpoptApplication::new` (after the upstream-options block).
204pub fn register_options(reg: &RegisteredOptions) -> Result<(), SolverException> {
205    reg.set_registering_category("NLP Presolve");
206
207    reg.add_bool_option(
208        "presolve",
209        "Master switch for algorithmic NLP preprocessing.",
210        false,
211        "If yes, wraps the user TNLP with a presolve layer that may \
212         tighten variable bounds, drop redundant constraints, and \
213         detect rank-deficient equality blocks before the IPM starts. \
214         Off by default; the per-pass options below are then dormant.",
215    )?;
216
217    reg.add_bool_option(
218        "presolve_bound_tightening",
219        "Tighten variable bounds via constraint propagation.",
220        true,
221        "When presolve is enabled, iteratively propagates each linear \
222         constraint into implied bounds on its variables (Andersen's \
223         LP presolve §2 adapted to NLP).",
224    )?;
225
226    reg.add_bool_option(
227        "presolve_redundant_constraint_removal",
228        "Drop constraints implied by current variable bounds.",
229        true,
230        "For each linear constraint, checks whether [lo, hi] is \
231         implied by the box [x_l, x_u]; drops those that are.",
232    )?;
233
234    reg.add_bool_option(
235        "presolve_linear_eq_reduction",
236        "Eliminate variables via linear-equality rows.",
237        false,
238        "Reduces problem dimension by Gauss-eliminating against \
239         linear equality rows. Off by default because it changes \
240         the variable count and complicates sensitivity integration.",
241    )?;
242
243    reg.add_bool_option(
244        "presolve_licq_check",
245        "Detect rank-deficient equality blocks before the IPM starts.",
246        true,
247        "Probes rank(J_c) at the starting point via a sparse symbolic \
248         factor. Interlocks with `presolve_licq_action` to (optionally) \
249         auto-activate the ℓ₁-exact penalty-barrier wrapper.",
250    )?;
251
252    reg.add_string_option(
253        "presolve_licq_action",
254        "Action when presolve_licq_check reports rank deficiency.",
255        "warn",
256        &[
257            ("warn", "Report on the journalist; do not modify the solve."),
258            (
259                "auto_l1",
260                "Set l1_exact_penalty_barrier=yes so the ℓ₁ wrapper takes over.",
261            ),
262        ],
263        "Only consulted when presolve_licq_check=yes and the verdict \
264         is non-full-rank.",
265    )?;
266
267    reg.add_bounded_integer_option(
268        "presolve_print_level",
269        "Per-pass progress reporting for presolve.",
270        0,
271        8,
272        0,
273        "0 silent; 5 prints a one-line summary per pass; 8 prints \
274         per-transformation detail (intended for debugging).",
275    )?;
276
277    reg.add_bounded_integer_option(
278        "presolve_max_passes",
279        "Maximum fixed-point iterations across presolve passes.",
280        1,
281        50,
282        3,
283        "Bound tightening (Phase 1) is iterated until no bound \
284         changes or this cap is hit.",
285    )?;
286
287    reg.add_bool_option(
288        "presolve_warm_z_bounds",
289        "Warm-start bound multipliers for bounds tightened by presolve.",
290        true,
291        "When a variable's lower (upper) bound is moved by Phase 1 \
292         tightening, that side is likely active at the optimum. With \
293         this option on and `init_z=yes` requested, the wrapper sets \
294         z_l (z_u) for those variables to `presolve_bound_mult_init_val` \
295         instead of the global default.",
296    )?;
297
298    reg.add_lower_bounded_number_option(
299        "presolve_bound_mult_init_val",
300        "Value used when warm-starting bound multipliers from presolve.",
301        0.0,
302        true,
303        1.0,
304        "Only consulted when presolve_warm_z_bounds=yes.",
305    )?;
306
307    // --- Auxiliary-equality preprocessing (Phase 0, issue #53) ------------
308
309    reg.add_bool_option(
310        "presolve_auxiliary",
311        "Master switch for auxiliary-equality preprocessing (Phase 0).",
312        false,
313        "Structural NLP preprocessing: incidence graph → bipartite \
314         matching → Dulmage-Mendelsohn → block-triangular form → \
315         per-block solve → postsolve multiplier recovery. Defaults \
316         off; will land incrementally across pounce#53.",
317    )?;
318
319    reg.add_lower_bounded_number_option(
320        "presolve_auxiliary_tol",
321        "Residual tolerance for accepting an auxiliary block solve.",
322        0.0,
323        true,
324        1e-8,
325        "Full-space KKT residual after the candidate reduction must \
326         stay within this; otherwise the reduction is rejected.",
327    )?;
328
329    reg.add_bounded_integer_option(
330        "presolve_auxiliary_max_block_dim",
331        "Largest block the lightweight Newton solver will attempt.",
332        1,
333        1024,
334        8,
335        "Blocks above this dimension fall through to the BlockSolver \
336         trait (IPM-backed fallback lands in PR 11 of pounce#53).",
337    )?;
338
339    reg.add_bounded_number_option(
340        "presolve_auxiliary_wall_time_fraction",
341        "Fraction of overall wall-time budget Phase 0 may spend.",
342        0.0,
343        true,
344        1.0,
345        false,
346        0.1,
347        "When the solver enforces a wall-time limit, Phase 0 is given \
348         this fraction of the budget; honoured by PR 8 onwards.",
349    )?;
350
351    reg.add_string_option(
352        "presolve_auxiliary_coupling",
353        "Coupling-class gate for auxiliary block elimination.",
354        "safe",
355        &[
356            (
357                "none",
358                "Run Phase 0 for diagnostics only; eliminate nothing.",
359            ),
360            (
361                "safe",
362                "Eliminate only PureEquality blocks (matches ripopt default).",
363            ),
364            (
365                "aggressive",
366                "Also accept ObjectiveCoupled blocks as postsolve candidates.",
367            ),
368        ],
369        "Only consulted when presolve_auxiliary=yes.",
370    )?;
371
372    reg.add_bool_option(
373        "presolve_auxiliary_diagnostics",
374        "Emit the auxiliary-preprocessing diagnostics summary.",
375        false,
376        "When yes, the diagnostics struct (block counts, timings, \
377         rejection reasons) is published via the journalist after \
378         Phase 0 runs.",
379    )?;
380
381    reg.add_bool_option(
382        "presolve_fbbt",
383        "Feasibility-Based Bound Tightening on nonlinear constraints.",
384        false,
385        "When yes, runs interval-arithmetic propagation through each \
386         constraint's expression DAG to tighten variable bounds before \
387         the IPM starts (issue #62). Off by default — only TNLPs that \
388         implement the `ExpressionProvider` trait (currently: `.nl` \
389         files via pounce-cli's `NlTnlp`) participate; others are no-op.",
390    )?;
391
392    reg.add_lower_bounded_number_option(
393        "fbbt_tol",
394        "Minimum bound improvement to keep FBBT iterating.",
395        0.0,
396        true,
397        1.0e-6,
398        "Per Belotti et al. (2010), FBBT may not converge finitely \
399         even on linear constraints, so termination is tolerance-based: \
400         a sweep that produces no per-variable improvement above this \
401         threshold stops the outer loop.",
402    )?;
403
404    reg.add_bounded_integer_option(
405        "fbbt_max_iter",
406        "Outer-sweep cap for FBBT.",
407        1,
408        100,
409        10,
410        "Hard ceiling on the number of FBBT sweeps over all \
411         constraints. The outer loop terminates earlier as soon as a \
412         sweep produces no tightening above `fbbt_tol`.",
413    )?;
414
415    reg.add_lower_bounded_integer_option(
416        "fbbt_max_constraints",
417        "Per-sweep cap on the number of constraints FBBT inspects.",
418        0,
419        0,
420        "Useful as a wall-time guard on very large problems where the \
421         first few constraints carry most of the tightening. `0` means \
422         unlimited.",
423    )?;
424
425    reg.set_registering_category("");
426    Ok(())
427}
428
429#[cfg(test)]
430mod tests {
431    use super::*;
432    use std::rc::Rc;
433
434    fn reg_with_presolve() -> Rc<RegisteredOptions> {
435        let reg = RegisteredOptions::default();
436        register_options(&reg).unwrap();
437        Rc::new(reg)
438    }
439
440    #[test]
441    fn defaults_round_trip() {
442        let reg = reg_with_presolve();
443        let opts = OptionsList::with_registered(reg);
444        let p = PresolveOptions::from_options_list(&opts).unwrap();
445        assert!(!p.enabled);
446        assert!(p.bound_tightening);
447        assert!(p.redundant_constraint_removal);
448        assert!(!p.linear_eq_reduction);
449        assert!(p.licq_check);
450        assert_eq!(p.licq_action, LicqAction::Warn);
451        assert_eq!(p.print_level, 0);
452        assert_eq!(p.max_passes, 3);
453    }
454
455    #[test]
456    fn enabling_master_switch_round_trips() {
457        let reg = reg_with_presolve();
458        let mut opts = OptionsList::with_registered(reg);
459        opts.set_string_value("presolve", "yes", true, false)
460            .unwrap();
461        opts.set_string_value("presolve_licq_action", "auto_l1", true, false)
462            .unwrap();
463        opts.set_integer_value("presolve_max_passes", 5, true, false)
464            .unwrap();
465        let p = PresolveOptions::from_options_list(&opts).unwrap();
466        assert!(p.enabled);
467        assert_eq!(p.licq_action, LicqAction::AutoL1);
468        assert_eq!(p.max_passes, 5);
469    }
470
471    #[test]
472    fn invalid_licq_action_rejected_at_set_time() {
473        let reg = reg_with_presolve();
474        let mut opts = OptionsList::with_registered(reg);
475        // Registered enum option only accepts "warn" / "auto_l1".
476        let err = opts
477            .set_string_value("presolve_licq_action", "bogus", true, false)
478            .err();
479        assert!(err.is_some(), "invalid enum should be rejected at set time");
480    }
481
482    #[test]
483    fn auxiliary_defaults_round_trip() {
484        let reg = reg_with_presolve();
485        let opts = OptionsList::with_registered(reg);
486        let p = PresolveOptions::from_options_list(&opts).unwrap();
487        assert!(!p.auxiliary);
488        assert_eq!(p.auxiliary_tol, 1e-8);
489        assert_eq!(p.auxiliary_max_block_dim, 8);
490        assert_eq!(p.auxiliary_wall_time_fraction, 0.1);
491        assert_eq!(p.auxiliary_coupling, AuxiliaryCouplingPolicy::Safe);
492        assert!(!p.auxiliary_diagnostics);
493    }
494
495    #[test]
496    fn auxiliary_master_switch_round_trips() {
497        let reg = reg_with_presolve();
498        let mut opts = OptionsList::with_registered(reg);
499        opts.set_string_value("presolve_auxiliary", "yes", true, false)
500            .unwrap();
501        opts.set_string_value("presolve_auxiliary_coupling", "aggressive", true, false)
502            .unwrap();
503        opts.set_numeric_value("presolve_auxiliary_tol", 1e-10, true, false)
504            .unwrap();
505        opts.set_integer_value("presolve_auxiliary_max_block_dim", 16, true, false)
506            .unwrap();
507        opts.set_string_value("presolve_auxiliary_diagnostics", "yes", true, false)
508            .unwrap();
509        let p = PresolveOptions::from_options_list(&opts).unwrap();
510        assert!(p.auxiliary);
511        assert_eq!(p.auxiliary_coupling, AuxiliaryCouplingPolicy::Aggressive);
512        assert_eq!(p.auxiliary_tol, 1e-10);
513        assert_eq!(p.auxiliary_max_block_dim, 16);
514        assert!(p.auxiliary_diagnostics);
515    }
516
517    #[test]
518    fn invalid_auxiliary_coupling_rejected_at_set_time() {
519        let reg = reg_with_presolve();
520        let mut opts = OptionsList::with_registered(reg);
521        let err = opts
522            .set_string_value("presolve_auxiliary_coupling", "bogus", true, false)
523            .err();
524        assert!(err.is_some(), "invalid enum should be rejected at set time");
525    }
526}