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}
454
455/// String-option values pounce does not implement. Refused when set.
456pub const UNIMPLEMENTED_VALUES: &[UnimplementedValue] = &[UnimplementedValue {
457    option: "bound_mult_init_method",
458    value: "mu-based",
459    feature: "initializing each bound multiplier to mu_init divided by its \
460              own slack",
461    advice: "`bound_mult_init_method=constant` (the default) initializes them \
462             all to `bound_mult_init_val`, which you can set",
463}];
464
465/// Options that *are* honored in the sense that matters — the answer is
466/// unaffected — but whose performance hint pounce does not exploit.
467/// These warn rather than fail: refusing them would stop a solve that
468/// returns the right result today, only a little slower.
469///
470/// **Empty since gh #588 phase Q6.** The four constant-derivative hints
471/// (`grad_f_constant`, `hessian_constant`, `jac_c_constant`,
472/// `jac_d_constant`) lived here and are now exploited:
473/// [`pounce_nlp::constant_derivatives`] reconciles each one against what
474/// the model can prove about its own algebra, and
475/// `OrigIpoptNlp` reuses the derivative across iterates when the answer
476/// is yes. A hint the model *disproves* still produces a warning — but a
477/// louder one, from that module, saying the hint was refused rather than
478/// merely unused.
479///
480/// The table stays because the shape is right for the next hint that
481/// arrives registered-but-unexploited, and because
482/// [`hint_warnings`] and its two membership tests are the mechanism that
483/// keeps such an option from going silent again.
484pub const UNEXPLOITED_HINTS: &[&str] = &[];
485
486/// The same four hints again — on the **convex** route, where they are
487/// still unexploited.
488///
489/// [`UNEXPLOITED_HINTS`] is empty because the NLP path exploits them. The
490/// convex path does not: an LP/QP/SOCP routed to `pounce-convex` never
491/// builds an `OrigIpoptNlp` and so never reaches
492/// `IpoptApplication::install_constant_derivative_hints`, which is where
493/// the reuse is decided. Emptying the table above therefore silenced the
494/// warning on *both* routes when only one of them had earned it — a
495/// registered option going quiet without anyone deciding it should, which
496/// is the failure mode this module exists to remove (gh#483).
497///
498/// A warning and not a refusal, for the same reason as above and one
499/// more: on a convex engine these hints are not merely unexploited but
500/// structurally vacuous. The engine is handed constant `P`/`A` matrices
501/// to begin with, so there is no per-iterate derivative to cache and
502/// asserting that there is changes nothing about the answer.
503pub const CONVEX_UNEXPLOITED_HINTS: &[&str] = &pounce_nlp::constant_derivatives::HINT_OPTIONS;
504
505/// One linear-solver backend pounce does not implement, and the
506/// registered options that tune it.
507///
508/// Separate from [`UnimplementedFeature`] because the disposition is
509/// different: these *warn* and solve, they never refuse. See "Backend
510/// knobs warn, they do not refuse" in the module header for why.
511pub struct UnimplementedBackend {
512    /// Named in the warning, e.g. "the HSL MA97 sparse symmetric linear
513    /// solver".
514    pub backend: &'static str,
515    /// The registered prefix the family shares, quoted in the warning so
516    /// the user learns the whole group is inert, not just the one option
517    /// they happened to set.
518    pub family: &'static str,
519    /// Every registered option of this backend. Complete per family —
520    /// `backend_families_are_complete` fails if the registry grows one
521    /// that is missing here, which would hand it back its silence.
522    pub options: &'static [&'static str],
523}
524
525/// Every linear-solver backend pounce does not implement, with the
526/// options that tune it. Warned about when set; never refused.
527pub const UNIMPLEMENTED_BACKENDS: &[UnimplementedBackend] = &[
528    UnimplementedBackend {
529        backend: "the HSL MA27 sparse symmetric linear solver",
530        family: "ma27_*",
531        options: &[
532            "ma27_ignore_singularity",
533            "ma27_la_init_factor",
534            "ma27_liw_init_factor",
535            "ma27_meminc_factor",
536            "ma27_pivtol",
537            "ma27_pivtolmax",
538            "ma27_print_level",
539            "ma27_skip_inertia_check",
540        ],
541    },
542    UnimplementedBackend {
543        backend: "the HSL MA77 out-of-core sparse symmetric linear solver",
544        family: "ma77_*",
545        options: &[
546            "ma77_buffer_lpage",
547            "ma77_buffer_npage",
548            "ma77_file_size",
549            "ma77_maxstore",
550            "ma77_nemin",
551            "ma77_order",
552            "ma77_print_level",
553            "ma77_small",
554            "ma77_static",
555            "ma77_u",
556            "ma77_umax",
557        ],
558    },
559    UnimplementedBackend {
560        backend: "the HSL MA86 parallel sparse symmetric linear solver",
561        family: "ma86_*",
562        options: &[
563            "ma86_nemin",
564            "ma86_order",
565            "ma86_print_level",
566            "ma86_scaling",
567            "ma86_small",
568            "ma86_static",
569            "ma86_u",
570            "ma86_umax",
571        ],
572    },
573    UnimplementedBackend {
574        backend: "the HSL MA97 sparse symmetric linear solver",
575        family: "ma97_*",
576        options: &[
577            "ma97_dump_matrix",
578            "ma97_nemin",
579            "ma97_order",
580            "ma97_print_level",
581            "ma97_scaling",
582            "ma97_scaling1",
583            "ma97_scaling2",
584            "ma97_scaling3",
585            "ma97_small",
586            "ma97_solve_blas3",
587            "ma97_switch1",
588            "ma97_switch2",
589            "ma97_switch3",
590            "ma97_u",
591            "ma97_umax",
592        ],
593    },
594    UnimplementedBackend {
595        backend: "the MUMPS sparse symmetric linear solver",
596        family: "mumps_*",
597        options: &[
598            "mumps_dep_tol",
599            "mumps_mem_percent",
600            "mumps_mpi_communicator",
601            "mumps_permuting_scaling",
602            "mumps_pivot_order",
603            "mumps_pivtol",
604            "mumps_pivtolmax",
605            "mumps_print_level",
606            "mumps_scaling",
607        ],
608    },
609    UnimplementedBackend {
610        backend: "the Pardiso linear solver (pardiso-project.org)",
611        family: "pardiso_*",
612        options: &[
613            "pardiso_iter_coarse_size",
614            "pardiso_iter_dropping_factor",
615            "pardiso_iter_dropping_schur",
616            "pardiso_iter_inverse_norm_factor",
617            "pardiso_iter_max_levels",
618            "pardiso_iter_max_row_fill",
619            "pardiso_iter_relative_tol",
620            "pardiso_iterative",
621            "pardiso_matching_strategy",
622            "pardiso_max_droptol_corrections",
623            "pardiso_max_iter",
624            "pardiso_max_iterative_refinement_steps",
625            "pardiso_msglvl",
626            "pardiso_order",
627            "pardiso_redo_symbolic_fact_only_if_inertia_wrong",
628            "pardiso_repeated_perturbation_means_singular",
629            "pardiso_skip_inertia_check",
630            "pardisolib",
631        ],
632    },
633    UnimplementedBackend {
634        backend: "the Pardiso linear solver bundled with Intel MKL",
635        family: "pardisomkl_*",
636        options: &[
637            "pardisomkl_matching_strategy",
638            "pardisomkl_max_iterative_refinement_steps",
639            "pardisomkl_msglvl",
640            "pardisomkl_order",
641            "pardisomkl_redo_symbolic_fact_only_if_inertia_wrong",
642            "pardisomkl_repeated_perturbation_means_singular",
643            "pardisomkl_skip_inertia_check",
644        ],
645    },
646    UnimplementedBackend {
647        backend: "the SPRAL SSIDS sparse symmetric linear solver",
648        family: "spral_*",
649        options: &[
650            "spral_cpu_block_size",
651            "spral_gpu_perf_coeff",
652            "spral_ignore_numa",
653            "spral_max_load_inbalance",
654            "spral_min_gpu_work",
655            "spral_nemin",
656            "spral_order",
657            "spral_pivot_method",
658            "spral_print_level",
659            "spral_scaling",
660            "spral_scaling_1",
661            "spral_scaling_2",
662            "spral_scaling_3",
663            "spral_small",
664            "spral_small_subtree_threshold",
665            "spral_switch_1",
666            "spral_switch_2",
667            "spral_switch_3",
668            "spral_u",
669            "spral_umax",
670            "spral_use_gpu",
671        ],
672    },
673    UnimplementedBackend {
674        backend: "the WSMP sparse symmetric linear solver",
675        family: "wsmp_*",
676        options: &[
677            "wsmp_inexact_droptol",
678            "wsmp_inexact_fillin_limit",
679            "wsmp_iterative",
680            "wsmp_max_iter",
681            "wsmp_no_pivoting",
682            "wsmp_num_threads",
683            "wsmp_ordering_option",
684            "wsmp_ordering_option2",
685            "wsmp_pivtol",
686            "wsmp_pivtolmax",
687            "wsmp_scaling",
688            "wsmp_singularity_threshold",
689            "wsmp_skip_inertia_check",
690            "wsmp_write_matrix_iteration",
691        ],
692    },
693];
694
695/// Warnings for backend knobs the caller set. Never blocks a solve, and
696/// emits at most one line per backend family: an `ipopt.opt` tuned for
697/// MA97 sets a dozen `ma97_*` knobs at once, and a dozen near-identical
698/// lines would be noise the reader learns to skip.
699///
700/// Same default gate as everything else here — an `ipopt.opt` that
701/// spells out `ma97_u 1e-8` (the registered default) asks for nothing
702/// and gets nothing said about it.
703pub fn backend_warnings(options: &OptionsList, reg: &RegisteredOptions) -> Vec<String> {
704    UNIMPLEMENTED_BACKENDS
705        .iter()
706        .filter_map(|group| {
707            let set: Vec<&str> = group
708                .options
709                .iter()
710                .copied()
711                .filter(|name| set_to_a_non_default(options, reg, name))
712                .collect();
713            if set.is_empty() {
714                return None;
715            }
716            let named = set
717                .iter()
718                .map(|n| format!("`{n}`"))
719                .collect::<Vec<_>>()
720                .join(", ");
721            let (verb, ignored, registered) = if set.len() == 1 {
722                ("configures", "it is ignored".to_string(), "The name is")
723            } else {
724                (
725                    "configure",
726                    format!("those {} are ignored", set.len()),
727                    "The names are",
728                )
729            };
730            Some(format!(
731                "pounce: warning: {named} {verb} {}, which pounce does not \
732                 implement, so {ignored} — as is every other `{}` option. \
733                 pounce factors the KKT system with `feral` (pure Rust, the \
734                 default) or MA57 (`linear_solver=ma57`, in a `--features \
735                 ma57` build); no setting written for another backend \
736                 transfers to either. {registered} registered so an \
737                 `ipopt.opt` written for Ipopt still parses unchanged — which \
738                 is why this is a warning and not an error: the solve runs, \
739                 and its result is unaffected. Tracking issue: \
740                 https://github.com/jkitchin/pounce/issues/551",
741                group.backend, group.family,
742            ))
743        })
744        .collect()
745}
746
747/// Names that say where the options came from, not what to solve.
748///
749/// `option_file_name` is the mechanism that delivered the rest of the
750/// list, so counting it as content would mean "the file you pointed me
751/// at configures nothing" is exactly the case that never fires — and
752/// pointing at a file is one of the two normal ways to supply one.
753/// Contrast `print_level`, which is deliberately *not* here: a caller
754/// who raises it asked for something and got it.
755const DELIVERY_MECHANISM: &[&str] = &["option_file_name"];
756
757/// Every backend-knob name in [`UNIMPLEMENTED_BACKENDS`].
758fn is_backend_knob(name: &str) -> bool {
759    UNIMPLEMENTED_BACKENDS
760        .iter()
761        .any(|group| group.options.contains(&name))
762}
763
764/// The refusal for a run whose options configure *nothing but* backends
765/// pounce does not ship, or `None`.
766///
767/// This is the boundary of the warn-don't-refuse rule above, not an
768/// exception to it. That rule rests on one premise: the file has other
769/// business here, and failing it over `ma97_order` would reject a run
770/// the caller wanted for the sake of a knob it never touches. When the
771/// backend knobs are *all* that is there, the premise is gone — nothing
772/// in the file survives, so there is no working run left to protect.
773/// Warning and solving anyway answers a request to tune the linear
774/// solver by tuning nothing and reporting success, which is the shape
775/// of gh#677 rather than a fix for it.
776///
777/// Two gates, and both are needed:
778///
779/// 1. **Something was actually asked for** — at least one backend knob
780///    is set to a non-default. A file that spells out `ma97_u 1e-8`
781///    (the registered default) asks for nothing and still gets nothing
782///    said about it, exactly as `a_backend_knob_at_its_default_is_silent`
783///    requires.
784/// 2. **Nothing else was mentioned at all.** The test is *presence* in
785///    the options list, not `set_to_a_non_default`: a caller who writes
786///    `tol 1e-8` has stated a real intention about this solve even
787///    when `1e-8` is the default, and a file with real content in it is
788///    the portable-`ipopt.opt` case the warning exists for. Presence is
789///    also the safe direction to be wrong in — it can only make this
790///    refusal rarer. [`DELIVERY_MECHANISM`] is the one exemption, and
791///    it exists so that pointing at a backend-only file with
792///    `option_file_name` is not permanently exempt from the refusal
793///    that file has earned.
794///
795/// Note that a file selecting the backend it tunes never reaches here:
796/// `linear_solver=ma97` is refused before this, by
797/// [`crate::application::IpoptApplication::unimplemented_linear_solver`].
798/// What lands here is the file that tunes MA97 without ever saying so.
799pub fn backend_only_refusal(options: &OptionsList, reg: &RegisteredOptions) -> Option<String> {
800    let asked_for_something = UNIMPLEMENTED_BACKENDS.iter().any(|group| {
801        group
802            .options
803            .iter()
804            .any(|name| set_to_a_non_default(options, reg, name))
805    });
806    if !asked_for_something {
807        return None;
808    }
809    let mentions_something_real = options
810        .names()
811        .any(|name| !is_backend_knob(name) && !DELIVERY_MECHANISM.contains(&name));
812    if mentions_something_real {
813        return None;
814    }
815    Some(
816        "pounce: error: every option this run sets configures a linear-solver \
817         backend pounce does not implement, so there is nothing left for it to \
818         act on. pounce factors the KKT system with `feral` (pure Rust, the \
819         default) or MA57 (`linear_solver=ma57`, in a `--features ma57` \
820         build); no setting written for another backend transfers to either. \
821         These names are registered so an `ipopt.opt` written for Ipopt still \
822         parses unchanged, and a file that also carries options pounce reads \
823         is warned about rather than refused — but a file that carries only \
824         these would run as if it had configured the solver when it \
825         configured nothing. Set `linear_solver=feral` (or `ma57`) if the \
826         defaults are what you want. Tracking issue: \
827         https://github.com/jkitchin/pounce/issues/551"
828            .to_string(),
829    )
830}
831
832/// An option set to something the registry says is not its default.
833///
834/// Both halves matter. `found` alone would fire on an `ipopt.opt` that
835/// spells out a default; comparing values alone would fire on nothing,
836/// since an unset option *reads back* as its default.
837pub(crate) fn set_to_a_non_default(
838    options: &OptionsList,
839    reg: &RegisteredOptions,
840    name: &str,
841) -> bool {
842    let Some(opt) = reg.get_option(name) else {
843        return false;
844    };
845    match &opt.default {
846        // Bools are registered as `yes`/`no` string options, so this arm
847        // covers them too.
848        DefaultValue::String(d) => {
849            matches!(options.get_string_value(name, ""), Ok((v, true)) if !v.eq_ignore_ascii_case(d))
850        }
851        DefaultValue::Number(d) => {
852            matches!(options.get_numeric_value(name, ""), Ok((v, true)) if v != *d)
853        }
854        DefaultValue::Integer(d) => {
855            matches!(options.get_integer_value(name, ""), Ok((v, true)) if v != *d)
856        }
857        DefaultValue::None => false,
858    }
859}
860
861/// The first unimplemented-feature option the caller set, with the
862/// message it earns. `None` when nothing in the table was touched.
863pub fn refusal(options: &OptionsList, reg: &RegisteredOptions) -> Option<String> {
864    for group in UNIMPLEMENTED_FEATURES {
865        for name in group.options {
866            if set_to_a_non_default(options, reg, name) {
867                let advice = if group.advice.is_empty() {
868                    String::new()
869                } else {
870                    format!(" Instead: {}.", group.advice)
871                };
872                return Some(format!(
873                    "pounce: `{name}` configures {}, which pounce does not \
874                     implement. It is registered so an ipopt.opt written for \
875                     Ipopt still parses, but setting it used to do nothing at \
876                     all — silently — so it is refused instead.{advice} \
877                     Remove it to run. Tracking issue: \
878                     https://github.com/jkitchin/pounce/issues/{}",
879                    group.feature, group.issue
880                ));
881            }
882        }
883    }
884    None
885}
886
887/// The first unimplemented *value* the caller selected, with the message
888/// it earns. `None` when every string option holds a mode pounce runs.
889///
890/// No default gate here, unlike [`refusal`]: a value that equals the
891/// registered default is by construction the implemented one, so it
892/// never reaches the table.
893pub fn value_refusal(options: &OptionsList) -> Option<String> {
894    for entry in UNIMPLEMENTED_VALUES {
895        let selected = matches!(
896            options.get_string_value(entry.option, ""),
897            Ok((v, true)) if v.eq_ignore_ascii_case(entry.value)
898        );
899        if !selected {
900            continue;
901        }
902        let advice = if entry.advice.is_empty() {
903            String::new()
904        } else {
905            format!(" Instead: {}.", entry.advice)
906        };
907        return Some(format!(
908            "pounce: `{}={}` selects {}, which pounce does not implement. The \
909             value is registered so an ipopt.opt written for Ipopt still \
910             parses; falling back to another mode would silently run a \
911             different initialization than the one you asked for, so it is \
912             refused instead.{advice} Tracking issue: \
913             https://github.com/jkitchin/pounce/issues/604",
914            entry.option, entry.value, entry.feature,
915        ));
916    }
917    None
918}
919
920/// Warnings for hints pounce does not exploit. Never blocks a solve.
921pub fn hint_warnings(options: &OptionsList, reg: &RegisteredOptions) -> Vec<String> {
922    UNEXPLOITED_HINTS
923        .iter()
924        .filter(|name| set_to_a_non_default(options, reg, name))
925        .map(|name| {
926            format!(
927                "pounce: warning: `{name}` is a caching hint pounce does not \
928                 exploit — it re-evaluates each iteration regardless. Your \
929                 answer is unaffected; only the evaluation count is. \
930                 (gh#483)"
931            )
932        })
933        .collect()
934}
935
936/// Warnings for the constant-derivative hints on the convex route, where
937/// — unlike the NLP route — nothing reads them. Never blocks a solve.
938/// Call this only when the convex dispatch is actually taken; on the NLP
939/// route the same options are honoured, and warning there would
940/// contradict the reuse the solver really does.
941pub fn convex_hint_warnings(options: &OptionsList, reg: &RegisteredOptions) -> Vec<String> {
942    CONVEX_UNEXPLOITED_HINTS
943        .iter()
944        .filter(|name| set_to_a_non_default(options, reg, name))
945        .map(|name| {
946            format!(
947                "pounce: warning: `{name}` asserts a derivative is constant \
948                 across iterates, which the NLP path reconciles against the \
949                 model and exploits — but this problem routed to \
950                 pounce-convex, whose LP/QP/SOCP engines are handed constant \
951                 matrices to begin with and do not read the option at all. \
952                 Your answer is unaffected. Use `solver_selection=nlp` if you \
953                 wanted the path that acts on it. (gh#483, gh#588)"
954            )
955        })
956        .collect()
957}
958
959#[cfg(test)]
960mod tests {
961    use super::*;
962    use std::collections::BTreeSet;
963
964    fn registry() -> std::rc::Rc<RegisteredOptions> {
965        let r = RegisteredOptions::new();
966        crate::upstream_options::register_all_upstream_options(&r).expect("register");
967        r
968    }
969
970    /// A fresh options list over the shared registry, plus a handle on
971    /// the registry itself for the default lookups.
972    fn fixture() -> (OptionsList, std::rc::Rc<RegisteredOptions>) {
973        let reg = registry();
974        (OptionsList::with_registered(std::rc::Rc::clone(&reg)), reg)
975    }
976
977    /// Every name in the table must actually be registered — a typo
978    /// would make its entry dead code that silently never fires, which
979    /// is the exact failure mode this module exists to remove.
980    #[test]
981    fn every_listed_option_is_registered() {
982        let (_, reg) = fixture();
983        for group in UNIMPLEMENTED_FEATURES {
984            for name in group.options {
985                assert!(
986                    reg.get_option(name).is_some(),
987                    "`{name}` is in the refusal table but is not registered",
988                );
989            }
990        }
991        for name in UNEXPLOITED_HINTS.iter().chain(CONVEX_UNEXPLOITED_HINTS) {
992            assert!(
993                reg.get_option(name).is_some(),
994                "`{name}` is in a hint table but is not registered",
995            );
996        }
997        for group in UNIMPLEMENTED_BACKENDS {
998            for name in group.options {
999                assert!(
1000                    reg.get_option(name).is_some(),
1001                    "`{name}` is in the backend table but is not registered",
1002                );
1003            }
1004        }
1005    }
1006
1007    /// The backend groups must cover their families *completely*. A
1008    /// `ma97_*` option registered later and not added here would be
1009    /// silent again — the exact defect this table removes — and it would
1010    /// slip past `no_silent_options.rs` too, which only asks whether a
1011    /// name is declared somewhere, not whether the family is whole.
1012    #[test]
1013    fn backend_families_are_complete() {
1014        let (_, reg) = fixture();
1015        for group in UNIMPLEMENTED_BACKENDS {
1016            let prefix = group
1017                .family
1018                .strip_suffix('*')
1019                .expect("family is a prefix glob, e.g. `ma97_*`");
1020            for opt in reg.registered_options_in_order() {
1021                if !opt.name.starts_with(prefix) {
1022                    continue;
1023                }
1024                assert!(
1025                    group.options.contains(&opt.name.as_str()),
1026                    "`{}` is registered and matches `{}` but is missing from \
1027                     the {} group, so setting it would still be silent",
1028                    opt.name,
1029                    group.family,
1030                    group.backend,
1031                );
1032            }
1033        }
1034    }
1035
1036    /// A hint must never also be a refusal: refusing beats warning, so an
1037    /// option in both tables would fail the solve *and* warn about it.
1038    /// The two hint tables are allowed to differ from each other — they
1039    /// describe two different routes — but neither may collide with the
1040    /// features table.
1041    #[test]
1042    fn the_convex_hint_table_does_not_collide_with_a_refusal() {
1043        let refused: BTreeSet<&str> = UNIMPLEMENTED_FEATURES
1044            .iter()
1045            .flat_map(|g| g.options.iter().copied())
1046            .collect();
1047        for name in CONVEX_UNEXPLOITED_HINTS {
1048            assert!(
1049                !refused.contains(name),
1050                "`{name}` both warns on the convex route and is refused",
1051            );
1052        }
1053    }
1054
1055    /// The convex route never reaches `install_constant_derivative_hints`,
1056    /// so the hint that the NLP path acts on does nothing there. It has to
1057    /// say so: gh #588 Q6 emptied [`UNEXPLOITED_HINTS`] for the NLP route
1058    /// and took the convex route's warning down with it.
1059    #[test]
1060    fn the_convex_route_still_says_the_hints_do_nothing_there() {
1061        let (mut opts, reg) = fixture();
1062        assert!(
1063            convex_hint_warnings(&opts, &reg).is_empty(),
1064            "an unset hint must not warn"
1065        );
1066        opts.set_string_value("hessian_constant", "yes", true, false)
1067            .unwrap();
1068        let w = convex_hint_warnings(&opts, &reg);
1069        assert_eq!(w.len(), 1, "{w:?}");
1070        assert!(w[0].contains("hessian_constant"), "{}", w[0]);
1071        assert!(w[0].contains("pounce-convex"), "{}", w[0]);
1072        assert_eq!(
1073            refusal(&opts, &reg),
1074            None,
1075            "a hint must not block a convex solve either"
1076        );
1077    }
1078
1079    /// No option may appear twice — once in two feature groups, or in
1080    /// both tables — or the message a user gets would depend on table
1081    /// order.
1082    #[test]
1083    fn the_tables_do_not_overlap() {
1084        let mut seen = BTreeSet::new();
1085        for name in UNIMPLEMENTED_FEATURES
1086            .iter()
1087            .flat_map(|g| g.options.iter())
1088            .chain(UNEXPLOITED_HINTS.iter())
1089            .chain(UNIMPLEMENTED_BACKENDS.iter().flat_map(|g| g.options.iter()))
1090        {
1091            assert!(seen.insert(*name), "`{name}` is listed twice");
1092        }
1093    }
1094
1095    /// A pristine options list touches nothing.
1096    #[test]
1097    fn defaults_are_not_refused() {
1098        let (opts, reg) = fixture();
1099        assert_eq!(refusal(&opts, &reg), None);
1100        assert!(hint_warnings(&opts, &reg).is_empty());
1101        assert!(
1102            backend_warnings(&opts, &reg).is_empty(),
1103            "a default run must stay silent",
1104        );
1105        assert_eq!(
1106            backend_only_refusal(&opts, &reg),
1107            None,
1108            "an empty list sets no backend knob, so there is nothing to refuse",
1109        );
1110    }
1111
1112    /// Backend knobs with nothing else in the list are refused; adding
1113    /// one option pounce reads turns the same knobs back into a
1114    /// warning. This is the whole rule, in one test.
1115    #[test]
1116    fn backend_only_is_refused_but_a_mixed_list_is_not() {
1117        let (mut opts, reg) = fixture();
1118        opts.set_string_value("ma97_order", "metis", true, false)
1119            .unwrap();
1120        let refused = backend_only_refusal(&opts, &reg)
1121            .expect("a list of nothing but backend knobs must be refused");
1122        assert!(
1123            refused.contains("every option this run sets"),
1124            "the message is about the whole list, not one knob: {refused}",
1125        );
1126        assert!(
1127            !backend_warnings(&opts, &reg).is_empty(),
1128            "the per-family warning still describes the knob; the refusal is \
1129             what stops the run",
1130        );
1131
1132        opts.set_numeric_value("tol", 1e-8, true, false).unwrap();
1133        assert_eq!(
1134            backend_only_refusal(&opts, &reg),
1135            None,
1136            "one option pounce reads is enough content to protect",
1137        );
1138    }
1139
1140    /// The default gate applies to the refusal too: a list that spells
1141    /// out a backend knob's registered default has asked for nothing.
1142    #[test]
1143    fn a_backend_knob_at_its_default_is_not_refused() {
1144        let (mut opts, reg) = fixture();
1145        opts.set_string_value("ma97_order", "auto", true, false)
1146            .unwrap();
1147        assert_eq!(backend_only_refusal(&opts, &reg), None);
1148    }
1149
1150    /// `option_file_name` says where the options came from, not what to
1151    /// solve, so it does not count as content — otherwise pointing at a
1152    /// backend-only file would be permanently exempt from the refusal
1153    /// that file has earned.
1154    #[test]
1155    fn the_delivery_mechanism_is_not_content() {
1156        let (mut opts, reg) = fixture();
1157        opts.set_string_value("ma97_order", "metis", true, false)
1158            .unwrap();
1159        opts.set_string_value("option_file_name", "ipopt.opt", true, false)
1160            .unwrap();
1161        assert!(
1162            backend_only_refusal(&opts, &reg).is_some(),
1163            "naming the file that carried the knobs is not a reason to spare it",
1164        );
1165    }
1166
1167    /// Every name in [`DELIVERY_MECHANISM`] must actually be a
1168    /// registered option — a typo there would silently widen the
1169    /// exemption to nothing and narrow it to nothing at once.
1170    #[test]
1171    fn the_delivery_mechanism_names_are_registered() {
1172        let (_, reg) = fixture();
1173        for name in DELIVERY_MECHANISM {
1174            assert!(
1175                reg.get_option(name).is_some(),
1176                "`{name}` is not a registered option",
1177            );
1178            assert!(
1179                !is_backend_knob(name),
1180                "`{name}` is a backend knob; exempting it is meaningless",
1181            );
1182        }
1183    }
1184
1185    /// A backend knob warns and solves — it never refuses. Refusing
1186    /// would fail a portable `ipopt.opt` over a backend the run does not
1187    /// use, which is the compatibility the registry exists to provide.
1188    #[test]
1189    fn a_backend_knob_warns_but_does_not_refuse() {
1190        let (mut opts, reg) = fixture();
1191        opts.set_string_value("ma97_order", "metis", true, false)
1192            .unwrap();
1193        assert_eq!(
1194            refusal(&opts, &reg),
1195            None,
1196            "a backend knob must not block a solve",
1197        );
1198        let warnings = backend_warnings(&opts, &reg);
1199        assert_eq!(warnings.len(), 1, "{warnings:?}");
1200        let w = &warnings[0];
1201        assert!(w.contains("warning:"), "{w}");
1202        assert!(w.contains("`ma97_order`"), "{w}");
1203        assert!(w.contains("MA97"), "the backend must be named: {w}");
1204        assert!(w.contains("`ma97_*`"), "the family must be named: {w}");
1205        // The user has to be told the answer is not at risk, or a
1206        // warning naming a linear solver reads as "your factorization
1207        // may be wrong".
1208        assert!(w.contains("result is unaffected"), "{w}");
1209        assert!(w.contains("551"), "{w}");
1210    }
1211
1212    /// Explicitly writing a backend knob's registered default asks for
1213    /// nothing — the same gate the refusal table uses — so it must not
1214    /// even warn. A generated `ipopt.opt` spells defaults out.
1215    #[test]
1216    fn a_backend_knob_at_its_default_is_silent() {
1217        let (mut opts, reg) = fixture();
1218        // `ma97_order` defaults to "auto", `pardiso_msglvl` to 0.
1219        opts.set_string_value("ma97_order", "auto", true, false)
1220            .unwrap();
1221        opts.set_integer_value("pardiso_msglvl", 0, true, false)
1222            .unwrap();
1223        assert!(backend_warnings(&opts, &reg).is_empty());
1224    }
1225
1226    /// One line per backend family, not per option: an MA97-tuned
1227    /// `ipopt.opt` sets a dozen `ma97_*` knobs at once, and a dozen
1228    /// near-identical lines is noise the reader learns to skip — silence
1229    /// with extra steps. The one line names every knob it saw.
1230    #[test]
1231    fn the_warning_is_grouped_by_backend_family() {
1232        let (mut opts, reg) = fixture();
1233        opts.set_string_value("ma97_order", "metis", true, false)
1234            .unwrap();
1235        opts.set_numeric_value("ma97_u", 1e-4, true, false).unwrap();
1236        opts.set_string_value("ma97_scaling", "mc64", true, false)
1237            .unwrap();
1238        opts.set_integer_value("pardiso_msglvl", 1, true, false)
1239            .unwrap();
1240
1241        let warnings = backend_warnings(&opts, &reg);
1242        assert_eq!(
1243            warnings.len(),
1244            2,
1245            "one per family, not per option: {warnings:?}"
1246        );
1247        let ma97 = warnings.iter().find(|w| w.contains("MA97")).expect("MA97");
1248        for name in ["`ma97_order`", "`ma97_u`", "`ma97_scaling`"] {
1249            assert!(ma97.contains(name), "{ma97}");
1250        }
1251        assert!(ma97.contains("those 3 are ignored"), "{ma97}");
1252        assert!(
1253            warnings.iter().any(|w| w.contains("Pardiso")),
1254            "{warnings:?}",
1255        );
1256    }
1257
1258    /// `pardisolib` warns with its family rather than being refused like
1259    /// `hsllib`. The difference is that pounce *has* an HSL backend, so
1260    /// `hsllib` is a caller reaching for a solver pounce can run by a
1261    /// mechanism it lacks; there is no Pardiso here by any route.
1262    #[test]
1263    fn pardisolib_warns_with_the_pardiso_family() {
1264        let (mut opts, reg) = fixture();
1265        opts.set_string_value("pardisolib", "libpardiso600.so", true, false)
1266            .unwrap();
1267        assert_eq!(refusal(&opts, &reg), None);
1268        let warnings = backend_warnings(&opts, &reg);
1269        assert_eq!(warnings.len(), 1, "{warnings:?}");
1270        assert!(warnings[0].contains("`pardisolib`"), "{:?}", warnings[0]);
1271        assert!(warnings[0].contains("Pardiso"), "{:?}", warnings[0]);
1272
1273        // …while `hsllib` keeps its refusal.
1274        let (mut opts, reg) = fixture();
1275        opts.set_string_value("hsllib", "libcoinhsl.so", true, false)
1276            .unwrap();
1277        assert!(refusal(&opts, &reg).is_some());
1278    }
1279
1280    /// Explicitly writing a default is how a generated `ipopt.opt` looks;
1281    /// it asks for nothing and must not fail.
1282    #[test]
1283    fn explicitly_setting_the_default_is_not_refused() {
1284        let (mut opts, reg) = fixture();
1285        // `dependency_detector` defaults to "none"; `magic_steps` to "no".
1286        opts.set_string_value("dependency_detector", "none", true, false)
1287            .unwrap();
1288        opts.set_string_value("magic_steps", "no", true, false)
1289            .unwrap();
1290        assert_eq!(refusal(&opts, &reg), None);
1291    }
1292
1293    /// …but asking for the feature is refused, by name, with a pointer.
1294    #[test]
1295    fn requesting_an_unimplemented_feature_is_refused() {
1296        let (mut opts, reg) = fixture();
1297        opts.set_string_value("dependency_detector", "mumps", true, false)
1298            .unwrap();
1299        let msg = refusal(&opts, &reg).expect("must refuse");
1300        assert!(msg.contains("dependency_detector"), "{msg}");
1301        assert!(msg.contains("linear-dependency detection"), "{msg}");
1302        assert!(msg.contains("483"), "{msg}");
1303    }
1304
1305    /// Numeric knobs of an absent feature are refused the same way.
1306    #[test]
1307    fn a_numeric_knob_of_an_absent_feature_is_refused() {
1308        let (mut opts, reg) = fixture();
1309        opts.set_numeric_value("penalty_init_max", 42.0, true, false)
1310            .unwrap();
1311        let msg = refusal(&opts, &reg).expect("must refuse");
1312        assert!(msg.contains("CG-penalty"), "{msg}");
1313    }
1314
1315    /// The four constant-derivative hints left this table in gh #588 Q6.
1316    /// They must not block a solve — that was never in question — and
1317    /// they must no longer produce the "pounce does not exploit this"
1318    /// warning, because pounce now does. What they earn instead (a
1319    /// proof-backed refusal, or silent reuse) is asserted where it is
1320    /// decided: `pounce_nlp::constant_derivatives` and
1321    /// `pounce-cli/tests/unimplemented_options.rs`.
1322    #[test]
1323    fn the_constant_derivative_hints_are_no_longer_unexploited() {
1324        let (mut opts, reg) = fixture();
1325        opts.set_string_value("hessian_constant", "yes", true, false)
1326            .unwrap();
1327        assert_eq!(refusal(&opts, &reg), None, "a hint must not block a solve");
1328        assert!(
1329            hint_warnings(&opts, &reg).is_empty(),
1330            "`hessian_constant` is exploited now; the unexploited-hint \
1331             warning would contradict the reuse the solver actually does",
1332        );
1333        assert!(
1334            UNEXPLOITED_HINTS.is_empty(),
1335            "gh #588 Q6 emptied this table; an entry added back needs its \
1336             own warning text and a test that the option is really unused",
1337        );
1338    }
1339
1340    /// `fast_step_computation` was in the refusal table for one commit,
1341    /// added by hand against the membership rule above. It fails here if
1342    /// it ever comes back: `PdSearchDirCalc` owns the flag and consumes
1343    /// it at two sites, so refusing it would fail a solve pounce can
1344    /// serve. Its read site is wired in `algorithm_builder_from_options`.
1345    #[test]
1346    fn fast_step_computation_is_wired_not_refused() {
1347        let (mut opts, reg) = fixture();
1348        opts.set_string_value("fast_step_computation", "yes", true, false)
1349            .unwrap();
1350        assert_eq!(refusal(&opts, &reg), None);
1351
1352        let mut app = crate::application::IpoptApplication::new();
1353        app.initialize().unwrap();
1354        app.initialize_with_options_str("fast_step_computation yes\n")
1355            .unwrap();
1356        assert!(
1357            app.algorithm_builder_from_options().fast_step_computation,
1358            "the option must reach the builder, or wiring it changed nothing",
1359        );
1360        // …and the default is still off.
1361        let mut app = crate::application::IpoptApplication::new();
1362        app.initialize().unwrap();
1363        assert!(!app.algorithm_builder_from_options().fast_step_computation);
1364    }
1365
1366    /// `option_file_name` left the table when gh#518 implemented the
1367    /// feature it names. Refusing it *from the table* again would be a
1368    /// regression in the other direction: it now configures the run, so
1369    /// a user who sets it gets what they asked for rather than an error.
1370    #[test]
1371    fn option_file_name_is_implemented_not_in_the_table() {
1372        let (mut opts, reg) = fixture();
1373        opts.set_string_value("option_file_name", "tiny.opt", true, false)
1374            .unwrap();
1375        assert_eq!(refusal(&opts, &reg), None);
1376    }
1377
1378    /// …but leaving the table must not hand the option back its silence
1379    /// on the surfaces that still cannot honor it. Only
1380    /// `initialize_with_option_file` resolves it, and library callers
1381    /// (Python, the C interface, WASM) never call it — so there, setting
1382    /// the option is still refused, just by a different guard.
1383    #[test]
1384    fn option_file_name_is_refused_where_nothing_resolves_it() {
1385        let mut app = crate::application::IpoptApplication::new();
1386        app.initialize().unwrap();
1387        assert_eq!(app.unhonored_option_file_name(), None, "unset asks nothing");
1388
1389        app.initialize_with_options_str("option_file_name tiny.opt\n")
1390            .unwrap();
1391        let msg = app
1392            .unhonored_option_file_name()
1393            .expect("a library caller cannot honor it");
1394        assert!(msg.contains("tiny.opt"), "{msg}");
1395        assert!(msg.contains("does not read options files"), "{msg}");
1396        assert!(msg.contains("518"), "{msg}");
1397    }
1398
1399    /// The default gate applies here too: `option_file_name` defaults to
1400    /// `ipopt.opt`, so a caller replaying a full option dump sets that
1401    /// value while asking for nothing. Failing them would break the same
1402    /// compatibility the explicitly-set-default rule protects everywhere
1403    /// else.
1404    #[test]
1405    fn option_file_name_at_its_default_asks_nothing_of_a_library_caller() {
1406        let mut app = crate::application::IpoptApplication::new();
1407        app.initialize_with_options_str("option_file_name ipopt.opt\n")
1408            .unwrap();
1409        assert_eq!(app.unhonored_option_file_name(), None);
1410    }
1411
1412    /// On the CLI's path the option *is* resolved, so the guard stays
1413    /// quiet — including when the resolver finds no file to read, which
1414    /// still means the option was honored (there was nothing to read).
1415    #[test]
1416    fn the_guard_is_quiet_once_the_option_file_path_has_run() {
1417        let dir = std::env::temp_dir().join(format!("pounce_gh518_lib_{}", std::process::id()));
1418        std::fs::create_dir_all(&dir).unwrap();
1419        let path = dir.join("tiny.opt");
1420        std::fs::write(&path, "max_iter 5\n").unwrap();
1421
1422        let mut app = crate::application::IpoptApplication::new();
1423        app.initialize_with_option_file(Some(&path)).unwrap();
1424        assert_eq!(app.unhonored_option_file_name(), None);
1425        assert_eq!(
1426            app.options().get_integer_value("max_iter", "").unwrap(),
1427            (5, true),
1428            "the file must actually have been read",
1429        );
1430
1431        let _ = std::fs::remove_dir_all(&dir);
1432    }
1433
1434    /// The restoration switches wired in gh#483 / #191 round 2. Each
1435    /// field was already consumed by `RestoAlgorithmBuilder`; only the
1436    /// read site was missing, so setting the option did nothing. The
1437    /// assertion that matters is that the value *reaches the builder* —
1438    /// a read site populating a field nobody consumes would be a fresh
1439    /// silent no-op, the very defect this work removes.
1440    #[test]
1441    fn the_restoration_switches_reach_the_builder() {
1442        for (key, default_on) in [
1443            ("evaluate_orig_obj_at_resto_trial", true),
1444            ("expect_infeasible_problem", false),
1445            ("start_with_resto", false),
1446        ] {
1447            let mut app = crate::application::IpoptApplication::new();
1448            app.initialize().unwrap();
1449            let resto = app.algorithm_builder_from_options().resto;
1450            let got = match key {
1451                "evaluate_orig_obj_at_resto_trial" => resto.evaluate_orig_obj_at_resto_trial,
1452                "expect_infeasible_problem" => resto.expect_infeasible_problem,
1453                _ => resto.start_with_resto,
1454            };
1455            assert_eq!(got, default_on, "{key}: default changed");
1456
1457            // Flip it and check the flip lands.
1458            let flipped = if default_on { "no" } else { "yes" };
1459            let mut app = crate::application::IpoptApplication::new();
1460            app.initialize().unwrap();
1461            app.initialize_with_options_str(&format!("{key} {flipped}\n"))
1462                .unwrap();
1463            let resto = app.algorithm_builder_from_options().resto;
1464            let got = match key {
1465                "evaluate_orig_obj_at_resto_trial" => resto.evaluate_orig_obj_at_resto_trial,
1466                "expect_infeasible_problem" => resto.expect_infeasible_problem,
1467                _ => resto.start_with_resto,
1468            };
1469            assert_eq!(
1470                got, !default_on,
1471                "{key}={flipped} never reached the builder"
1472            );
1473        }
1474    }
1475
1476    /// `max_resto_iter` reaches the builder (#551 / #677). The cap it
1477    /// sets is enforced by `RestoConvCheckAdapter::maximum_resto_iters`,
1478    /// which `pounce-restoration` tests against this field; here we only
1479    /// pin the option → builder link and the default.
1480    #[test]
1481    fn max_resto_iter_reaches_the_builder() {
1482        let mut app = crate::application::IpoptApplication::new();
1483        app.initialize().unwrap();
1484        let b = app.algorithm_builder_from_options();
1485        // NOT the registered default (3000000). pounce has capped
1486        // successive restoration iterations at 3000 since the cap landed,
1487        // and wiring the option must not move that for anyone who did not
1488        // ask. See `RestoOptions::max_resto_iter`.
1489        assert_eq!(b.resto.max_resto_iter, 3000);
1490        let reg = registry();
1491        let registered = reg.get_option("max_resto_iter").expect("registered");
1492        assert!(
1493            matches!(registered.default, DefaultValue::Integer(3_000_000)),
1494            "the registry no longer declares 3000000 — if upstream's number \
1495             was adopted as the effective default, that is a trajectory \
1496             change and this test should be the one that says so",
1497        );
1498
1499        let mut app = crate::application::IpoptApplication::new();
1500        app.initialize().unwrap();
1501        app.initialize_with_options_str("max_resto_iter 17\n")
1502            .unwrap();
1503        assert_eq!(
1504            app.algorithm_builder_from_options().resto.max_resto_iter,
1505            17,
1506            "never reached the builder",
1507        );
1508    }
1509
1510    /// The four corrector knobs select `FilterLSAcceptor::TryCorrector`,
1511    /// which pounce does not have — no acceptor here takes a corrector
1512    /// trial. They were classified as missing read sites on the strength
1513    /// of pounce having *a* corrector (Mehrotra's, in the search-direction
1514    /// RHS, reached through `mehrotra_algorithm`); that is a different
1515    /// mechanism and a different option (#551 / #677).
1516    #[test]
1517    fn the_corrector_knobs_are_refused() {
1518        let (mut opts, reg) = fixture();
1519        opts.set_string_value("corrector_type", "affine", true, false)
1520            .unwrap();
1521        let msg = refusal(&opts, &reg).expect("must refuse");
1522        assert!(msg.contains("`corrector_type`"), "{msg}");
1523        assert!(msg.contains("TryCorrector"), "{msg}");
1524        assert!(msg.contains("mehrotra_algorithm"), "{msg}");
1525        assert!(msg.contains("551"), "{msg}");
1526
1527        for (name, value) in [
1528            ("skip_corr_if_neg_curv", "no"),
1529            ("skip_corr_in_monotone_mode", "no"),
1530        ] {
1531            let (mut opts, reg) = fixture();
1532            opts.set_string_value(name, value, true, false).unwrap();
1533            assert!(refusal(&opts, &reg).is_some(), "`{name}` must refuse");
1534        }
1535        let (mut opts, reg) = fixture();
1536        opts.set_numeric_value("corrector_compl_avrg_red_fact", 2.0, true, false)
1537            .unwrap();
1538        assert!(refusal(&opts, &reg).is_some());
1539
1540        // …and the default gate still holds: `corrector_type=none` is what
1541        // an untouched solve already does.
1542        let (mut opts, reg) = fixture();
1543        opts.set_string_value("corrector_type", "none", true, false)
1544            .unwrap();
1545        assert_eq!(refusal(&opts, &reg), None);
1546    }
1547
1548    /// The remaining three restoration/L-BFGS sub-capabilities. Each
1549    /// message must distinguish "the feature runs, this part of it does
1550    /// not" from "pounce does not implement this feature" — that is what
1551    /// the `advice` half is for, and a user who set one of these needs to
1552    /// know restoration itself is still doing its job.
1553    #[test]
1554    fn the_missing_restoration_sub_capabilities_are_refused() {
1555        let (mut opts, reg) = fixture();
1556        opts.set_numeric_value("expect_infeasible_problem_ctol", 1e-4, true, false)
1557            .unwrap();
1558        let msg = refusal(&opts, &reg).expect("must refuse");
1559        assert!(msg.contains("filter line search"), "{msg}");
1560        assert!(
1561            msg.contains("restoration phase itself runs"),
1562            "the message must say the parent feature is unaffected: {msg}",
1563        );
1564
1565        let (mut opts, reg) = fixture();
1566        opts.set_numeric_value("expect_infeasible_problem_ytol", 1e6, true, false)
1567            .unwrap();
1568        assert!(refusal(&opts, &reg).is_some());
1569
1570        let (mut opts, reg) = fixture();
1571        opts.set_string_value("limited_memory_special_for_resto", "yes", true, false)
1572            .unwrap();
1573        let msg = refusal(&opts, &reg).expect("must refuse");
1574        assert!(msg.contains("Nov 2010"), "{msg}");
1575        assert!(
1576            msg.contains("L-BFGS runs in the restoration sub-solve"),
1577            "{msg}",
1578        );
1579
1580        let (mut opts, reg) = fixture();
1581        opts.set_numeric_value("resto_failure_feasibility_threshold", 1e-6, true, false)
1582            .unwrap();
1583        let msg = refusal(&opts, &reg).expect("must refuse");
1584        assert!(msg.contains("restoration runs"), "{msg}");
1585        // The wired sibling is named as the thing that *does* bound a
1586        // restoration, so the message points somewhere real.
1587        assert!(msg.contains("max_resto_iter"), "{msg}");
1588
1589        // Defaults ask for nothing, on every one of them.
1590        let (mut opts, reg) = fixture();
1591        opts.set_numeric_value("expect_infeasible_problem_ctol", 1e-3, true, false)
1592            .unwrap();
1593        opts.set_string_value("limited_memory_special_for_resto", "no", true, false)
1594            .unwrap();
1595        opts.set_numeric_value("resto_failure_feasibility_threshold", 0.0, true, false)
1596            .unwrap();
1597        assert_eq!(refusal(&opts, &reg), None);
1598    }
1599
1600    /// The L-BFGS σ clamp, wired in gh#483 / #191 round 2.
1601    /// `LimMemQuasiNewtonUpdater` consumes both bounds in
1602    /// `initial_hessian_scalar`; only the read sites were missing. Note
1603    /// the fields are named `init_val_{max,min}`, not after the options —
1604    /// which is why a grep for the option name found nothing and the
1605    /// consumer had to be located by hand.
1606    #[test]
1607    fn the_lbfgs_sigma_clamp_reaches_the_builder() {
1608        let mut app = crate::application::IpoptApplication::new();
1609        app.initialize().unwrap();
1610        let b = app.algorithm_builder_from_options();
1611        assert_eq!(b.limited_memory_init_val_max, 1e8, "default changed");
1612        assert_eq!(b.limited_memory_init_val_min, 1e-8, "default changed");
1613
1614        let mut app = crate::application::IpoptApplication::new();
1615        app.initialize().unwrap();
1616        app.initialize_with_options_str(
1617            "limited_memory_init_val_max 5e5\nlimited_memory_init_val_min 1e-3\n",
1618        )
1619        .unwrap();
1620        let b = app.algorithm_builder_from_options();
1621        assert_eq!(
1622            b.limited_memory_init_val_max, 5e5,
1623            "never reached the builder"
1624        );
1625        assert_eq!(
1626            b.limited_memory_init_val_min, 1e-3,
1627            "never reached the builder"
1628        );
1629    }
1630
1631    /// Options whose *feature* runs and only whose read site is missing
1632    /// must stay out of the table — refusing them would fail solves that
1633    /// are correct today. This pins the boundary the triage drew.
1634    #[test]
1635    fn options_on_implemented_features_are_not_refused() {
1636        for (name, value) in [
1637            // restoration's successive-iteration cap, wired in #551 /
1638            // #677 round 3 — `RestoConvCheckAdapter::maximum_resto_iters`
1639            ("max_resto_iter", "17"),
1640            // the filter line search runs
1641            ("accept_after_max_steps", "3"),
1642            // L-BFGS runs
1643            ("limited_memory_max_skipping", "4"),
1644            // `PdSearchDirCalc` has the flag and consumes it; it was
1645            // briefly in the refusal table by hand, against the rule
1646            // above, which would have failed a solve it can serve.
1647            ("fast_step_computation", "yes"),
1648        ] {
1649            let (mut opts, reg) = fixture();
1650            // The table mixes string, integer and numeric options; try
1651            // each setter until one takes the value.
1652            let set = opts.set_string_value(name, value, true, false).is_ok()
1653                || value
1654                    .parse::<i32>()
1655                    .ok()
1656                    .is_some_and(|v| opts.set_integer_value(name, v, true, false).is_ok())
1657                || value
1658                    .parse::<f64>()
1659                    .ok()
1660                    .is_some_and(|v| opts.set_numeric_value(name, v, true, false).is_ok());
1661            assert!(set, "could not set `{name}` to `{value}`");
1662            assert_eq!(
1663                refusal(&opts, &reg),
1664                None,
1665                "`{name}` configures a feature pounce implements; it needs a \
1666                 read site, not a refusal",
1667            );
1668        }
1669    }
1670}