Skip to main content

pounce_algorithm/
unimplemented_options.rs

1//! Options registered for `ipopt.opt` compatibility whose *feature*
2//! pounce does not implement (gh#483 follow-up, continuing #191).
3//!
4//! # Why these are refused rather than ignored
5//!
6//! `upstream_options.rs` is a faithful port of Ipopt's option registry:
7//! every name Ipopt registers is registered here, so an `ipopt.opt`
8//! written for Ipopt parses unchanged. That is a real compatibility
9//! benefit — and it silently turned ~200 knobs into no-ops, because
10//! registering an option says nothing about implementing it. Setting one
11//! did exactly nothing and said exactly nothing.
12//!
13//! Issue #191 audited this class and fixed the half where the *feature*
14//! runs and only the option's read site was missing. It explicitly
15//! scoped out "feature genuinely unimplemented — expected no-ops". This
16//! module closes that half: an option naming a feature pounce does not
17//! have is now an error, not a shrug.
18//!
19//! # What is and is not in the table
20//!
21//! Membership was established per option, not guessed:
22//!
23//! 1. the option's name appears in **no** crate source outside the
24//!    registry (whole-word — `penalty_max` does not count as present
25//!    because `l1_penalty_max` exists), **and**
26//! 2. the *feature* it configures is absent too.
27//!
28//! Both are needed. An option whose name is unread but whose feature
29//! runs — the `limited_memory_*` tail, the corrector knobs — is a
30//! missing read site, not a missing feature; refusing those would fail
31//! solves whose current answers are already correct. They are
32//! deliberately **not** here; wiring them is the other half of the work.
33//!
34//! A third shape turned up while wiring that half and belongs to
35//! neither: an option for a *sub-capability* of a feature that does run.
36//! `max_resto_iter` and `resto_failure_feasibility_threshold` are the
37//! examples — restoration runs, but there is no iteration cap or failure
38//! threshold to point a read site at, so honouring them means building
39//! the capability, not adding a line. They are left out of this table
40//! until that call is made, and so remain silent for now.
41//!
42//! The clearest case is the penalty line search. pounce implements
43//! `IpPenaltyLSAcceptor` (`line_search_method=penalty`), so its knobs
44//! (`nu_init`, `nu_inc`, `rho`, `eta_penalty`) are read sites to add.
45//! Ipopt's *other* penalty acceptor — the CG-penalty / inexact-Newton
46//! one — has no counterpart here at all, and the port registered its
47//! whole option set. Those are refused.
48//!
49//! An entry leaves the table by being implemented. `option_file_name`
50//! was here — refusing it was the cheap half of gh#518's "implement it
51//! or fail loudly" — until gh#518 got the other half:
52//! [`crate::application::IpoptApplication::initialize_with_option_file`]
53//! reads the named file, so the option now configures something.
54//!
55//! # The default gate
56//!
57//! Only an explicit value **different from the registered default** is
58//! refused. `expect_infeasible_problem_ctol` left alone, or an
59//! `ipopt.opt` that spells out defaults, must keep working: those ask
60//! for nothing. Refusing them would break the very compatibility the
61//! registry exists to provide.
62
63use pounce_common::options_list::OptionsList;
64use pounce_common::reg_options::{DefaultValue, RegisteredOptions};
65
66/// One unimplemented feature and the options that configure it.
67pub struct UnimplementedFeature {
68    /// Named in the error, e.g. "the CG-penalty / inexact-Newton line search".
69    pub feature: &'static str,
70    /// What the caller can do instead. Empty when there is nothing.
71    pub advice: &'static str,
72    /// The options that belong to it.
73    pub options: &'static [&'static str],
74}
75
76/// Feature groups pounce does not implement. Refused when set.
77pub const UNIMPLEMENTED_FEATURES: &[UnimplementedFeature] = &[
78    UnimplementedFeature {
79        feature: "the Chen-Goldfarb (CG-penalty) / inexact-Newton line search \
80                  — Ipopt's `CGPenaltyLSAcceptor`",
81        advice: "pounce implements the filter line search (the default) and \
82                 `line_search_method=penalty` (`IpPenaltyLSAcceptor`); tune \
83                 those instead",
84        options: &[
85            "chi_cup",
86            "chi_hat",
87            "chi_tilde",
88            "delta_y_max",
89            "epsilon_c",
90            "eta_min",
91            "fast_des_fact",
92            "gamma_hat",
93            "gamma_tilde",
94            "kappa_x_dis",
95            "kappa_y_dis",
96            "min_alpha_primal",
97            "mult_diverg_feasibility_tol",
98            "mult_diverg_y_tol",
99            "never_use_fact_cgpen_direction",
100            "never_use_piecewise_penalty_ls",
101            "pen_des_fact",
102            "pen_init_fac",
103            "pen_theta_max_fact",
104            "penalty_init_max",
105            "penalty_init_min",
106            "penalty_max",
107            "penalty_update_compl_tol",
108            "penalty_update_infeasibility_tol",
109            "piecewisepenalty_gamma_infeasi",
110            "piecewisepenalty_gamma_obj",
111            "vartheta",
112            "inexact_algorithm",
113        ],
114    },
115    UnimplementedFeature {
116        feature: "derivative approximation by finite differences",
117        advice: "supply `eval_grad_f` / `eval_jac_g` / `eval_h`, and check them \
118                 with `derivative_test=first-order`",
119        options: &[
120            "gradient_approximation",
121            "jacobian_approximation",
122            "findiff_perturbation",
123        ],
124    },
125    UnimplementedFeature {
126        feature: "linear-dependency detection on the equality constraints",
127        advice: "pounce's presolve removes structurally redundant rows; see \
128                 `presolve`",
129        options: &[
130            "dependency_detector",
131            "dependency_detection_with_rhs",
132            "ma28_pivtol",
133        ],
134    },
135    UnimplementedFeature {
136        feature: "the per-iteration NaN/Inf check on derivative matrices",
137        advice: "`derivative_test=first-order` checks the derivatives once, at \
138                 the starting point",
139        options: &["check_derivatives_for_naninf"],
140    },
141    UnimplementedFeature {
142        feature: "multiplier recalculation by least squares",
143        advice: "",
144        options: &["recalc_y", "recalc_y_feas_tol"],
145    },
146    UnimplementedFeature {
147        feature: "a selectable constraint-violation norm",
148        advice: "pounce measures the violation in the 2-norm throughout",
149        options: &["constraint_violation_norm_type"],
150    },
151    UnimplementedFeature {
152        feature: "magic steps",
153        advice: "",
154        options: &["magic_steps"],
155    },
156    UnimplementedFeature {
157        feature: "bound replacement on the original problem",
158        advice: "",
159        options: &["replace_bounds"],
160    },
161    UnimplementedFeature {
162        feature: "the L-BFGS augmented-system and space variants",
163        advice: "`hessian_approximation=limited-memory` uses the low-rank \
164                 augmented system unconditionally",
165        options: &["hessian_approximation_space", "limited_memory_aug_solver"],
166    },
167    UnimplementedFeature {
168        feature: "the linear-variable count hint for L-BFGS",
169        advice: "",
170        options: &["num_linear_variables"],
171    },
172    UnimplementedFeature {
173        feature: "skipping the finalize-solution callback",
174        advice: "",
175        options: &["skip_finalize_solution_call"],
176    },
177    UnimplementedFeature {
178        feature: "the dynamic HSL loader",
179        advice: "MA57 is linked at build time with `--features ma57`",
180        options: &["hsllib"],
181    },
182    UnimplementedFeature {
183        feature: "these output controls",
184        advice: "use `print_level` (0 silences the solver) and `sb=yes` to \
185                 suppress the banner",
186        options: &["suppress_all_output", "debug_print_level"],
187    },
188    UnimplementedFeature {
189        feature: "a randomly perturbed evaluation point for the derivative \
190                  checker",
191        advice: "pounce's checker tests at the (bound-projected) starting point, \
192                 which is where the solve actually begins",
193        options: &["point_perturbation_radius"],
194    },
195];
196
197/// Options that *are* honored in the sense that matters — the answer is
198/// unaffected — but whose performance hint pounce does not exploit.
199/// These warn rather than fail: refusing them would stop a solve that
200/// returns the right result today, only a little slower.
201pub const UNEXPLOITED_HINTS: &[&str] = &[
202    "grad_f_constant",
203    "hessian_constant",
204    "jac_c_constant",
205    "jac_d_constant",
206];
207
208/// An option set to something the registry says is not its default.
209///
210/// Both halves matter. `found` alone would fire on an `ipopt.opt` that
211/// spells out a default; comparing values alone would fire on nothing,
212/// since an unset option *reads back* as its default.
213pub(crate) fn set_to_a_non_default(
214    options: &OptionsList,
215    reg: &RegisteredOptions,
216    name: &str,
217) -> bool {
218    let Some(opt) = reg.get_option(name) else {
219        return false;
220    };
221    match &opt.default {
222        // Bools are registered as `yes`/`no` string options, so this arm
223        // covers them too.
224        DefaultValue::String(d) => {
225            matches!(options.get_string_value(name, ""), Ok((v, true)) if !v.eq_ignore_ascii_case(d))
226        }
227        DefaultValue::Number(d) => {
228            matches!(options.get_numeric_value(name, ""), Ok((v, true)) if v != *d)
229        }
230        DefaultValue::Integer(d) => {
231            matches!(options.get_integer_value(name, ""), Ok((v, true)) if v != *d)
232        }
233        DefaultValue::None => false,
234    }
235}
236
237/// The first unimplemented-feature option the caller set, with the
238/// message it earns. `None` when nothing in the table was touched.
239pub fn refusal(options: &OptionsList, reg: &RegisteredOptions) -> Option<String> {
240    for group in UNIMPLEMENTED_FEATURES {
241        for name in group.options {
242            if set_to_a_non_default(options, reg, name) {
243                let advice = if group.advice.is_empty() {
244                    String::new()
245                } else {
246                    format!(" Instead: {}.", group.advice)
247                };
248                return Some(format!(
249                    "pounce: `{name}` configures {}, which pounce does not \
250                     implement. It is registered so an ipopt.opt written for \
251                     Ipopt still parses, but setting it used to do nothing at \
252                     all — silently — so it is refused instead.{advice} \
253                     Remove it to run. Tracking issue: \
254                     https://github.com/jkitchin/pounce/issues/483",
255                    group.feature
256                ));
257            }
258        }
259    }
260    None
261}
262
263/// Warnings for hints pounce does not exploit. Never blocks a solve.
264pub fn hint_warnings(options: &OptionsList, reg: &RegisteredOptions) -> Vec<String> {
265    UNEXPLOITED_HINTS
266        .iter()
267        .filter(|name| set_to_a_non_default(options, reg, name))
268        .map(|name| {
269            format!(
270                "pounce: warning: `{name}` is a caching hint pounce does not \
271                 exploit — it re-evaluates each iteration regardless. Your \
272                 answer is unaffected; only the evaluation count is. \
273                 (gh#483)"
274            )
275        })
276        .collect()
277}
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282    use std::collections::BTreeSet;
283
284    fn registry() -> std::rc::Rc<RegisteredOptions> {
285        let r = RegisteredOptions::new();
286        crate::upstream_options::register_all_upstream_options(&r).expect("register");
287        r
288    }
289
290    /// A fresh options list over the shared registry, plus a handle on
291    /// the registry itself for the default lookups.
292    fn fixture() -> (OptionsList, std::rc::Rc<RegisteredOptions>) {
293        let reg = registry();
294        (OptionsList::with_registered(std::rc::Rc::clone(&reg)), reg)
295    }
296
297    /// Every name in the table must actually be registered — a typo
298    /// would make its entry dead code that silently never fires, which
299    /// is the exact failure mode this module exists to remove.
300    #[test]
301    fn every_listed_option_is_registered() {
302        let (_, reg) = fixture();
303        for group in UNIMPLEMENTED_FEATURES {
304            for name in group.options {
305                assert!(
306                    reg.get_option(name).is_some(),
307                    "`{name}` is in the refusal table but is not registered",
308                );
309            }
310        }
311        for name in UNEXPLOITED_HINTS {
312            assert!(
313                reg.get_option(name).is_some(),
314                "`{name}` is in the hint table but is not registered",
315            );
316        }
317    }
318
319    /// No option may appear twice — once in two feature groups, or in
320    /// both tables — or the message a user gets would depend on table
321    /// order.
322    #[test]
323    fn the_tables_do_not_overlap() {
324        let mut seen = BTreeSet::new();
325        for name in UNIMPLEMENTED_FEATURES
326            .iter()
327            .flat_map(|g| g.options.iter())
328            .chain(UNEXPLOITED_HINTS.iter())
329        {
330            assert!(seen.insert(*name), "`{name}` is listed twice");
331        }
332    }
333
334    /// A pristine options list touches nothing.
335    #[test]
336    fn defaults_are_not_refused() {
337        let (opts, reg) = fixture();
338        assert_eq!(refusal(&opts, &reg), None);
339        assert!(hint_warnings(&opts, &reg).is_empty());
340    }
341
342    /// Explicitly writing a default is how a generated `ipopt.opt` looks;
343    /// it asks for nothing and must not fail.
344    #[test]
345    fn explicitly_setting_the_default_is_not_refused() {
346        let (mut opts, reg) = fixture();
347        // `dependency_detector` defaults to "none"; `magic_steps` to "no".
348        opts.set_string_value("dependency_detector", "none", true, false)
349            .unwrap();
350        opts.set_string_value("magic_steps", "no", true, false)
351            .unwrap();
352        assert_eq!(refusal(&opts, &reg), None);
353    }
354
355    /// …but asking for the feature is refused, by name, with a pointer.
356    #[test]
357    fn requesting_an_unimplemented_feature_is_refused() {
358        let (mut opts, reg) = fixture();
359        opts.set_string_value("dependency_detector", "mumps", true, false)
360            .unwrap();
361        let msg = refusal(&opts, &reg).expect("must refuse");
362        assert!(msg.contains("dependency_detector"), "{msg}");
363        assert!(msg.contains("linear-dependency detection"), "{msg}");
364        assert!(msg.contains("483"), "{msg}");
365    }
366
367    /// Numeric knobs of an absent feature are refused the same way.
368    #[test]
369    fn a_numeric_knob_of_an_absent_feature_is_refused() {
370        let (mut opts, reg) = fixture();
371        opts.set_numeric_value("penalty_init_max", 42.0, true, false)
372            .unwrap();
373        let msg = refusal(&opts, &reg).expect("must refuse");
374        assert!(msg.contains("CG-penalty"), "{msg}");
375    }
376
377    /// Hints warn instead of failing: the answer is the same either way,
378    /// so blocking the solve would cost the user more than the silence
379    /// did.
380    #[test]
381    fn caching_hints_warn_but_do_not_refuse() {
382        let (mut opts, reg) = fixture();
383        opts.set_string_value("hessian_constant", "yes", true, false)
384            .unwrap();
385        assert_eq!(refusal(&opts, &reg), None, "a hint must not block a solve");
386        let warnings = hint_warnings(&opts, &reg);
387        assert_eq!(warnings.len(), 1, "{warnings:?}");
388        assert!(warnings[0].contains("hessian_constant"));
389    }
390
391    /// `fast_step_computation` was in the refusal table for one commit,
392    /// added by hand against the membership rule above. It fails here if
393    /// it ever comes back: `PdSearchDirCalc` owns the flag and consumes
394    /// it at two sites, so refusing it would fail a solve pounce can
395    /// serve. Its read site is wired in `algorithm_builder_from_options`.
396    #[test]
397    fn fast_step_computation_is_wired_not_refused() {
398        let (mut opts, reg) = fixture();
399        opts.set_string_value("fast_step_computation", "yes", true, false)
400            .unwrap();
401        assert_eq!(refusal(&opts, &reg), None);
402
403        let mut app = crate::application::IpoptApplication::new();
404        app.initialize().unwrap();
405        app.initialize_with_options_str("fast_step_computation yes\n")
406            .unwrap();
407        assert!(
408            app.algorithm_builder_from_options().fast_step_computation,
409            "the option must reach the builder, or wiring it changed nothing",
410        );
411        // …and the default is still off.
412        let mut app = crate::application::IpoptApplication::new();
413        app.initialize().unwrap();
414        assert!(!app.algorithm_builder_from_options().fast_step_computation);
415    }
416
417    /// `option_file_name` left the table when gh#518 implemented the
418    /// feature it names. Refusing it *from the table* again would be a
419    /// regression in the other direction: it now configures the run, so
420    /// a user who sets it gets what they asked for rather than an error.
421    #[test]
422    fn option_file_name_is_implemented_not_in_the_table() {
423        let (mut opts, reg) = fixture();
424        opts.set_string_value("option_file_name", "tiny.opt", true, false)
425            .unwrap();
426        assert_eq!(refusal(&opts, &reg), None);
427    }
428
429    /// …but leaving the table must not hand the option back its silence
430    /// on the surfaces that still cannot honor it. Only
431    /// `initialize_with_option_file` resolves it, and library callers
432    /// (Python, the C interface, WASM) never call it — so there, setting
433    /// the option is still refused, just by a different guard.
434    #[test]
435    fn option_file_name_is_refused_where_nothing_resolves_it() {
436        let mut app = crate::application::IpoptApplication::new();
437        app.initialize().unwrap();
438        assert_eq!(app.unhonored_option_file_name(), None, "unset asks nothing");
439
440        app.initialize_with_options_str("option_file_name tiny.opt\n")
441            .unwrap();
442        let msg = app
443            .unhonored_option_file_name()
444            .expect("a library caller cannot honor it");
445        assert!(msg.contains("tiny.opt"), "{msg}");
446        assert!(msg.contains("does not read options files"), "{msg}");
447        assert!(msg.contains("518"), "{msg}");
448    }
449
450    /// The default gate applies here too: `option_file_name` defaults to
451    /// `ipopt.opt`, so a caller replaying a full option dump sets that
452    /// value while asking for nothing. Failing them would break the same
453    /// compatibility the explicitly-set-default rule protects everywhere
454    /// else.
455    #[test]
456    fn option_file_name_at_its_default_asks_nothing_of_a_library_caller() {
457        let mut app = crate::application::IpoptApplication::new();
458        app.initialize_with_options_str("option_file_name ipopt.opt\n")
459            .unwrap();
460        assert_eq!(app.unhonored_option_file_name(), None);
461    }
462
463    /// On the CLI's path the option *is* resolved, so the guard stays
464    /// quiet — including when the resolver finds no file to read, which
465    /// still means the option was honored (there was nothing to read).
466    #[test]
467    fn the_guard_is_quiet_once_the_option_file_path_has_run() {
468        let dir = std::env::temp_dir().join(format!("pounce_gh518_lib_{}", std::process::id()));
469        std::fs::create_dir_all(&dir).unwrap();
470        let path = dir.join("tiny.opt");
471        std::fs::write(&path, "max_iter 5\n").unwrap();
472
473        let mut app = crate::application::IpoptApplication::new();
474        app.initialize_with_option_file(Some(&path)).unwrap();
475        assert_eq!(app.unhonored_option_file_name(), None);
476        assert_eq!(
477            app.options().get_integer_value("max_iter", "").unwrap(),
478            (5, true),
479            "the file must actually have been read",
480        );
481
482        let _ = std::fs::remove_dir_all(&dir);
483    }
484
485    /// The restoration switches wired in gh#483 / #191 round 2. Each
486    /// field was already consumed by `RestoAlgorithmBuilder`; only the
487    /// read site was missing, so setting the option did nothing. The
488    /// assertion that matters is that the value *reaches the builder* —
489    /// a read site populating a field nobody consumes would be a fresh
490    /// silent no-op, the very defect this work removes.
491    #[test]
492    fn the_restoration_switches_reach_the_builder() {
493        for (key, default_on) in [
494            ("evaluate_orig_obj_at_resto_trial", true),
495            ("expect_infeasible_problem", false),
496            ("start_with_resto", false),
497        ] {
498            let mut app = crate::application::IpoptApplication::new();
499            app.initialize().unwrap();
500            let resto = app.algorithm_builder_from_options().resto;
501            let got = match key {
502                "evaluate_orig_obj_at_resto_trial" => resto.evaluate_orig_obj_at_resto_trial,
503                "expect_infeasible_problem" => resto.expect_infeasible_problem,
504                _ => resto.start_with_resto,
505            };
506            assert_eq!(got, default_on, "{key}: default changed");
507
508            // Flip it and check the flip lands.
509            let flipped = if default_on { "no" } else { "yes" };
510            let mut app = crate::application::IpoptApplication::new();
511            app.initialize().unwrap();
512            app.initialize_with_options_str(&format!("{key} {flipped}\n"))
513                .unwrap();
514            let resto = app.algorithm_builder_from_options().resto;
515            let got = match key {
516                "evaluate_orig_obj_at_resto_trial" => resto.evaluate_orig_obj_at_resto_trial,
517                "expect_infeasible_problem" => resto.expect_infeasible_problem,
518                _ => resto.start_with_resto,
519            };
520            assert_eq!(
521                got, !default_on,
522                "{key}={flipped} never reached the builder"
523            );
524        }
525    }
526
527    /// The L-BFGS σ clamp, wired in gh#483 / #191 round 2.
528    /// `LimMemQuasiNewtonUpdater` consumes both bounds in
529    /// `initial_hessian_scalar`; only the read sites were missing. Note
530    /// the fields are named `init_val_{max,min}`, not after the options —
531    /// which is why a grep for the option name found nothing and the
532    /// consumer had to be located by hand.
533    #[test]
534    fn the_lbfgs_sigma_clamp_reaches_the_builder() {
535        let mut app = crate::application::IpoptApplication::new();
536        app.initialize().unwrap();
537        let b = app.algorithm_builder_from_options();
538        assert_eq!(b.limited_memory_init_val_max, 1e8, "default changed");
539        assert_eq!(b.limited_memory_init_val_min, 1e-8, "default changed");
540
541        let mut app = crate::application::IpoptApplication::new();
542        app.initialize().unwrap();
543        app.initialize_with_options_str(
544            "limited_memory_init_val_max 5e5\nlimited_memory_init_val_min 1e-3\n",
545        )
546        .unwrap();
547        let b = app.algorithm_builder_from_options();
548        assert_eq!(
549            b.limited_memory_init_val_max, 5e5,
550            "never reached the builder"
551        );
552        assert_eq!(
553            b.limited_memory_init_val_min, 1e-3,
554            "never reached the builder"
555        );
556    }
557
558    /// Options whose *feature* runs and only whose read site is missing
559    /// must stay out of the table — refusing them would fail solves that
560    /// are correct today. This pins the boundary the triage drew.
561    #[test]
562    fn options_on_implemented_features_are_not_refused() {
563        for (name, value) in [
564            // restoration runs; these are missing read sites (#191 round 2)
565            ("max_resto_iter", "17"),
566            // the filter line search runs
567            ("accept_after_max_steps", "3"),
568            // L-BFGS runs
569            ("limited_memory_max_skipping", "4"),
570            // the Mehrotra corrector runs
571            ("corrector_type", "affine"),
572            // `PdSearchDirCalc` has the flag and consumes it; it was
573            // briefly in the refusal table by hand, against the rule
574            // above, which would have failed a solve it can serve.
575            ("fast_step_computation", "yes"),
576        ] {
577            let (mut opts, reg) = fixture();
578            // The table mixes string, integer and numeric options; try
579            // each setter until one takes the value.
580            let set = opts.set_string_value(name, value, true, false).is_ok()
581                || value
582                    .parse::<i32>()
583                    .ok()
584                    .is_some_and(|v| opts.set_integer_value(name, v, true, false).is_ok())
585                || value
586                    .parse::<f64>()
587                    .ok()
588                    .is_some_and(|v| opts.set_numeric_value(name, v, true, false).is_ok());
589            assert!(set, "could not set `{name}` to `{value}`");
590            assert_eq!(
591                refusal(&opts, &reg),
592                None,
593                "`{name}` configures a feature pounce implements; it needs a \
594                 read site, not a refusal",
595            );
596        }
597    }
598}