1use serde::Serialize;
11
12#[derive(Debug, Clone, Serialize)]
14pub struct ColumnEntry {
15 pub definition: &'static str,
16 pub typical_range: &'static str,
17 pub what_abnormal_means: &'static str,
18 pub see_also: &'static [&'static str],
19}
20
21#[derive(Debug, Clone, Serialize)]
23pub struct FindingEntry {
24 pub severity: &'static str,
25 pub meaning: &'static str,
26 pub what_to_try: &'static str,
27 pub see_also: &'static [&'static str],
28}
29
30#[derive(Debug, Clone, Serialize)]
32pub struct Citation {
33 pub key: &'static str,
34 pub entry_type: &'static str,
35 pub title: &'static str,
36 pub author: &'static str,
37 pub year: &'static str,
38 pub venue: &'static str,
39 pub doi: &'static str,
40}
41
42#[derive(Debug, Clone, Serialize)]
45#[serde(tag = "kind", rename_all = "snake_case")]
46pub enum Explanation {
47 Column {
48 term: String,
49 #[serde(flatten)]
50 entry: ColumnEntry,
51 },
52 Finding {
53 term: String,
54 #[serde(flatten)]
55 entry: FindingEntry,
56 },
57 Unknown {
58 term: String,
59 suggestions: Vec<&'static str>,
60 all_columns: Vec<&'static str>,
61 all_findings: Vec<&'static str>,
62 },
63}
64
65const COLUMNS: &[(&str, ColumnEntry)] = &[
66 ("iter", ColumnEntry {
67 definition: "Zero-based iteration index of the outer interior-point loop.",
68 typical_range: "0 to a few hundred for well-scaled problems.",
69 what_abnormal_means: "Hitting `max_iter` without converging usually points at scaling or degeneracy.",
70 see_also: &["wachter2006"],
71 }),
72 ("objective", ColumnEntry {
73 definition: "Current objective value f(x_k) at the iterate.",
74 typical_range: "Problem-dependent. For well-scaled problems on the order of 1.",
75 what_abnormal_means: "Wild swings (especially after restoration) can signal bad scaling.",
76 see_also: &[],
77 }),
78 ("inf_pr", ColumnEntry {
79 definition: "Primal infeasibility: max-norm of constraint violation c(x_k).",
80 typical_range: "Drops monotonically toward `tol` (default 1e-8) at convergence.",
81 what_abnormal_means: "Stalling at large inf_pr → likely infeasible or restoration-stuck.",
82 see_also: &["wachter2006", "byrd2010"],
83 }),
84 ("inf_du", ColumnEntry {
85 definition: "Dual infeasibility: max-norm of the gradient of the Lagrangian.",
86 typical_range: "Drops toward `tol` alongside inf_pr; sometimes lags.",
87 what_abnormal_means: "inf_du much larger than inf_pr → multipliers ill-conditioned or scaling bad.",
88 see_also: &["wachter2006"],
89 }),
90 ("mu", ColumnEntry {
91 definition: "Barrier parameter for the log-barrier homotopy.",
92 typical_range: "Starts ~0.1, decreases toward 1e-9 as iterations progress.",
93 what_abnormal_means: "Mu stuck at one value across many iterations → see finding `mu_stuck`.",
94 see_also: &["wachter2006", "hinder2018"],
95 }),
96 ("d_norm", ColumnEntry {
97 definition: "Norm of the Newton search direction d_k.",
98 typical_range: "Decreases as the iterate approaches the solution.",
99 what_abnormal_means: "d_norm growing → search direction quality degrading; check regularization.",
100 see_also: &["wachter2006"],
101 }),
102 ("regularization", ColumnEntry {
103 definition: "Diagonal Hessian regularization δ added to make the KKT matrix have the correct inertia.",
104 typical_range: "0 for convex problems; small positive values near saddle points.",
105 what_abnormal_means: "Repeated large δ → Hessian is indefinite, problem is non-convex; finding `hessian_regularized` fires.",
106 see_also: &["wachter2006"],
107 }),
108 ("alpha_dual", ColumnEntry {
109 definition: "Step length applied to the dual variables (bound multipliers).",
110 typical_range: "(0, 1]. Often 1.0 near the solution.",
111 what_abnormal_means: "Persistently tiny alpha_dual → fraction-to-boundary biting; bounds may be active.",
112 see_also: &["wachter2006"],
113 }),
114 ("alpha_primal", ColumnEntry {
115 definition: "Step length applied to the primal variables x.",
116 typical_range: "(0, 1]. Tiny values point at line-search difficulty.",
117 what_abnormal_means: "Repeated tiny alpha_primal → finding `heavy_line_search` fires.",
118 see_also: &["wachter2006"],
119 }),
120 ("alpha_primal_char", ColumnEntry {
121 definition: "Single-character tag for what the line search did this iter: `f` filter-accepted, `h` Armijo, `r` restoration, `s` second-order correction, `R` restoration entry, `-` rejected.",
122 typical_range: "Mostly `f` on a healthy solve.",
123 what_abnormal_means: "Runs of `r` are restoration windows; consecutive `R` entries = `restoration_loop`.",
124 see_also: &["wachter2006"],
125 }),
126 ("ls_trials", ColumnEntry {
127 definition: "Number of line-search trials in this iteration.",
128 typical_range: "1-3 for healthy solves.",
129 what_abnormal_means: "Persistently high → curvature mismatch; consider regularization or restart.",
130 see_also: &["wachter2006"],
131 }),
132 ("log10_mu", ColumnEntry {
133 definition: "log10(mu); convenience for plotting the barrier homotopy.",
134 typical_range: "Decreases from ~-1 to ~-9 over a typical solve.",
135 what_abnormal_means: "Flat trace → mu_stuck.",
136 see_also: &["wachter2006"],
137 }),
138 ("log10_inf_pr", ColumnEntry {
139 definition: "log10(inf_pr); convenience for spotting stalls.",
140 typical_range: "Monotone descent to ~-8.",
141 what_abnormal_means: "Plateaus over many iters → `find_stalls` will flag the window.",
142 see_also: &[],
143 }),
144 ("log10_inf_du", ColumnEntry {
145 definition: "log10(inf_du); convenience for spotting stalls.",
146 typical_range: "Monotone descent to ~-8.",
147 what_abnormal_means: "Plateaus → check Hessian regularization and step quality.",
148 see_also: &[],
149 }),
150 ("n_factors", ColumnEntry {
151 definition: "Total successful symmetric factorisations across the solve.",
152 typical_range: "≈ iteration_count for filter-line-search runs.",
153 what_abnormal_means: "Much larger than iter count → repeated regularization retries.",
154 see_also: &["n_pattern_reuse", "n_pattern_changes"],
155 }),
156 ("n_pattern_reuse", ColumnEntry {
157 definition: "Factors that reused the prior symbolic factorisation (sparsity pattern unchanged → cheap).",
158 typical_range: "Should dominate n_factors after iter 1.",
159 what_abnormal_means: "Low share → matrix structure changing per iter; analyse() runs repeatedly, hurting throughput.",
160 see_also: &["n_pattern_changes"],
161 }),
162 ("n_pattern_changes", ColumnEntry {
163 definition: "Factors that required a fresh symbolic factorisation.",
164 typical_range: "1 (the first factor) for a healthy solve.",
165 what_abnormal_means: "> 1 → KKT structure shifting; check inertia-correction regularization policy or active-set churn.",
166 see_also: &["n_pattern_reuse"],
167 }),
168 ("max_fill_ratio", ColumnEntry {
169 definition: "Max nnz(L) / nnz(A) observed across factors.",
170 typical_range: "1–10 for well-ordered KKT systems.",
171 what_abnormal_means: ">> 10 → AMD/METIS ordering struggled; expect memory + time spikes.",
172 see_also: &["last_nnz_a", "last_nnz_l"],
173 }),
174 ("min_abs_pivot", ColumnEntry {
175 definition: "Smallest absolute pivot encountered during factorisation.",
176 typical_range: "1e-8 .. 1e+6 depending on problem scaling.",
177 what_abnormal_means: "Approaching working precision floor (~1e-16) → matrix near-singular; regularization is probably kicking in.",
178 see_also: &["max_abs_pivot", "regularization"],
179 }),
180 ("max_abs_pivot", ColumnEntry {
181 definition: "Largest absolute pivot encountered during factorisation.",
182 typical_range: "Within ~6 orders of magnitude of min_abs_pivot.",
183 what_abnormal_means: "max/min >> 1e8 → catastrophic conditioning; consider nlp_scaling_method.",
184 see_also: &["min_abs_pivot"],
185 }),
186 ("last_inertia", ColumnEntry {
187 definition: "(positive, negative, zero) eigenvalue counts of the final factorisation, from the LDLᵀ pivots.",
188 typical_range: "(n, m, 0) at a converged primal-dual KKT system.",
189 what_abnormal_means: "zero > 0 → singular; positive < n → indefinite, inertia correction failed.",
190 see_also: &["regularization"],
191 }),
192 ("last_nnz_a", ColumnEntry {
193 definition: "nnz(A) at the final factorisation's input KKT matrix.",
194 typical_range: "Problem-dependent.",
195 what_abnormal_means: "n/a — informational.",
196 see_also: &["last_nnz_l", "max_fill_ratio"],
197 }),
198 ("last_nnz_l", ColumnEntry {
199 definition: "nnz(L) at the final factorisation.",
200 typical_range: "Problem-dependent.",
201 what_abnormal_means: "n/a — informational; combine with last_nnz_a for fill.",
202 see_also: &["last_nnz_a", "max_fill_ratio"],
203 }),
204];
205
206const FINDINGS: &[(&str, FindingEntry)] = &[
207 ("converged", FindingEntry {
208 severity: "info",
209 meaning: "Solver reached the convergence tolerance on both primal and dual infeasibility.",
210 what_to_try: "Nothing — this is the success path.",
211 see_also: &["wachter2006"],
212 }),
213 ("max_iter_exceeded", FindingEntry {
214 severity: "error",
215 meaning: "Solver hit `max_iter` without satisfying tolerances.",
216 what_to_try: "Inspect the convergence trace: is residual still decreasing (raise max_iter), stalled (loosen tol, improve scaling), or oscillating (regularize)?",
217 see_also: &["wachter2006"],
218 }),
219 ("restoration_used", FindingEntry {
220 severity: "info",
221 meaning: "Restoration phase was entered at least once during the solve.",
222 what_to_try: "Often benign on hard problems. Check restoration-windows; a single short entry is fine, repeated entries suggest a deeper feasibility issue.",
223 see_also: &["wachter2006", "byrd2010"],
224 }),
225 ("mu_stuck", FindingEntry {
226 severity: "warning",
227 meaning: "Barrier parameter μ failed to decrease across a window of iterations. Usually a degenerate active set or a poorly-scaled barrier.",
228 what_to_try: "Try `mu_strategy=adaptive`, tighten `bound_relax_factor`, or check that bound values are sensible (no infs masking effective bounds).",
229 see_also: &["wachter2006", "hinder2018"],
230 }),
231 ("heavy_line_search", FindingEntry {
232 severity: "warning",
233 meaning: "Line search needed many trials on average — search direction is low-quality.",
234 what_to_try: "Often Hessian-related: enable second-order-correction, or investigate regularization values.",
235 see_also: &["wachter2006"],
236 }),
237 ("hessian_regularized", FindingEntry {
238 severity: "warning",
239 meaning: "Hessian needed inertia-correction (added δ on the diagonal) frequently — the problem is non-convex or has near-singular Hessian.",
240 what_to_try: "Consider tightening tolerances on `min_hessian_perturbation`, providing an analytic Hessian if you have one, or reformulating to convexify.",
241 see_also: &["wachter2006"],
242 }),
243 ("restoration_loop", FindingEntry {
244 severity: "error",
245 meaning: "Restoration phase was entered repeatedly and never exited cleanly.",
246 what_to_try: "Strong signal of local infeasibility. Try a different start point, relax tight constraints, or run feasibility diagnostics.",
247 see_also: &["wachter2006", "byrd2010", "leyffer2003"],
248 }),
249 ("convergence_stall", FindingEntry {
250 severity: "warning",
251 meaning: "log10(inf_pr|inf_du) barely moved across a window — solver is grinding.",
252 what_to_try: "Check `find-stalls` for the window, then `get-iterate` at its midpoint to inspect μ, alpha_primal, and regularization. Common cause: bad scaling.",
253 see_also: &["wachter2006"],
254 }),
255];
256
257pub const TOPICS: &[(&str, &[&str])] = &[
259 ("interior_point", &["wachter2006", "byrd1999", "hinder2018"]),
260 ("filter_line_search", &["wachter2006"]),
261 ("restoration", &["wachter2006", "byrd2010"]),
262 ("regularization", &["wachter2006"]),
263 ("trust_region", &["byrd2000", "waltz2006"]),
264 ("inexact_step", &["curtis2010"]),
265 ("infeasibility_detection", &["byrd2010", "leyffer2003"]),
266 ("mu_strategy", &["wachter2006", "hinder2018"]),
267 ("sensitivity", &["zavala2009"]),
268 ("knitro", &["byrd2006"]),
269];
270
271pub const SOLVE_FEATURES: &[(&str, &[&str])] = &[
282 ("core", &["pounce2026", "wachter2006"]),
283 ("restoration", &["byrd2010"]),
289];
290
291const CITATIONS: &[Citation] = &[
292 Citation {
293 key: "pounce2026",
294 entry_type: "software",
295 title: "POUNCE: a pure-Rust port of the Ipopt interior-point NLP solver",
296 author: "Kitchin, J. R.",
297 year: "2026",
298 venue: "Zenodo",
299 doi: "10.5281/zenodo.20387011",
300 },
301 Citation {
302 key: "wachter2006",
303 entry_type: "article",
304 title: "On the implementation of an interior-point filter line-search algorithm for large-scale nonlinear programming",
305 author: "Wächter, A. and Biegler, L. T.",
306 year: "2006",
307 venue: "Mathematical Programming 106(1), 25–57",
308 doi: "10.1007/s10107-004-0559-y",
309 },
310 Citation {
311 key: "byrd1999",
312 entry_type: "article",
313 title: "An interior point algorithm for large-scale nonlinear programming",
314 author: "Byrd, R. H., Hribar, M. E. and Nocedal, J.",
315 year: "1999",
316 venue: "SIAM Journal on Optimization 9(4), 877–900",
317 doi: "10.1137/S1052623497325107",
318 },
319 Citation {
320 key: "byrd2000",
321 entry_type: "article",
322 title: "A trust region method based on interior point techniques for nonlinear programming",
323 author: "Byrd, R. H., Gilbert, J. C. and Nocedal, J.",
324 year: "2000",
325 venue: "Mathematical Programming 89(1), 149–185",
326 doi: "10.1007/PL00011391",
327 },
328 Citation {
329 key: "byrd2006",
330 entry_type: "inbook",
331 title: "Knitro: An integrated package for nonlinear optimization",
332 author: "Byrd, R. H., Nocedal, J. and Waltz, R. A.",
333 year: "2006",
334 venue: "Large-Scale Nonlinear Optimization, Springer, 35–59",
335 doi: "10.1007/0-387-30065-1_4",
336 },
337 Citation {
338 key: "byrd2010",
339 entry_type: "article",
340 title: "Infeasibility detection and SQP methods for nonlinear optimization",
341 author: "Byrd, R. H., Curtis, F. E. and Nocedal, J.",
342 year: "2010",
343 venue: "SIAM Journal on Optimization 20(5), 2281–2299",
344 doi: "10.1137/080738222",
345 },
346 Citation {
347 key: "waltz2006",
348 entry_type: "article",
349 title: "An interior algorithm for nonlinear optimization that combines line search and trust region steps",
350 author: "Waltz, R. A., Morales, J. L., Nocedal, J. and Orban, D.",
351 year: "2006",
352 venue: "Mathematical Programming 107(3), 391–408",
353 doi: "10.1007/s10107-004-0560-5",
354 },
355 Citation {
356 key: "curtis2010",
357 entry_type: "article",
358 title: "An adaptive Gauss-Newton algorithm for training multilayer nonlinear filters that have embedded memory",
359 author: "Curtis, F. E.",
360 year: "2010",
361 venue: "Mathematical Programming Computation 4(1), 27–62",
362 doi: "10.1007/s12532-011-0033-9",
363 },
364 Citation {
365 key: "leyffer2003",
366 entry_type: "article",
367 title: "Interior methods for mathematical programs with complementarity constraints",
368 author: "Leyffer, S., López-Calva, G. and Nocedal, J.",
369 year: "2003",
370 venue: "Argonne National Laboratory technical report",
371 doi: "",
372 },
373 Citation {
374 key: "hinder2018",
375 entry_type: "article",
376 title: "One-phase: A new method for global linear and quadratic programming",
377 author: "Hinder, O. and Ye, Y.",
378 year: "2018",
379 venue: "Optimization Online preprint",
380 doi: "",
381 },
382 Citation {
383 key: "zavala2009",
384 entry_type: "article",
385 title: "Real-time nonlinear optimization as a generalized equation",
386 author: "Zavala, V. M. and Anitescu, M.",
387 year: "2009",
388 venue: "SIAM Journal on Control and Optimization 48(8), 5444–5467",
389 doi: "10.1137/090762634",
390 },
391];
392
393pub fn column(name: &str) -> Option<&'static ColumnEntry> {
395 COLUMNS.iter().find(|(k, _)| *k == name).map(|(_, v)| v)
396}
397
398pub fn finding(code: &str) -> Option<&'static FindingEntry> {
400 FINDINGS.iter().find(|(k, _)| *k == code).map(|(_, v)| v)
401}
402
403pub fn all_columns() -> Vec<&'static str> {
405 COLUMNS.iter().map(|(k, _)| *k).collect()
406}
407
408pub fn all_findings() -> Vec<&'static str> {
410 FINDINGS.iter().map(|(k, _)| *k).collect()
411}
412
413pub fn citation_by_key(key: &str) -> Option<&'static Citation> {
415 CITATIONS.iter().find(|c| c.key == key)
416}
417
418pub fn topic_keys(topic: &str) -> Option<&'static [&'static str]> {
420 TOPICS
421 .iter()
422 .find(|(t, _)| *t == topic)
423 .map(|(_, keys)| *keys)
424}
425
426pub fn all_topics() -> Vec<&'static str> {
428 TOPICS.iter().map(|(t, _)| *t).collect()
429}
430
431pub fn solve_feature_keys(feature: &str) -> Option<&'static [&'static str]> {
435 SOLVE_FEATURES
436 .iter()
437 .find(|(f, _)| *f == feature)
438 .map(|(_, keys)| *keys)
439}
440
441pub fn all_citations() -> &'static [Citation] {
443 CITATIONS
444}
445
446pub fn explain(term: &str) -> Explanation {
449 if let Some(entry) = column(term) {
450 return Explanation::Column {
451 term: term.to_string(),
452 entry: entry.clone(),
453 };
454 }
455 if let Some(entry) = finding(term) {
456 return Explanation::Finding {
457 term: term.to_string(),
458 entry: entry.clone(),
459 };
460 }
461 let mut pool = all_columns();
462 pool.extend(all_findings());
463 Explanation::Unknown {
464 term: term.to_string(),
465 suggestions: fuzzy_suggest(term, &pool, 3),
466 all_columns: all_columns(),
467 all_findings: all_findings(),
468 }
469}
470
471fn fuzzy_suggest(term: &str, candidates: &[&'static str], limit: usize) -> Vec<&'static str> {
472 let needle = term.to_ascii_lowercase();
473 let mut scored: Vec<(u8, &'static str)> = Vec::new();
474 for &c in candidates {
475 let cl = c.to_ascii_lowercase();
476 if cl == needle {
477 scored.push((0, c));
478 } else if cl.starts_with(&needle) || needle.starts_with(&cl) {
479 scored.push((1, c));
480 } else if cl.contains(&needle) || needle.contains(&cl) {
481 scored.push((2, c));
482 }
483 }
484 scored.sort_by_key(|(r, _)| *r);
485 scored.into_iter().take(limit).map(|(_, c)| c).collect()
486}
487
488#[cfg(test)]
489mod tests {
490 use super::*;
491
492 #[test]
493 fn column_lookup() {
494 assert!(column("inf_pr").is_some());
495 assert!(column("not_a_real_column").is_none());
496 }
497
498 #[test]
499 fn finding_lookup() {
500 let f = finding("mu_stuck").expect("mu_stuck should exist");
501 assert_eq!(f.severity, "warning");
502 }
503
504 #[test]
505 fn explain_finds_column() {
506 let e = explain("inf_du");
507 assert!(matches!(e, Explanation::Column { .. }));
508 }
509
510 #[test]
511 fn explain_finds_finding() {
512 let e = explain("restoration_loop");
513 assert!(matches!(e, Explanation::Finding { .. }));
514 }
515
516 #[test]
517 fn explain_fuzzy_unknown() {
518 let e = explain("inf");
520 match e {
521 Explanation::Unknown { suggestions, .. } => {
522 assert!(
523 suggestions.iter().any(|s| s.starts_with("inf_")),
524 "got {suggestions:?}",
525 );
526 }
527 _ => panic!("expected Unknown"),
528 }
529 }
530
531 #[test]
532 fn topic_lookup() {
533 let keys = topic_keys("restoration").expect("restoration topic exists");
534 assert!(keys.contains(&"wachter2006"));
535 }
536
537 #[test]
538 fn citation_lookup() {
539 let c = citation_by_key("wachter2006").expect("wachter2006 cited");
540 assert!(c.title.contains("interior-point filter line-search"));
541 }
542
543 #[test]
544 fn pounce_self_citation_present() {
545 let c = citation_by_key("pounce2026").expect("pounce2026 cited");
546 assert_eq!(c.doi, "10.5281/zenodo.20387011");
547 }
548
549 #[test]
550 fn solve_feature_core_and_restoration() {
551 let core = solve_feature_keys("core").expect("core feature exists");
552 assert_eq!(core, &["pounce2026", "wachter2006"]);
553 let resto = solve_feature_keys("restoration").expect("restoration feature exists");
554 assert!(resto.contains(&"byrd2010"));
555 assert!(solve_feature_keys("nonsense").is_none());
556 }
557
558 #[test]
559 fn every_solve_feature_key_resolves() {
560 for (_feature, keys) in SOLVE_FEATURES {
561 for k in *keys {
562 assert!(
563 citation_by_key(k).is_some(),
564 "SOLVE_FEATURES references unknown key {k:?}",
565 );
566 }
567 }
568 }
569}