Skip to main content

pounce_studio_core/
glossary.rs

1//! Static glossary for solve-report column names, diagnose finding codes,
2//! and citation metadata. Backs the `explain` and `citations` CLI tools.
3//!
4//! Ported from `studio/mcp/pounce_studio_mcp/glossary.py` so the
5//! Rust-CLI / skill path returns the same definitions as the Python MCP
6//! path. Entries are intentionally terse — Claude can elaborate at call
7//! time; the job here is to surface canonical names, numeric ranges,
8//! and the right paper keys.
9
10use serde::Serialize;
11
12/// One per-iter column entry returned by [`explain_column`].
13#[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/// One diagnose-finding entry returned by [`explain_finding`].
22#[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/// One citation entry returned by [`citation_by_key`].
31#[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/// Lookup result for [`explain`]: column entry, finding entry, or
43/// fuzzy suggestions when the term is unknown.
44#[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
257/// Topic → ordered list of citation keys, most-relevant first.
258pub 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
271/// Solve *feature* → ordered citation keys, for the `pounce --cite`
272/// lister. Distinct from [`TOPICS`] (which is keyed by documentation
273/// topic): these keys name things a *run* can actually do, so the CLI
274/// can map "this solve used feature X" → "cite these papers".
275///
276/// `"core"` is the static set every pounce run should cite. The rest
277/// are solve-aware extras; `"restoration"` is the only one wired in
278/// v1 (the report schema records restoration activity). New entries
279/// (adaptive-μ → `hinder2018`, etc.) slot in here as the report grows
280/// a `features_used` block.
281pub const SOLVE_FEATURES: &[(&str, &[&str])] = &[
282    ("core", &["pounce2026", "wachter2006"]),
283    // Restoration *entered* is often benign, so cite only the on-point
284    // restoration/infeasibility-detection paper. `leyffer2003`
285    // (penalty-IPM non-convergence) belongs to a restoration *loop* /
286    // infeasibility signal, not a single restoration entry — kept out
287    // of v1 until the report distinguishes the two.
288    ("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
393/// Look up a per-iter column entry by name.
394pub fn column(name: &str) -> Option<&'static ColumnEntry> {
395    COLUMNS.iter().find(|(k, _)| *k == name).map(|(_, v)| v)
396}
397
398/// Look up a diagnose-finding entry by code.
399pub fn finding(code: &str) -> Option<&'static FindingEntry> {
400    FINDINGS.iter().find(|(k, _)| *k == code).map(|(_, v)| v)
401}
402
403/// All known column names, in declaration order.
404pub fn all_columns() -> Vec<&'static str> {
405    COLUMNS.iter().map(|(k, _)| *k).collect()
406}
407
408/// All known finding codes, in declaration order.
409pub fn all_findings() -> Vec<&'static str> {
410    FINDINGS.iter().map(|(k, _)| *k).collect()
411}
412
413/// Look up a citation by bib key.
414pub fn citation_by_key(key: &str) -> Option<&'static Citation> {
415    CITATIONS.iter().find(|c| c.key == key)
416}
417
418/// Look up the list of citation keys associated with a topic.
419pub fn topic_keys(topic: &str) -> Option<&'static [&'static str]> {
420    TOPICS
421        .iter()
422        .find(|(t, _)| *t == topic)
423        .map(|(_, keys)| *keys)
424}
425
426/// All known topics.
427pub fn all_topics() -> Vec<&'static str> {
428    TOPICS.iter().map(|(t, _)| *t).collect()
429}
430
431/// Citation keys for a solve *feature* (see [`SOLVE_FEATURES`]). Used
432/// by the `pounce --cite` lister to turn "this run did X" into the
433/// papers to cite.
434pub 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
441/// All citations.
442pub fn all_citations() -> &'static [Citation] {
443    CITATIONS
444}
445
446/// Try to resolve `term` against the column and finding tables. Falls
447/// back to fuzzy suggestions when neither matches.
448pub 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        // "inf" is a prefix of several columns — should surface suggestions.
519        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}