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 — is a missing read site, not a
30//! missing feature; refusing those would fail solves whose current
31//! answers are already correct. They are deliberately **not** here;
32//! wiring them is the other half of the work.
33//!
34//! Rule 2 is the one that takes work, and the corrector knobs are why.
35//! `corrector_type` and its three safeguards were once listed here as an
36//! example of rule 1 alone — "the Mehrotra corrector runs, so these need
37//! read sites" — on the strength of pounce having a corrector at all.
38//! They configure a different one. The registry files them under
39//! `FilterLSAcceptor::RegisterOptions`, and what they select is
40//! `FilterLSAcceptor::TryCorrector`: a corrector step *tried inside the
41//! line search* and accepted only if complementarity does not grow by
42//! more than `corrector_compl_avrg_red_fact`. pounce's corrector is
43//! Mehrotra's, in the search-direction right-hand side, reached through
44//! `mehrotra_algorithm` — an option upstream registers separately and
45//! pounce reads. No acceptor here takes a corrector trial, so the four
46//! are refused (#551 / #677). A dev-note had already measured the
47//! consequence without naming it: `corrector_type=affine` on `robot_a`
48//! was recorded as "identical to plain adaptive".
49//!
50//! A third shape belongs to neither rule: an option for a
51//! *sub-capability* of a feature that does run.
52//! `resto_failure_feasibility_threshold` is the example — restoration
53//! runs, but nothing reclassifies a stopped restoration as a failure
54//! below a feasibility threshold, so honouring it means building the
55//! capability, not adding a line. Those entries are refused too, and
56//! their `advice` says what the parent feature does instead, so the
57//! message tells a user which of the two they hit. `max_resto_iter`
58//! looked like this shape and was not: `RestoConvCheckAdapter` has
59//! capped successive restoration iterations all along, under the name
60//! `maximum_resto_iters`. It is wired, not refused.
61//!
62//! The clearest case is the penalty line search. pounce implements
63//! `IpPenaltyLSAcceptor` (`line_search_method=penalty`), so its knobs
64//! (`nu_init`, `nu_inc`, `rho`, `eta_penalty`) are read sites to add.
65//! Ipopt's *other* penalty acceptor — the CG-penalty / inexact-Newton
66//! one — has no counterpart here at all, and the port registered its
67//! whole option set. Those are refused.
68//!
69//! An entry leaves the table by being implemented. `option_file_name`
70//! was here — refusing it was the cheap half of gh#518's "implement it
71//! or fail loudly" — until gh#518 got the other half:
72//! [`crate::application::IpoptApplication::initialize_with_option_file`]
73//! reads the named file, so the option now configures something.
74//!
75//! # Backend knobs warn, they do not refuse
76//!
77//! The 111 `ma27_*` / `ma77_*` / `ma86_*` / `ma97_*` / `mumps_*` /
78//! `pardiso_*` / `pardisomkl_*` / `spral_*` / `wsmp_*` options, plus
79//! `pardisolib`, tune linear-solver backends pounce does not ship at
80//! all: it factors the KKT system with `feral` or with MA57. They were
81//! silent — #551 section 2 — and they are the one class where the
82//! refusal above is the wrong instrument.
83//!
84//! Refusing them attacks the goal the registry exists to serve. The
85//! rest of this table refuses an option whose *feature* the caller is
86//! plainly asking for; `ma97_order` in a portable `ipopt.opt` is
87//! usually not that. Such a file routinely carries settings for several
88//! backends at once so that one file runs everywhere, and pounce would
89//! reject it wholesale over knobs the run never touches — a hard error
90//! for a user who is not using MA97 and never asked pounce to. That is
91//! strictly worse than the silence: it breaks a working file instead of
92//! under-serving one.
93//!
94//! But silence is what #677 cost, so the answer is the third
95//! disposition this module already carries. The precedent is
96//! [`UNEXPLOITED_HINTS`] — pinned by `a_caching_hint_warns_but_solves`
97//! in `pounce-cli/tests/unimplemented_options.rs` — where the project
98//! chose WARN over REFUSE for exactly this trade: ignoring the option
99//! costs the caller nothing they cannot see, so blocking the solve
100//! would take more from them than the silence did. Backend knobs are a
101//! stronger case for it than the hints are: a hint changes the
102//! evaluation count, whereas a knob for a backend that is not linked
103//! could not have changed anything even in principle.
104//!
105//! Three properties follow from that, and each is pinned by a test:
106//!
107//! 1. **Only when explicitly set to a non-default.** The same gate as
108//!    everything else here (below). A file spelling out `ma97_u 1e-8`
109//!    asks for nothing, and a default run must stay completely silent —
110//!    `a_default_run_is_silent`.
111//! 2. **One line per backend family, not per option.** An MA97-tuned
112//!    file sets a dozen `ma97_*` knobs; a dozen near-identical lines is
113//!    noise a reader learns to skip, which is silence with extra steps.
114//!    The warning names the backend, lists the options it saw, and says
115//!    the rest of the family is inert too.
116//! 3. **The solve runs and its answer is unaffected**, which the
117//!    warning says in as many words — otherwise a warning naming a
118//!    linear solver reads as "your factorization may be wrong".
119//!
120//! `hsllib` stays in the refusal table above rather than moving here,
121//! and the line is deliberate: pounce *has* an HSL backend (MA57), so
122//! `hsllib` is a caller trying to reach a solver pounce can actually
123//! run, by a mechanism it does not have — the refusal tells them to
124//! build with `--features ma57` instead of letting them believe MA57 is
125//! loaded. `pardisolib` has no such other route (there is no Pardiso
126//! here by any means), so it warns with the rest of its family.
127//!
128//! ## …unless they are all there is
129//!
130//! The rule above rests on one premise, stated in it: the file has
131//! other business here, and failing the run over `ma97_order` would
132//! reject something the caller wanted for the sake of a knob it never
133//! touches. When the backend knobs are *all* that is there, the premise
134//! is gone. Nothing in the file survives, so there is no working run
135//! left to protect, and warning-then-solving answers "tune the linear
136//! solver" by tuning nothing and reporting success — the shape of
137//! gh#677, not a fix for it.
138//!
139//! So [`backend_only_refusal`] refuses that one case. It is the
140//! boundary of the warn rule rather than an exception to it: every file
141//! the portability argument was ever about still warns and still
142//! solves, because every such file has something else in it.
143//!
144//! Note that a file which *selects* the backend it tunes never reaches
145//! here — `linear_solver=ma97` is refused earlier, by
146//! [`crate::application::IpoptApplication::unimplemented_linear_solver`].
147//! What this catches is the file that tunes MA97 without ever saying
148//! so, which is the case that used to run FERAL and report success.
149//!
150//! # The default gate
151//!
152//! Only an explicit value **different from the registered default** is
153//! refused. `corrector_type` left alone, or an `ipopt.opt` that spells
154//! out defaults, must keep working: those ask for nothing. Refusing them
155//! would break the very compatibility the registry exists to provide.
156//! This gate binds [`backend_only_refusal`] too: a file of nothing but
157//! backend knobs at their registered defaults is silent, not refused.
158
159use pounce_common::options_list::OptionsList;
160use pounce_common::reg_options::{DefaultValue, RegisteredOptions};
161
162/// One unimplemented feature and the options that configure it.
163pub struct UnimplementedFeature {
164    /// Named in the error, e.g. "the CG-penalty / inexact-Newton line search".
165    pub feature: &'static str,
166    /// What the caller can do instead. Empty when there is nothing.
167    pub advice: &'static str,
168    /// The options that belong to it.
169    pub options: &'static [&'static str],
170    /// Issue tracking the missing feature, named in the error.
171    pub issue: u32,
172}
173
174/// Feature groups pounce does not implement. Refused when set.
175pub const UNIMPLEMENTED_FEATURES: &[UnimplementedFeature] = &[
176    UnimplementedFeature {
177        issue: 483,
178        feature: "the Chen-Goldfarb (CG-penalty) / inexact-Newton line search \
179                  — Ipopt's `CGPenaltyLSAcceptor`",
180        advice: "pounce implements the filter line search (the default) and \
181                 `line_search_method=penalty` (`IpPenaltyLSAcceptor`); tune \
182                 those instead",
183        options: &[
184            "chi_cup",
185            "chi_hat",
186            "chi_tilde",
187            "delta_y_max",
188            "epsilon_c",
189            "eta_min",
190            "fast_des_fact",
191            "gamma_hat",
192            "gamma_tilde",
193            "kappa_x_dis",
194            "kappa_y_dis",
195            "min_alpha_primal",
196            "mult_diverg_feasibility_tol",
197            "mult_diverg_y_tol",
198            "never_use_fact_cgpen_direction",
199            "never_use_piecewise_penalty_ls",
200            "pen_des_fact",
201            "pen_init_fac",
202            "pen_theta_max_fact",
203            "penalty_init_max",
204            "penalty_init_min",
205            "penalty_max",
206            "penalty_update_compl_tol",
207            "penalty_update_infeasibility_tol",
208            "piecewisepenalty_gamma_infeasi",
209            "piecewisepenalty_gamma_obj",
210            "vartheta",
211            "inexact_algorithm",
212        ],
213    },
214    UnimplementedFeature {
215        issue: 551,
216        feature: "the CG-penalty acceptor's `theta_min` — the constraint-violation \
217                  threshold its piecewise-penalty tests switch on. It is \
218                  registered by `IpCGPenaltyLSAcceptor`, not by the filter \
219                  acceptor, and pounce has no CG-penalty acceptor to point it at",
220        advice: "the filter line search has a theta_min of its own, but derives \
221                 it the way upstream does — `theta_min_fact * max(1, theta_0)`, \
222                 never set directly — so set `theta_min_fact` to move it",
223        options: &["theta_min"],
224    },
225    UnimplementedFeature {
226        issue: 551,
227        feature: "the `primal-and-full` / `dual-and-full` equality-multiplier \
228                  step rules, which is all this tolerance configures — under \
229                  them the multiplier step jumps to 1 once the max-norm of the \
230                  primal step drops below it",
231        advice: "pounce implements `alpha_for_y` = `primal` (the default), \
232                 `bound-mult`, `min`, `max` and `full`; `full` takes the unit \
233                 multiplier step unconditionally",
234        options: &["alpha_for_y_tol"],
235    },
236    UnimplementedFeature {
237        issue: 483,
238        feature: "derivative approximation by finite differences",
239        advice: "supply `eval_grad_f` / `eval_jac_g` / `eval_h`, and check them \
240                 with `derivative_test=first-order`",
241        options: &[
242            "gradient_approximation",
243            "jacobian_approximation",
244            "findiff_perturbation",
245        ],
246    },
247    UnimplementedFeature {
248        issue: 483,
249        feature: "linear-dependency detection on the equality constraints",
250        advice: "pounce's presolve removes structurally redundant rows; see \
251                 `presolve`",
252        options: &[
253            "dependency_detector",
254            "dependency_detection_with_rhs",
255            "ma28_pivtol",
256        ],
257    },
258    UnimplementedFeature {
259        issue: 483,
260        feature: "the per-iteration NaN/Inf check on derivative matrices",
261        advice: "`derivative_test=first-order` checks the derivatives once, at \
262                 the starting point",
263        options: &["check_derivatives_for_naninf"],
264    },
265    UnimplementedFeature {
266        issue: 483,
267        feature: "least-square initialization of *all* dual variables \
268                  (the first-order-optimality fit)",
269        advice: "the equality multipliers are least-square initialized \
270                 regardless (capped by `constr_mult_init_max`), and the bound \
271                 multipliers take `bound_mult_init_val` — which is what \
272                 `least_square_init_duals=no` asks for",
273        options: &["least_square_init_duals"],
274    },
275    UnimplementedFeature {
276        issue: 483,
277        feature: "a selectable constraint-violation norm",
278        advice: "pounce measures the violation in the 2-norm throughout",
279        options: &["constraint_violation_norm_type"],
280    },
281    UnimplementedFeature {
282        issue: 483,
283        feature: "magic steps",
284        advice: "",
285        options: &["magic_steps"],
286    },
287    UnimplementedFeature {
288        issue: 483,
289        feature: "bound replacement on the original problem",
290        advice: "",
291        options: &["replace_bounds"],
292    },
293    UnimplementedFeature {
294        issue: 483,
295        feature: "the L-BFGS augmented-system and space variants",
296        advice: "`hessian_approximation=limited-memory` uses the low-rank \
297                 augmented system unconditionally",
298        options: &["hessian_approximation_space", "limited_memory_aug_solver"],
299    },
300    UnimplementedFeature {
301        issue: 483,
302        feature: "skipping the finalize-solution callback",
303        advice: "",
304        options: &["skip_finalize_solution_call"],
305    },
306    UnimplementedFeature {
307        issue: 483,
308        feature: "the dynamic HSL loader",
309        advice: "MA57 is linked at build time with `--features ma57`",
310        options: &["hsllib"],
311    },
312    UnimplementedFeature {
313        issue: 483,
314        feature: "these output controls",
315        advice: "use `print_level` (0 silences the solver) and `sb=yes` to \
316                 suppress the banner",
317        options: &["suppress_all_output", "debug_print_level"],
318    },
319    UnimplementedFeature {
320        issue: 483,
321        feature: "a randomly perturbed evaluation point for the derivative \
322                  checker",
323        advice: "pounce's checker tests at the (bound-projected) starting point, \
324                 which is where the solve actually begins",
325        options: &["point_perturbation_radius"],
326    },
327    // ---- #551 / #677 round 3. Four groups whose *name* appears nowhere
328    // outside the registry and whose feature is absent too, so they were
329    // silent no-ops. The first is a whole feature pounce never ported;
330    // the other three are sub-capabilities of features that do run —
331    // restoration and L-BFGS — which is why each `advice` says what the
332    // parent feature does instead of what a replacement option is.
333    UnimplementedFeature {
334        issue: 551,
335        feature: "the corrector step tried inside the filter line search \
336                  under the adaptive barrier strategy — Ipopt's \
337                  `FilterLSAcceptor::TryCorrector`, which these four knobs \
338                  select and safeguard",
339        advice: "pounce's line search takes no corrector step at all, so \
340                 there is nothing for these to gate. The predictor-corrector \
341                 pounce does implement is Mehrotra's, applied to the \
342                 search-direction right-hand side rather than as a \
343                 line-search trial: `mehrotra_algorithm=yes` is read and \
344                 honoured (it also selects `mu_strategy=adaptive` and \
345                 `mu_oracle=probing`)",
346        options: &[
347            "corrector_type",
348            "skip_corr_if_neg_curv",
349            "skip_corr_in_monotone_mode",
350            "corrector_compl_avrg_red_fact",
351        ],
352    },
353    UnimplementedFeature {
354        issue: 551,
355        feature: "the `expect_infeasible_problem` heuristics inside the \
356                  filter line search — switching them off once the \
357                  constraint violation drops below a threshold (`_ctol`), \
358                  and diverting to restoration once the constraint \
359                  multipliers' max-norm rises above one (`_ytol`)",
360        advice: "the restoration phase itself runs and is unaffected; \
361                 pounce enters it when the line search cannot make \
362                 progress, and `required_infeasibility_reduction` sets how \
363                 much infeasibility reduction it must deliver before \
364                 handing back. `IpBacktrackingLineSearch`'s \
365                 `count_successive_shortened_steps_` machinery, which is \
366                 what these two thresholds steer, has no counterpart here",
367        options: &[
368            "expect_infeasible_problem_ctol",
369            "expect_infeasible_problem_ytol",
370        ],
371    },
372    UnimplementedFeature {
373        issue: 551,
374        feature: "the special quasi-Newton update Ipopt used inside the \
375                  restoration phase before Nov 2010",
376        advice: "L-BFGS runs in the restoration sub-solve; it uses the \
377                 regular update procedure there, which is what \
378                 `limited_memory_special_for_resto=no` — upstream's own \
379                 default, and its recommendation — asks for. Only the \
380                 revert to the old update is missing",
381        options: &["limited_memory_special_for_resto"],
382    },
383    UnimplementedFeature {
384        issue: 551,
385        feature: "declaring the restoration phase *failed* when it stops on \
386                  the acceptable-point criteria at a primal infeasibility \
387                  below a threshold",
388        advice: "restoration runs and reports failure on its own terms — \
389                 the reduction guard (`required_infeasibility_reduction`), \
390                 the successive-iteration cap (`max_resto_iter`), and the \
391                 locally-infeasible verdicts, which measure a violation \
392                 against `constr_viol_tol` relative to the offending row. \
393                 There is no threshold below which a stopped restoration is \
394                 reclassified as a failure, so this option has nothing to \
395                 set",
396        options: &["resto_failure_feasibility_threshold"],
397    },
398    UnimplementedFeature {
399        issue: 551,
400        feature: "choosing the barrier parameter with an *oracle* when the \
401                  adaptive strategy leaves free mode — Ipopt's \
402                  `fix_mu_oracle_`",
403        advice: "pounce implements `fixed_mu_oracle=average_compl` (the \
404                 default): the switch into fixed mode seeds μ with \
405                 `adaptive_mu_monotone_init_factor · avrg_compl`, which \
406                 that factor tunes. The probing / loqo / quality-function \
407                 oracles are implemented, but only for `mu_oracle`, which \
408                 drives μ in free mode",
409        options: &["fixed_mu_oracle"],
410    },
411    UnimplementedFeature {
412        issue: 606,
413        feature: "reuse of a previously-solved iterate or problem structure \
414                  through Ipopt's `TNLP::GetWarmStartIterate` surface",
415        advice: "pounce's warm start goes through `TNLP::get_starting_point` \
416                 with `warm_start_init_point=yes`, which carries the primal \
417                 point and all three multiplier blocks; from Python, \
418                 `pounce.WarmStart.from_info` packages it",
419        options: &["warm_start_entire_iterate", "warm_start_same_structure"],
420    },
421    UnimplementedFeature {
422        issue: 677,
423        feature: "sensitivity over more than one perturbation tier — upstream \
424                  sIPOPT walks `sens_state_1`, `sens_state_2`, … one tier per \
425                  step and reports a `sens_sol_state_k` for each",
426        advice: "pounce computes the single `sens_state_1` tier, which is what \
427                 `n_sens_steps=1` (the default) asks for; for a multi-step \
428                 parameter path, run one solve per perturbation, or drive \
429                 `pounce_sensitivity::Solver::parametric_step` in a loop \
430                 against the one converged factor",
431        options: &["n_sens_steps"],
432    },
433];
434
435/// One registered *value* of a string option that pounce does not
436/// implement, even though the option itself is read and other values of
437/// it work.
438///
439/// [`UNIMPLEMENTED_FEATURES`] refuses a whole option; this refuses one
440/// mode of one. The registry keeps upstream's full value list so an
441/// `ipopt.opt` written for Ipopt still parses — but a value that parses
442/// and then quietly behaves as a *different* mode is the same lie the
443/// module docstring is about, one level down.
444pub struct UnimplementedValue {
445    /// The option's registered name.
446    pub option: &'static str,
447    /// The value pounce does not implement.
448    pub value: &'static str,
449    /// What that value would mean, named in the error.
450    pub feature: &'static str,
451    /// What the caller can do instead. Empty when there is nothing.
452    pub advice: &'static str,
453    /// Issue tracking the missing mode, named in the error.
454    pub issue: u32,
455}
456
457/// String-option values pounce does not implement. Refused when set.
458pub const UNIMPLEMENTED_VALUES: &[UnimplementedValue] = &[
459    UnimplementedValue {
460        option: "bound_mult_init_method",
461        value: "mu-based",
462        feature: "initializing each bound multiplier to mu_init divided by its \
463                  own slack",
464        advice: "`bound_mult_init_method=constant` (the default) initializes \
465                 them all to `bound_mult_init_val`, which you can set",
466        issue: 604,
467    },
468    // gh#981: the CG-penalty acceptor's *options* were refused (the
469    // first entry of `UNIMPLEMENTED_FEATURES`), but the value that
470    // selects it was not — `application.rs` mapped it to
471    // `LineSearchChoice::CgPenalty`, which `alg_builder.rs` assembles as
472    // the plain `PenaltyLsAcceptor`. Measured: the run was identical to
473    // `line_search_method=penalty` down to the step characters.
474    UnimplementedValue {
475        option: "line_search_method",
476        value: "cg-penalty",
477        feature: "the Chen-Goldfarb (CG-penalty) line search — Ipopt's \
478                  `CGPenaltyLSAcceptor`",
479        advice: "`line_search_method=filter` (the default) or \
480                 `line_search_method=penalty` (`IpPenaltyLSAcceptor`), which \
481                 is what `cg-penalty` actually ran",
482        issue: 981,
483    },
484];
485
486/// Options that *are* honored in the sense that matters — the answer is
487/// unaffected — but whose performance hint pounce does not exploit.
488/// These warn rather than fail: refusing them would stop a solve that
489/// returns the right result today, only a little slower.
490///
491/// **Empty since gh #588 phase Q6.** The four constant-derivative hints
492/// (`grad_f_constant`, `hessian_constant`, `jac_c_constant`,
493/// `jac_d_constant`) lived here and are now exploited:
494/// [`pounce_nlp::constant_derivatives`] reconciles each one against what
495/// the model can prove about its own algebra, and
496/// `OrigIpoptNlp` reuses the derivative across iterates when the answer
497/// is yes. A hint the model *disproves* still produces a warning — but a
498/// louder one, from that module, saying the hint was refused rather than
499/// merely unused.
500///
501/// The table stays because the shape is right for the next hint that
502/// arrives registered-but-unexploited, and because
503/// [`hint_warnings`] and its two membership tests are the mechanism that
504/// keeps such an option from going silent again.
505pub const UNEXPLOITED_HINTS: &[&str] = &[];
506
507/// The same four hints again — on the **convex** route, where they are
508/// still unexploited.
509///
510/// [`UNEXPLOITED_HINTS`] is empty because the NLP path exploits them. The
511/// convex path does not: an LP/QP/SOCP routed to `pounce-convex` never
512/// builds an `OrigIpoptNlp` and so never reaches
513/// `IpoptApplication::install_constant_derivative_hints`, which is where
514/// the reuse is decided. Emptying the table above therefore silenced the
515/// warning on *both* routes when only one of them had earned it — a
516/// registered option going quiet without anyone deciding it should, which
517/// is the failure mode this module exists to remove (gh#483).
518///
519/// A warning and not a refusal, for the same reason as above and one
520/// more: on a convex engine these hints are not merely unexploited but
521/// structurally vacuous. The engine is handed constant `P`/`A` matrices
522/// to begin with, so there is no per-iterate derivative to cache and
523/// asserting that there is changes nothing about the answer.
524pub const CONVEX_UNEXPLOITED_HINTS: &[&str] = &pounce_nlp::constant_derivatives::HINT_OPTIONS;
525
526/// One linear-solver backend pounce does not implement, and the
527/// registered options that tune it.
528///
529/// Separate from [`UnimplementedFeature`] because the disposition is
530/// different: these *warn* and solve, they never refuse. See "Backend
531/// knobs warn, they do not refuse" in the module header for why.
532pub struct UnimplementedBackend {
533    /// Named in the warning, e.g. "the HSL MA97 sparse symmetric linear
534    /// solver".
535    pub backend: &'static str,
536    /// The registered prefix the family shares, quoted in the warning so
537    /// the user learns the whole group is inert, not just the one option
538    /// they happened to set.
539    pub family: &'static str,
540    /// Every registered option of this backend. Complete per family —
541    /// `backend_families_are_complete` fails if the registry grows one
542    /// that is missing here, which would hand it back its silence.
543    pub options: &'static [&'static str],
544}
545
546/// Every linear-solver backend pounce does not implement, with the
547/// options that tune it. Warned about when set; never refused.
548pub const UNIMPLEMENTED_BACKENDS: &[UnimplementedBackend] = &[
549    UnimplementedBackend {
550        backend: "the HSL MA27 sparse symmetric linear solver",
551        family: "ma27_*",
552        options: &[
553            "ma27_ignore_singularity",
554            "ma27_la_init_factor",
555            "ma27_liw_init_factor",
556            "ma27_meminc_factor",
557            "ma27_pivtol",
558            "ma27_pivtolmax",
559            "ma27_print_level",
560            "ma27_skip_inertia_check",
561        ],
562    },
563    UnimplementedBackend {
564        backend: "the HSL MA77 out-of-core sparse symmetric linear solver",
565        family: "ma77_*",
566        options: &[
567            "ma77_buffer_lpage",
568            "ma77_buffer_npage",
569            "ma77_file_size",
570            "ma77_maxstore",
571            "ma77_nemin",
572            "ma77_order",
573            "ma77_print_level",
574            "ma77_small",
575            "ma77_static",
576            "ma77_u",
577            "ma77_umax",
578        ],
579    },
580    UnimplementedBackend {
581        backend: "the HSL MA86 parallel sparse symmetric linear solver",
582        family: "ma86_*",
583        options: &[
584            "ma86_nemin",
585            "ma86_order",
586            "ma86_print_level",
587            "ma86_scaling",
588            "ma86_small",
589            "ma86_static",
590            "ma86_u",
591            "ma86_umax",
592        ],
593    },
594    UnimplementedBackend {
595        backend: "the HSL MA97 sparse symmetric linear solver",
596        family: "ma97_*",
597        options: &[
598            "ma97_dump_matrix",
599            "ma97_nemin",
600            "ma97_order",
601            "ma97_print_level",
602            "ma97_scaling",
603            "ma97_scaling1",
604            "ma97_scaling2",
605            "ma97_scaling3",
606            "ma97_small",
607            "ma97_solve_blas3",
608            "ma97_switch1",
609            "ma97_switch2",
610            "ma97_switch3",
611            "ma97_u",
612            "ma97_umax",
613        ],
614    },
615    UnimplementedBackend {
616        backend: "the MUMPS sparse symmetric linear solver",
617        family: "mumps_*",
618        options: &[
619            "mumps_dep_tol",
620            "mumps_mem_percent",
621            "mumps_mpi_communicator",
622            "mumps_permuting_scaling",
623            "mumps_pivot_order",
624            "mumps_pivtol",
625            "mumps_pivtolmax",
626            "mumps_print_level",
627            "mumps_scaling",
628        ],
629    },
630    UnimplementedBackend {
631        backend: "the Pardiso linear solver (pardiso-project.org)",
632        family: "pardiso_*",
633        options: &[
634            "pardiso_iter_coarse_size",
635            "pardiso_iter_dropping_factor",
636            "pardiso_iter_dropping_schur",
637            "pardiso_iter_inverse_norm_factor",
638            "pardiso_iter_max_levels",
639            "pardiso_iter_max_row_fill",
640            "pardiso_iter_relative_tol",
641            "pardiso_iterative",
642            "pardiso_matching_strategy",
643            "pardiso_max_droptol_corrections",
644            "pardiso_max_iter",
645            "pardiso_max_iterative_refinement_steps",
646            "pardiso_msglvl",
647            "pardiso_order",
648            "pardiso_redo_symbolic_fact_only_if_inertia_wrong",
649            "pardiso_repeated_perturbation_means_singular",
650            "pardiso_skip_inertia_check",
651            "pardisolib",
652        ],
653    },
654    UnimplementedBackend {
655        backend: "the Pardiso linear solver bundled with Intel MKL",
656        family: "pardisomkl_*",
657        options: &[
658            "pardisomkl_matching_strategy",
659            "pardisomkl_max_iterative_refinement_steps",
660            "pardisomkl_msglvl",
661            "pardisomkl_order",
662            "pardisomkl_redo_symbolic_fact_only_if_inertia_wrong",
663            "pardisomkl_repeated_perturbation_means_singular",
664            "pardisomkl_skip_inertia_check",
665        ],
666    },
667    UnimplementedBackend {
668        backend: "the SPRAL SSIDS sparse symmetric linear solver",
669        family: "spral_*",
670        options: &[
671            "spral_cpu_block_size",
672            "spral_gpu_perf_coeff",
673            "spral_ignore_numa",
674            "spral_max_load_inbalance",
675            "spral_min_gpu_work",
676            "spral_nemin",
677            "spral_order",
678            "spral_pivot_method",
679            "spral_print_level",
680            "spral_scaling",
681            "spral_scaling_1",
682            "spral_scaling_2",
683            "spral_scaling_3",
684            "spral_small",
685            "spral_small_subtree_threshold",
686            "spral_switch_1",
687            "spral_switch_2",
688            "spral_switch_3",
689            "spral_u",
690            "spral_umax",
691            "spral_use_gpu",
692        ],
693    },
694    UnimplementedBackend {
695        backend: "the WSMP sparse symmetric linear solver",
696        family: "wsmp_*",
697        options: &[
698            "wsmp_inexact_droptol",
699            "wsmp_inexact_fillin_limit",
700            "wsmp_iterative",
701            "wsmp_max_iter",
702            "wsmp_no_pivoting",
703            "wsmp_num_threads",
704            "wsmp_ordering_option",
705            "wsmp_ordering_option2",
706            "wsmp_pivtol",
707            "wsmp_pivtolmax",
708            "wsmp_scaling",
709            "wsmp_singularity_threshold",
710            "wsmp_skip_inertia_check",
711            "wsmp_write_matrix_iteration",
712        ],
713    },
714];
715
716/// Warnings for backend knobs the caller set. Never blocks a solve, and
717/// emits at most one line per backend family: an `ipopt.opt` tuned for
718/// MA97 sets a dozen `ma97_*` knobs at once, and a dozen near-identical
719/// lines would be noise the reader learns to skip.
720///
721/// Same default gate as everything else here — an `ipopt.opt` that
722/// spells out `ma97_u 1e-8` (the registered default) asks for nothing
723/// and gets nothing said about it.
724pub fn backend_warnings(options: &OptionsList, reg: &RegisteredOptions) -> Vec<String> {
725    UNIMPLEMENTED_BACKENDS
726        .iter()
727        .filter_map(|group| {
728            let set: Vec<&str> = group
729                .options
730                .iter()
731                .copied()
732                .filter(|name| set_to_a_non_default(options, reg, name))
733                .collect();
734            if set.is_empty() {
735                return None;
736            }
737            let named = set
738                .iter()
739                .map(|n| format!("`{n}`"))
740                .collect::<Vec<_>>()
741                .join(", ");
742            let (verb, ignored, registered) = if set.len() == 1 {
743                ("configures", "it is ignored".to_string(), "The name is")
744            } else {
745                (
746                    "configure",
747                    format!("those {} are ignored", set.len()),
748                    "The names are",
749                )
750            };
751            Some(format!(
752                "pounce: warning: {named} {verb} {}, which pounce does not \
753                 implement, so {ignored} — as is every other `{}` option. \
754                 pounce factors the KKT system with `feral` (pure Rust, the \
755                 default) or MA57 (`linear_solver=ma57`, in a `--features \
756                 ma57` build); no setting written for another backend \
757                 transfers to either. {registered} registered so an \
758                 `ipopt.opt` written for Ipopt still parses unchanged — which \
759                 is why this is a warning and not an error: the solve runs, \
760                 and its result is unaffected. Tracking issue: \
761                 https://github.com/jkitchin/pounce/issues/551",
762                group.backend, group.family,
763            ))
764        })
765        .collect()
766}
767
768/// Names that say where the options came from, not what to solve.
769///
770/// `option_file_name` is the mechanism that delivered the rest of the
771/// list, so counting it as content would mean "the file you pointed me
772/// at configures nothing" is exactly the case that never fires — and
773/// pointing at a file is one of the two normal ways to supply one.
774/// Contrast `print_level`, which is deliberately *not* here: a caller
775/// who raises it asked for something and got it.
776const DELIVERY_MECHANISM: &[&str] = &["option_file_name"];
777
778/// Every backend-knob name in [`UNIMPLEMENTED_BACKENDS`].
779fn is_backend_knob(name: &str) -> bool {
780    UNIMPLEMENTED_BACKENDS
781        .iter()
782        .any(|group| group.options.contains(&name))
783}
784
785/// The refusal for a run whose options configure *nothing but* backends
786/// pounce does not ship, or `None`.
787///
788/// This is the boundary of the warn-don't-refuse rule above, not an
789/// exception to it. That rule rests on one premise: the file has other
790/// business here, and failing it over `ma97_order` would reject a run
791/// the caller wanted for the sake of a knob it never touches. When the
792/// backend knobs are *all* that is there, the premise is gone — nothing
793/// in the file survives, so there is no working run left to protect.
794/// Warning and solving anyway answers a request to tune the linear
795/// solver by tuning nothing and reporting success, which is the shape
796/// of gh#677 rather than a fix for it.
797///
798/// Two gates, and both are needed:
799///
800/// 1. **Something was actually asked for** — at least one backend knob
801///    is set to a non-default. A file that spells out `ma97_u 1e-8`
802///    (the registered default) asks for nothing and still gets nothing
803///    said about it, exactly as `a_backend_knob_at_its_default_is_silent`
804///    requires.
805/// 2. **Nothing else was mentioned at all.** The test is *presence* in
806///    the options list, not `set_to_a_non_default`: a caller who writes
807///    `tol 1e-8` has stated a real intention about this solve even
808///    when `1e-8` is the default, and a file with real content in it is
809///    the portable-`ipopt.opt` case the warning exists for. Presence is
810///    also the safe direction to be wrong in — it can only make this
811///    refusal rarer. [`DELIVERY_MECHANISM`] is the one exemption, and
812///    it exists so that pointing at a backend-only file with
813///    `option_file_name` is not permanently exempt from the refusal
814///    that file has earned.
815///
816/// Note that a file selecting the backend it tunes never reaches here:
817/// `linear_solver=ma97` is refused before this, by
818/// [`crate::application::IpoptApplication::unimplemented_linear_solver`].
819/// What lands here is the file that tunes MA97 without ever saying so.
820pub fn backend_only_refusal(options: &OptionsList, reg: &RegisteredOptions) -> Option<String> {
821    let asked_for_something = UNIMPLEMENTED_BACKENDS.iter().any(|group| {
822        group
823            .options
824            .iter()
825            .any(|name| set_to_a_non_default(options, reg, name))
826    });
827    if !asked_for_something {
828        return None;
829    }
830    let mentions_something_real = options
831        .names()
832        .any(|name| !is_backend_knob(name) && !DELIVERY_MECHANISM.contains(&name));
833    if mentions_something_real {
834        return None;
835    }
836    Some(
837        "pounce: error: every option this run sets configures a linear-solver \
838         backend pounce does not implement, so there is nothing left for it to \
839         act on. pounce factors the KKT system with `feral` (pure Rust, the \
840         default) or MA57 (`linear_solver=ma57`, in a `--features ma57` \
841         build); no setting written for another backend transfers to either. \
842         These names are registered so an `ipopt.opt` written for Ipopt still \
843         parses unchanged, and a file that also carries options pounce reads \
844         is warned about rather than refused — but a file that carries only \
845         these would run as if it had configured the solver when it \
846         configured nothing. Set `linear_solver=feral` (or `ma57`) if the \
847         defaults are what you want. Tracking issue: \
848         https://github.com/jkitchin/pounce/issues/551"
849            .to_string(),
850    )
851}
852
853/// An option set to something the registry says is not its default.
854///
855/// Both halves matter. `found` alone would fire on an `ipopt.opt` that
856/// spells out a default; comparing values alone would fire on nothing,
857/// since an unset option *reads back* as its default.
858pub(crate) fn set_to_a_non_default(
859    options: &OptionsList,
860    reg: &RegisteredOptions,
861    name: &str,
862) -> bool {
863    let Some(opt) = reg.get_option(name) else {
864        return false;
865    };
866    match &opt.default {
867        // Bools are registered as `yes`/`no` string options, so this arm
868        // covers them too.
869        DefaultValue::String(d) => {
870            matches!(options.get_string_value(name, ""), Ok((v, true)) if !v.eq_ignore_ascii_case(d))
871        }
872        DefaultValue::Number(d) => {
873            matches!(options.get_numeric_value(name, ""), Ok((v, true)) if v != *d)
874        }
875        DefaultValue::Integer(d) => {
876            matches!(options.get_integer_value(name, ""), Ok((v, true)) if v != *d)
877        }
878        DefaultValue::None => false,
879    }
880}
881
882/// The first unimplemented-feature option the caller set, with the
883/// message it earns. `None` when nothing in the table was touched.
884pub fn refusal(options: &OptionsList, reg: &RegisteredOptions) -> Option<String> {
885    for group in UNIMPLEMENTED_FEATURES {
886        for name in group.options {
887            if set_to_a_non_default(options, reg, name) {
888                let advice = if group.advice.is_empty() {
889                    String::new()
890                } else {
891                    format!(" Instead: {}.", group.advice)
892                };
893                return Some(format!(
894                    "pounce: `{name}` configures {}, which pounce does not \
895                     implement. It is registered so an ipopt.opt written for \
896                     Ipopt still parses, but setting it used to do nothing at \
897                     all — silently — so it is refused instead.{advice} \
898                     Remove it to run. Tracking issue: \
899                     https://github.com/jkitchin/pounce/issues/{}",
900                    group.feature, group.issue
901                ));
902            }
903        }
904    }
905    None
906}
907
908/// The first unimplemented *value* the caller selected, with the message
909/// it earns. `None` when every string option holds a mode pounce runs.
910///
911/// No default gate here, unlike [`refusal`]: a value that equals the
912/// registered default is by construction the implemented one, so it
913/// never reaches the table.
914pub fn value_refusal(options: &OptionsList) -> Option<String> {
915    for entry in UNIMPLEMENTED_VALUES {
916        let selected = matches!(
917            options.get_string_value(entry.option, ""),
918            Ok((v, true)) if v.eq_ignore_ascii_case(entry.value)
919        );
920        if !selected {
921            continue;
922        }
923        let advice = if entry.advice.is_empty() {
924            String::new()
925        } else {
926            format!(" Instead: {}.", entry.advice)
927        };
928        return Some(format!(
929            "pounce: `{}={}` selects {}, which pounce does not implement. The \
930             value is registered so an ipopt.opt written for Ipopt still \
931             parses; falling back to another mode would silently run a \
932             different algorithm than the one you asked for, so it is \
933             refused instead.{advice} Tracking issue: \
934             https://github.com/jkitchin/pounce/issues/{}",
935            entry.option, entry.value, entry.feature, entry.issue,
936        ));
937    }
938    None
939}
940
941/// Warnings for hints pounce does not exploit. Never blocks a solve.
942pub fn hint_warnings(options: &OptionsList, reg: &RegisteredOptions) -> Vec<String> {
943    UNEXPLOITED_HINTS
944        .iter()
945        .filter(|name| set_to_a_non_default(options, reg, name))
946        .map(|name| {
947            format!(
948                "pounce: warning: `{name}` is a caching hint pounce does not \
949                 exploit — it re-evaluates each iteration regardless. Your \
950                 answer is unaffected; only the evaluation count is. \
951                 (gh#483)"
952            )
953        })
954        .collect()
955}
956
957/// Warnings for the constant-derivative hints on the convex route, where
958/// — unlike the NLP route — nothing reads them. Never blocks a solve.
959/// Call this only when the convex dispatch is actually taken; on the NLP
960/// route the same options are honoured, and warning there would
961/// contradict the reuse the solver really does.
962pub fn convex_hint_warnings(options: &OptionsList, reg: &RegisteredOptions) -> Vec<String> {
963    CONVEX_UNEXPLOITED_HINTS
964        .iter()
965        .filter(|name| set_to_a_non_default(options, reg, name))
966        .map(|name| {
967            format!(
968                "pounce: warning: `{name}` asserts a derivative is constant \
969                 across iterates, which the NLP path reconciles against the \
970                 model and exploits — but this problem routed to \
971                 pounce-convex, whose LP/QP/SOCP engines are handed constant \
972                 matrices to begin with and do not read the option at all. \
973                 Your answer is unaffected. Use `solver_selection=nlp` if you \
974                 wanted the path that acts on it. (gh#483, gh#588)"
975            )
976        })
977        .collect()
978}
979
980#[cfg(test)]
981mod tests {
982    use super::*;
983    use std::collections::BTreeSet;
984
985    fn registry() -> std::rc::Rc<RegisteredOptions> {
986        let r = RegisteredOptions::new();
987        crate::upstream_options::register_all_upstream_options(&r).expect("register");
988        r
989    }
990
991    /// A fresh options list over the shared registry, plus a handle on
992    /// the registry itself for the default lookups.
993    fn fixture() -> (OptionsList, std::rc::Rc<RegisteredOptions>) {
994        let reg = registry();
995        (OptionsList::with_registered(std::rc::Rc::clone(&reg)), reg)
996    }
997
998    /// Every name in the table must actually be registered — a typo
999    /// would make its entry dead code that silently never fires, which
1000    /// is the exact failure mode this module exists to remove.
1001    #[test]
1002    fn every_listed_option_is_registered() {
1003        let (_, reg) = fixture();
1004        for group in UNIMPLEMENTED_FEATURES {
1005            for name in group.options {
1006                assert!(
1007                    reg.get_option(name).is_some(),
1008                    "`{name}` is in the refusal table but is not registered",
1009                );
1010            }
1011        }
1012        for name in UNEXPLOITED_HINTS.iter().chain(CONVEX_UNEXPLOITED_HINTS) {
1013            assert!(
1014                reg.get_option(name).is_some(),
1015                "`{name}` is in a hint table but is not registered",
1016            );
1017        }
1018        for group in UNIMPLEMENTED_BACKENDS {
1019            for name in group.options {
1020                assert!(
1021                    reg.get_option(name).is_some(),
1022                    "`{name}` is in the backend table but is not registered",
1023                );
1024            }
1025        }
1026    }
1027
1028    /// The backend groups must cover their families *completely*. A
1029    /// `ma97_*` option registered later and not added here would be
1030    /// silent again — the exact defect this table removes — and it would
1031    /// slip past `no_silent_options.rs` too, which only asks whether a
1032    /// name is declared somewhere, not whether the family is whole.
1033    #[test]
1034    fn backend_families_are_complete() {
1035        let (_, reg) = fixture();
1036        for group in UNIMPLEMENTED_BACKENDS {
1037            let prefix = group
1038                .family
1039                .strip_suffix('*')
1040                .expect("family is a prefix glob, e.g. `ma97_*`");
1041            for opt in reg.registered_options_in_order() {
1042                if !opt.name.starts_with(prefix) {
1043                    continue;
1044                }
1045                assert!(
1046                    group.options.contains(&opt.name.as_str()),
1047                    "`{}` is registered and matches `{}` but is missing from \
1048                     the {} group, so setting it would still be silent",
1049                    opt.name,
1050                    group.family,
1051                    group.backend,
1052                );
1053            }
1054        }
1055    }
1056
1057    /// A hint must never also be a refusal: refusing beats warning, so an
1058    /// option in both tables would fail the solve *and* warn about it.
1059    /// The two hint tables are allowed to differ from each other — they
1060    /// describe two different routes — but neither may collide with the
1061    /// features table.
1062    #[test]
1063    fn the_convex_hint_table_does_not_collide_with_a_refusal() {
1064        let refused: BTreeSet<&str> = UNIMPLEMENTED_FEATURES
1065            .iter()
1066            .flat_map(|g| g.options.iter().copied())
1067            .collect();
1068        for name in CONVEX_UNEXPLOITED_HINTS {
1069            assert!(
1070                !refused.contains(name),
1071                "`{name}` both warns on the convex route and is refused",
1072            );
1073        }
1074    }
1075
1076    /// The convex route never reaches `install_constant_derivative_hints`,
1077    /// so the hint that the NLP path acts on does nothing there. It has to
1078    /// say so: gh #588 Q6 emptied [`UNEXPLOITED_HINTS`] for the NLP route
1079    /// and took the convex route's warning down with it.
1080    #[test]
1081    fn the_convex_route_still_says_the_hints_do_nothing_there() {
1082        let (mut opts, reg) = fixture();
1083        assert!(
1084            convex_hint_warnings(&opts, &reg).is_empty(),
1085            "an unset hint must not warn"
1086        );
1087        opts.set_string_value("hessian_constant", "yes", true, false)
1088            .unwrap();
1089        let w = convex_hint_warnings(&opts, &reg);
1090        assert_eq!(w.len(), 1, "{w:?}");
1091        assert!(w[0].contains("hessian_constant"), "{}", w[0]);
1092        assert!(w[0].contains("pounce-convex"), "{}", w[0]);
1093        assert_eq!(
1094            refusal(&opts, &reg),
1095            None,
1096            "a hint must not block a convex solve either"
1097        );
1098    }
1099
1100    /// No option may appear twice — once in two feature groups, or in
1101    /// both tables — or the message a user gets would depend on table
1102    /// order.
1103    #[test]
1104    fn the_tables_do_not_overlap() {
1105        let mut seen = BTreeSet::new();
1106        for name in UNIMPLEMENTED_FEATURES
1107            .iter()
1108            .flat_map(|g| g.options.iter())
1109            .chain(UNEXPLOITED_HINTS.iter())
1110            .chain(UNIMPLEMENTED_BACKENDS.iter().flat_map(|g| g.options.iter()))
1111        {
1112            assert!(seen.insert(*name), "`{name}` is listed twice");
1113        }
1114    }
1115
1116    /// A pristine options list touches nothing.
1117    #[test]
1118    fn defaults_are_not_refused() {
1119        let (opts, reg) = fixture();
1120        assert_eq!(refusal(&opts, &reg), None);
1121        assert!(hint_warnings(&opts, &reg).is_empty());
1122        assert!(
1123            backend_warnings(&opts, &reg).is_empty(),
1124            "a default run must stay silent",
1125        );
1126        assert_eq!(
1127            backend_only_refusal(&opts, &reg),
1128            None,
1129            "an empty list sets no backend knob, so there is nothing to refuse",
1130        );
1131    }
1132
1133    /// Backend knobs with nothing else in the list are refused; adding
1134    /// one option pounce reads turns the same knobs back into a
1135    /// warning. This is the whole rule, in one test.
1136    #[test]
1137    fn backend_only_is_refused_but_a_mixed_list_is_not() {
1138        let (mut opts, reg) = fixture();
1139        opts.set_string_value("ma97_order", "metis", true, false)
1140            .unwrap();
1141        let refused = backend_only_refusal(&opts, &reg)
1142            .expect("a list of nothing but backend knobs must be refused");
1143        assert!(
1144            refused.contains("every option this run sets"),
1145            "the message is about the whole list, not one knob: {refused}",
1146        );
1147        assert!(
1148            !backend_warnings(&opts, &reg).is_empty(),
1149            "the per-family warning still describes the knob; the refusal is \
1150             what stops the run",
1151        );
1152
1153        opts.set_numeric_value("tol", 1e-8, true, false).unwrap();
1154        assert_eq!(
1155            backend_only_refusal(&opts, &reg),
1156            None,
1157            "one option pounce reads is enough content to protect",
1158        );
1159    }
1160
1161    /// The default gate applies to the refusal too: a list that spells
1162    /// out a backend knob's registered default has asked for nothing.
1163    #[test]
1164    fn a_backend_knob_at_its_default_is_not_refused() {
1165        let (mut opts, reg) = fixture();
1166        opts.set_string_value("ma97_order", "auto", true, false)
1167            .unwrap();
1168        assert_eq!(backend_only_refusal(&opts, &reg), None);
1169    }
1170
1171    /// `option_file_name` says where the options came from, not what to
1172    /// solve, so it does not count as content — otherwise pointing at a
1173    /// backend-only file would be permanently exempt from the refusal
1174    /// that file has earned.
1175    #[test]
1176    fn the_delivery_mechanism_is_not_content() {
1177        let (mut opts, reg) = fixture();
1178        opts.set_string_value("ma97_order", "metis", true, false)
1179            .unwrap();
1180        opts.set_string_value("option_file_name", "ipopt.opt", true, false)
1181            .unwrap();
1182        assert!(
1183            backend_only_refusal(&opts, &reg).is_some(),
1184            "naming the file that carried the knobs is not a reason to spare it",
1185        );
1186    }
1187
1188    /// Every name in [`DELIVERY_MECHANISM`] must actually be a
1189    /// registered option — a typo there would silently widen the
1190    /// exemption to nothing and narrow it to nothing at once.
1191    #[test]
1192    fn the_delivery_mechanism_names_are_registered() {
1193        let (_, reg) = fixture();
1194        for name in DELIVERY_MECHANISM {
1195            assert!(
1196                reg.get_option(name).is_some(),
1197                "`{name}` is not a registered option",
1198            );
1199            assert!(
1200                !is_backend_knob(name),
1201                "`{name}` is a backend knob; exempting it is meaningless",
1202            );
1203        }
1204    }
1205
1206    /// A backend knob warns and solves — it never refuses. Refusing
1207    /// would fail a portable `ipopt.opt` over a backend the run does not
1208    /// use, which is the compatibility the registry exists to provide.
1209    #[test]
1210    fn a_backend_knob_warns_but_does_not_refuse() {
1211        let (mut opts, reg) = fixture();
1212        opts.set_string_value("ma97_order", "metis", true, false)
1213            .unwrap();
1214        assert_eq!(
1215            refusal(&opts, &reg),
1216            None,
1217            "a backend knob must not block a solve",
1218        );
1219        let warnings = backend_warnings(&opts, &reg);
1220        assert_eq!(warnings.len(), 1, "{warnings:?}");
1221        let w = &warnings[0];
1222        assert!(w.contains("warning:"), "{w}");
1223        assert!(w.contains("`ma97_order`"), "{w}");
1224        assert!(w.contains("MA97"), "the backend must be named: {w}");
1225        assert!(w.contains("`ma97_*`"), "the family must be named: {w}");
1226        // The user has to be told the answer is not at risk, or a
1227        // warning naming a linear solver reads as "your factorization
1228        // may be wrong".
1229        assert!(w.contains("result is unaffected"), "{w}");
1230        assert!(w.contains("551"), "{w}");
1231    }
1232
1233    /// Explicitly writing a backend knob's registered default asks for
1234    /// nothing — the same gate the refusal table uses — so it must not
1235    /// even warn. A generated `ipopt.opt` spells defaults out.
1236    #[test]
1237    fn a_backend_knob_at_its_default_is_silent() {
1238        let (mut opts, reg) = fixture();
1239        // `ma97_order` defaults to "auto", `pardiso_msglvl` to 0.
1240        opts.set_string_value("ma97_order", "auto", true, false)
1241            .unwrap();
1242        opts.set_integer_value("pardiso_msglvl", 0, true, false)
1243            .unwrap();
1244        assert!(backend_warnings(&opts, &reg).is_empty());
1245    }
1246
1247    /// One line per backend family, not per option: an MA97-tuned
1248    /// `ipopt.opt` sets a dozen `ma97_*` knobs at once, and a dozen
1249    /// near-identical lines is noise the reader learns to skip — silence
1250    /// with extra steps. The one line names every knob it saw.
1251    #[test]
1252    fn the_warning_is_grouped_by_backend_family() {
1253        let (mut opts, reg) = fixture();
1254        opts.set_string_value("ma97_order", "metis", true, false)
1255            .unwrap();
1256        opts.set_numeric_value("ma97_u", 1e-4, true, false).unwrap();
1257        opts.set_string_value("ma97_scaling", "mc64", true, false)
1258            .unwrap();
1259        opts.set_integer_value("pardiso_msglvl", 1, true, false)
1260            .unwrap();
1261
1262        let warnings = backend_warnings(&opts, &reg);
1263        assert_eq!(
1264            warnings.len(),
1265            2,
1266            "one per family, not per option: {warnings:?}"
1267        );
1268        let ma97 = warnings.iter().find(|w| w.contains("MA97")).expect("MA97");
1269        for name in ["`ma97_order`", "`ma97_u`", "`ma97_scaling`"] {
1270            assert!(ma97.contains(name), "{ma97}");
1271        }
1272        assert!(ma97.contains("those 3 are ignored"), "{ma97}");
1273        assert!(
1274            warnings.iter().any(|w| w.contains("Pardiso")),
1275            "{warnings:?}",
1276        );
1277    }
1278
1279    /// `pardisolib` warns with its family rather than being refused like
1280    /// `hsllib`. The difference is that pounce *has* an HSL backend, so
1281    /// `hsllib` is a caller reaching for a solver pounce can run by a
1282    /// mechanism it lacks; there is no Pardiso here by any route.
1283    #[test]
1284    fn pardisolib_warns_with_the_pardiso_family() {
1285        let (mut opts, reg) = fixture();
1286        opts.set_string_value("pardisolib", "libpardiso600.so", true, false)
1287            .unwrap();
1288        assert_eq!(refusal(&opts, &reg), None);
1289        let warnings = backend_warnings(&opts, &reg);
1290        assert_eq!(warnings.len(), 1, "{warnings:?}");
1291        assert!(warnings[0].contains("`pardisolib`"), "{:?}", warnings[0]);
1292        assert!(warnings[0].contains("Pardiso"), "{:?}", warnings[0]);
1293
1294        // …while `hsllib` keeps its refusal.
1295        let (mut opts, reg) = fixture();
1296        opts.set_string_value("hsllib", "libcoinhsl.so", true, false)
1297            .unwrap();
1298        assert!(refusal(&opts, &reg).is_some());
1299    }
1300
1301    /// Explicitly writing a default is how a generated `ipopt.opt` looks;
1302    /// it asks for nothing and must not fail.
1303    #[test]
1304    fn explicitly_setting_the_default_is_not_refused() {
1305        let (mut opts, reg) = fixture();
1306        // `dependency_detector` defaults to "none"; `magic_steps` to "no".
1307        opts.set_string_value("dependency_detector", "none", true, false)
1308            .unwrap();
1309        opts.set_string_value("magic_steps", "no", true, false)
1310            .unwrap();
1311        assert_eq!(refusal(&opts, &reg), None);
1312    }
1313
1314    /// …but asking for the feature is refused, by name, with a pointer.
1315    #[test]
1316    fn requesting_an_unimplemented_feature_is_refused() {
1317        let (mut opts, reg) = fixture();
1318        opts.set_string_value("dependency_detector", "mumps", true, false)
1319            .unwrap();
1320        let msg = refusal(&opts, &reg).expect("must refuse");
1321        assert!(msg.contains("dependency_detector"), "{msg}");
1322        assert!(msg.contains("linear-dependency detection"), "{msg}");
1323        assert!(msg.contains("483"), "{msg}");
1324    }
1325
1326    /// Numeric knobs of an absent feature are refused the same way.
1327    #[test]
1328    fn a_numeric_knob_of_an_absent_feature_is_refused() {
1329        let (mut opts, reg) = fixture();
1330        opts.set_numeric_value("penalty_init_max", 42.0, true, false)
1331            .unwrap();
1332        let msg = refusal(&opts, &reg).expect("must refuse");
1333        assert!(msg.contains("CG-penalty"), "{msg}");
1334    }
1335
1336    /// gh#981: the *value* that selects the CG-penalty acceptor is
1337    /// refused too — it used to run the plain penalty acceptor under the
1338    /// `cg-penalty` name. The two implemented values stay accepted.
1339    #[test]
1340    fn line_search_method_cg_penalty_is_refused_but_penalty_is_not() {
1341        for ok in ["filter", "penalty"] {
1342            let (mut opts, _) = fixture();
1343            opts.set_string_value("line_search_method", ok, true, false)
1344                .unwrap();
1345            assert_eq!(value_refusal(&opts), None, "{ok} is implemented");
1346        }
1347        let (mut opts, _) = fixture();
1348        opts.set_string_value("line_search_method", "cg-penalty", true, false)
1349            .expect("upstream's value must still parse");
1350        let msg = value_refusal(&opts).expect("cg-penalty must be refused");
1351        assert!(msg.contains("line_search_method=cg-penalty"), "{msg}");
1352        assert!(msg.contains("Chen-Goldfarb"), "{msg}");
1353        assert!(msg.contains("issues/981"), "{msg}");
1354    }
1355
1356    /// The four constant-derivative hints left this table in gh #588 Q6.
1357    /// They must not block a solve — that was never in question — and
1358    /// they must no longer produce the "pounce does not exploit this"
1359    /// warning, because pounce now does. What they earn instead (a
1360    /// proof-backed refusal, or silent reuse) is asserted where it is
1361    /// decided: `pounce_nlp::constant_derivatives` and
1362    /// `pounce-cli/tests/unimplemented_options.rs`.
1363    #[test]
1364    fn the_constant_derivative_hints_are_no_longer_unexploited() {
1365        let (mut opts, reg) = fixture();
1366        opts.set_string_value("hessian_constant", "yes", true, false)
1367            .unwrap();
1368        assert_eq!(refusal(&opts, &reg), None, "a hint must not block a solve");
1369        assert!(
1370            hint_warnings(&opts, &reg).is_empty(),
1371            "`hessian_constant` is exploited now; the unexploited-hint \
1372             warning would contradict the reuse the solver actually does",
1373        );
1374        assert!(
1375            UNEXPLOITED_HINTS.is_empty(),
1376            "gh #588 Q6 emptied this table; an entry added back needs its \
1377             own warning text and a test that the option is really unused",
1378        );
1379    }
1380
1381    /// `fast_step_computation` was in the refusal table for one commit,
1382    /// added by hand against the membership rule above. It fails here if
1383    /// it ever comes back: `PdSearchDirCalc` owns the flag and consumes
1384    /// it at two sites, so refusing it would fail a solve pounce can
1385    /// serve. Its read site is wired in `algorithm_builder_from_options`.
1386    #[test]
1387    fn fast_step_computation_is_wired_not_refused() {
1388        let (mut opts, reg) = fixture();
1389        opts.set_string_value("fast_step_computation", "yes", true, false)
1390            .unwrap();
1391        assert_eq!(refusal(&opts, &reg), None);
1392
1393        let mut app = crate::application::IpoptApplication::new();
1394        app.initialize().unwrap();
1395        app.initialize_with_options_str("fast_step_computation yes\n")
1396            .unwrap();
1397        assert!(
1398            app.algorithm_builder_from_options().fast_step_computation,
1399            "the option must reach the builder, or wiring it changed nothing",
1400        );
1401        // …and the default is still off.
1402        let mut app = crate::application::IpoptApplication::new();
1403        app.initialize().unwrap();
1404        assert!(!app.algorithm_builder_from_options().fast_step_computation);
1405    }
1406
1407    /// `option_file_name` left the table when gh#518 implemented the
1408    /// feature it names. Refusing it *from the table* again would be a
1409    /// regression in the other direction: it now configures the run, so
1410    /// a user who sets it gets what they asked for rather than an error.
1411    #[test]
1412    fn option_file_name_is_implemented_not_in_the_table() {
1413        let (mut opts, reg) = fixture();
1414        opts.set_string_value("option_file_name", "tiny.opt", true, false)
1415            .unwrap();
1416        assert_eq!(refusal(&opts, &reg), None);
1417    }
1418
1419    /// …but leaving the table must not hand the option back its silence
1420    /// on the surfaces that still cannot honor it. Only
1421    /// `initialize_with_option_file` resolves it, and library callers
1422    /// (Python, the C interface, WASM) never call it — so there, setting
1423    /// the option is still refused, just by a different guard.
1424    #[test]
1425    fn option_file_name_is_refused_where_nothing_resolves_it() {
1426        let mut app = crate::application::IpoptApplication::new();
1427        app.initialize().unwrap();
1428        assert_eq!(app.unhonored_option_file_name(), None, "unset asks nothing");
1429
1430        app.initialize_with_options_str("option_file_name tiny.opt\n")
1431            .unwrap();
1432        let msg = app
1433            .unhonored_option_file_name()
1434            .expect("a library caller cannot honor it");
1435        assert!(msg.contains("tiny.opt"), "{msg}");
1436        assert!(msg.contains("does not read options files"), "{msg}");
1437        assert!(msg.contains("518"), "{msg}");
1438    }
1439
1440    /// The default gate applies here too: `option_file_name` defaults to
1441    /// `ipopt.opt`, so a caller replaying a full option dump sets that
1442    /// value while asking for nothing. Failing them would break the same
1443    /// compatibility the explicitly-set-default rule protects everywhere
1444    /// else.
1445    #[test]
1446    fn option_file_name_at_its_default_asks_nothing_of_a_library_caller() {
1447        let mut app = crate::application::IpoptApplication::new();
1448        app.initialize_with_options_str("option_file_name ipopt.opt\n")
1449            .unwrap();
1450        assert_eq!(app.unhonored_option_file_name(), None);
1451    }
1452
1453    /// On the CLI's path the option *is* resolved, so the guard stays
1454    /// quiet — including when the resolver finds no file to read, which
1455    /// still means the option was honored (there was nothing to read).
1456    #[test]
1457    fn the_guard_is_quiet_once_the_option_file_path_has_run() {
1458        let dir = std::env::temp_dir().join(format!("pounce_gh518_lib_{}", std::process::id()));
1459        std::fs::create_dir_all(&dir).unwrap();
1460        let path = dir.join("tiny.opt");
1461        std::fs::write(&path, "max_iter 5\n").unwrap();
1462
1463        let mut app = crate::application::IpoptApplication::new();
1464        app.initialize_with_option_file(Some(&path)).unwrap();
1465        assert_eq!(app.unhonored_option_file_name(), None);
1466        assert_eq!(
1467            app.options().get_integer_value("max_iter", "").unwrap(),
1468            (5, true),
1469            "the file must actually have been read",
1470        );
1471
1472        let _ = std::fs::remove_dir_all(&dir);
1473    }
1474
1475    /// The restoration switches wired in gh#483 / #191 round 2. Each
1476    /// field was already consumed by `RestoAlgorithmBuilder`; only the
1477    /// read site was missing, so setting the option did nothing. The
1478    /// assertion that matters is that the value *reaches the builder* —
1479    /// a read site populating a field nobody consumes would be a fresh
1480    /// silent no-op, the very defect this work removes.
1481    #[test]
1482    fn the_restoration_switches_reach_the_builder() {
1483        for (key, default_on) in [
1484            ("evaluate_orig_obj_at_resto_trial", true),
1485            ("expect_infeasible_problem", false),
1486            ("start_with_resto", false),
1487        ] {
1488            let mut app = crate::application::IpoptApplication::new();
1489            app.initialize().unwrap();
1490            let resto = app.algorithm_builder_from_options().resto;
1491            let got = match key {
1492                "evaluate_orig_obj_at_resto_trial" => resto.evaluate_orig_obj_at_resto_trial,
1493                "expect_infeasible_problem" => resto.expect_infeasible_problem,
1494                _ => resto.start_with_resto,
1495            };
1496            assert_eq!(got, default_on, "{key}: default changed");
1497
1498            // Flip it and check the flip lands.
1499            let flipped = if default_on { "no" } else { "yes" };
1500            let mut app = crate::application::IpoptApplication::new();
1501            app.initialize().unwrap();
1502            app.initialize_with_options_str(&format!("{key} {flipped}\n"))
1503                .unwrap();
1504            let resto = app.algorithm_builder_from_options().resto;
1505            let got = match key {
1506                "evaluate_orig_obj_at_resto_trial" => resto.evaluate_orig_obj_at_resto_trial,
1507                "expect_infeasible_problem" => resto.expect_infeasible_problem,
1508                _ => resto.start_with_resto,
1509            };
1510            assert_eq!(
1511                got, !default_on,
1512                "{key}={flipped} never reached the builder"
1513            );
1514        }
1515    }
1516
1517    /// `max_resto_iter` reaches the builder (#551 / #677). The cap it
1518    /// sets is enforced by `RestoConvCheckAdapter::maximum_resto_iters`,
1519    /// which `pounce-restoration` tests against this field; here we only
1520    /// pin the option → builder link and the default.
1521    #[test]
1522    fn max_resto_iter_reaches_the_builder() {
1523        let mut app = crate::application::IpoptApplication::new();
1524        app.initialize().unwrap();
1525        let b = app.algorithm_builder_from_options();
1526        // NOT the registered default (3000000). pounce has capped
1527        // successive restoration iterations at 3000 since the cap landed,
1528        // and wiring the option must not move that for anyone who did not
1529        // ask. See `RestoOptions::max_resto_iter`.
1530        assert_eq!(b.resto.max_resto_iter, 3000);
1531        let reg = registry();
1532        let registered = reg.get_option("max_resto_iter").expect("registered");
1533        assert!(
1534            matches!(registered.default, DefaultValue::Integer(3_000_000)),
1535            "the registry no longer declares 3000000 — if upstream's number \
1536             was adopted as the effective default, that is a trajectory \
1537             change and this test should be the one that says so",
1538        );
1539
1540        let mut app = crate::application::IpoptApplication::new();
1541        app.initialize().unwrap();
1542        app.initialize_with_options_str("max_resto_iter 17\n")
1543            .unwrap();
1544        assert_eq!(
1545            app.algorithm_builder_from_options().resto.max_resto_iter,
1546            17,
1547            "never reached the builder",
1548        );
1549    }
1550
1551    /// The four corrector knobs select `FilterLSAcceptor::TryCorrector`,
1552    /// which pounce does not have — no acceptor here takes a corrector
1553    /// trial. They were classified as missing read sites on the strength
1554    /// of pounce having *a* corrector (Mehrotra's, in the search-direction
1555    /// RHS, reached through `mehrotra_algorithm`); that is a different
1556    /// mechanism and a different option (#551 / #677).
1557    #[test]
1558    fn the_corrector_knobs_are_refused() {
1559        let (mut opts, reg) = fixture();
1560        opts.set_string_value("corrector_type", "affine", true, false)
1561            .unwrap();
1562        let msg = refusal(&opts, &reg).expect("must refuse");
1563        assert!(msg.contains("`corrector_type`"), "{msg}");
1564        assert!(msg.contains("TryCorrector"), "{msg}");
1565        assert!(msg.contains("mehrotra_algorithm"), "{msg}");
1566        assert!(msg.contains("551"), "{msg}");
1567
1568        for (name, value) in [
1569            ("skip_corr_if_neg_curv", "no"),
1570            ("skip_corr_in_monotone_mode", "no"),
1571        ] {
1572            let (mut opts, reg) = fixture();
1573            opts.set_string_value(name, value, true, false).unwrap();
1574            assert!(refusal(&opts, &reg).is_some(), "`{name}` must refuse");
1575        }
1576        let (mut opts, reg) = fixture();
1577        opts.set_numeric_value("corrector_compl_avrg_red_fact", 2.0, true, false)
1578            .unwrap();
1579        assert!(refusal(&opts, &reg).is_some());
1580
1581        // …and the default gate still holds: `corrector_type=none` is what
1582        // an untouched solve already does.
1583        let (mut opts, reg) = fixture();
1584        opts.set_string_value("corrector_type", "none", true, false)
1585            .unwrap();
1586        assert_eq!(refusal(&opts, &reg), None);
1587    }
1588
1589    /// The remaining three restoration/L-BFGS sub-capabilities. Each
1590    /// message must distinguish "the feature runs, this part of it does
1591    /// not" from "pounce does not implement this feature" — that is what
1592    /// the `advice` half is for, and a user who set one of these needs to
1593    /// know restoration itself is still doing its job.
1594    #[test]
1595    fn the_missing_restoration_sub_capabilities_are_refused() {
1596        let (mut opts, reg) = fixture();
1597        opts.set_numeric_value("expect_infeasible_problem_ctol", 1e-4, true, false)
1598            .unwrap();
1599        let msg = refusal(&opts, &reg).expect("must refuse");
1600        assert!(msg.contains("filter line search"), "{msg}");
1601        assert!(
1602            msg.contains("restoration phase itself runs"),
1603            "the message must say the parent feature is unaffected: {msg}",
1604        );
1605
1606        let (mut opts, reg) = fixture();
1607        opts.set_numeric_value("expect_infeasible_problem_ytol", 1e6, true, false)
1608            .unwrap();
1609        assert!(refusal(&opts, &reg).is_some());
1610
1611        let (mut opts, reg) = fixture();
1612        opts.set_string_value("limited_memory_special_for_resto", "yes", true, false)
1613            .unwrap();
1614        let msg = refusal(&opts, &reg).expect("must refuse");
1615        assert!(msg.contains("Nov 2010"), "{msg}");
1616        assert!(
1617            msg.contains("L-BFGS runs in the restoration sub-solve"),
1618            "{msg}",
1619        );
1620
1621        let (mut opts, reg) = fixture();
1622        opts.set_numeric_value("resto_failure_feasibility_threshold", 1e-6, true, false)
1623            .unwrap();
1624        let msg = refusal(&opts, &reg).expect("must refuse");
1625        assert!(msg.contains("restoration runs"), "{msg}");
1626        // The wired sibling is named as the thing that *does* bound a
1627        // restoration, so the message points somewhere real.
1628        assert!(msg.contains("max_resto_iter"), "{msg}");
1629
1630        // Defaults ask for nothing, on every one of them.
1631        let (mut opts, reg) = fixture();
1632        opts.set_numeric_value("expect_infeasible_problem_ctol", 1e-3, true, false)
1633            .unwrap();
1634        opts.set_string_value("limited_memory_special_for_resto", "no", true, false)
1635            .unwrap();
1636        opts.set_numeric_value("resto_failure_feasibility_threshold", 0.0, true, false)
1637            .unwrap();
1638        assert_eq!(refusal(&opts, &reg), None);
1639    }
1640
1641    /// The L-BFGS σ clamp, wired in gh#483 / #191 round 2.
1642    /// `LimMemQuasiNewtonUpdater` consumes both bounds in
1643    /// `initial_hessian_scalar`; only the read sites were missing. Note
1644    /// the fields are named `init_val_{max,min}`, not after the options —
1645    /// which is why a grep for the option name found nothing and the
1646    /// consumer had to be located by hand.
1647    #[test]
1648    fn the_lbfgs_sigma_clamp_reaches_the_builder() {
1649        let mut app = crate::application::IpoptApplication::new();
1650        app.initialize().unwrap();
1651        let b = app.algorithm_builder_from_options();
1652        assert_eq!(b.limited_memory_init_val_max, 1e8, "default changed");
1653        assert_eq!(b.limited_memory_init_val_min, 1e-8, "default changed");
1654
1655        let mut app = crate::application::IpoptApplication::new();
1656        app.initialize().unwrap();
1657        app.initialize_with_options_str(
1658            "limited_memory_init_val_max 5e5\nlimited_memory_init_val_min 1e-3\n",
1659        )
1660        .unwrap();
1661        let b = app.algorithm_builder_from_options();
1662        assert_eq!(
1663            b.limited_memory_init_val_max, 5e5,
1664            "never reached the builder"
1665        );
1666        assert_eq!(
1667            b.limited_memory_init_val_min, 1e-3,
1668            "never reached the builder"
1669        );
1670    }
1671
1672    /// Options whose *feature* runs and only whose read site is missing
1673    /// must stay out of the table — refusing them would fail solves that
1674    /// are correct today. This pins the boundary the triage drew.
1675    #[test]
1676    fn options_on_implemented_features_are_not_refused() {
1677        for (name, value) in [
1678            // restoration's successive-iteration cap, wired in #551 /
1679            // #677 round 3 — `RestoConvCheckAdapter::maximum_resto_iters`
1680            ("max_resto_iter", "17"),
1681            // the filter line search runs
1682            ("accept_after_max_steps", "3"),
1683            // L-BFGS runs
1684            ("limited_memory_max_skipping", "4"),
1685            // `PdSearchDirCalc` has the flag and consumes it; it was
1686            // briefly in the refusal table by hand, against the rule
1687            // above, which would have failed a solve it can serve.
1688            ("fast_step_computation", "yes"),
1689        ] {
1690            let (mut opts, reg) = fixture();
1691            // The table mixes string, integer and numeric options; try
1692            // each setter until one takes the value.
1693            let set = opts.set_string_value(name, value, true, false).is_ok()
1694                || value
1695                    .parse::<i32>()
1696                    .ok()
1697                    .is_some_and(|v| opts.set_integer_value(name, v, true, false).is_ok())
1698                || value
1699                    .parse::<f64>()
1700                    .ok()
1701                    .is_some_and(|v| opts.set_numeric_value(name, v, true, false).is_ok());
1702            assert!(set, "could not set `{name}` to `{value}`");
1703            assert_eq!(
1704                refusal(&opts, &reg),
1705                None,
1706                "`{name}` configures a feature pounce implements; it needs a \
1707                 read site, not a refusal",
1708            );
1709        }
1710    }
1711}