Skip to main content

pounce_studio_core/
analysis.rs

1//! Derived series and diagnostics over a [`SolveReport`].
2//!
3//! Mirrors the Python analysis helpers in `studio/mcp/pounce_studio_mcp/
4//! reports.py` so the desktop / VS Code shells and the MCP server can
5//! agree on the same notion of "stall window", "restoration window",
6//! and "common failure modes". Heuristics are tunable via the
7//! parameters on each function; the defaults are the ones the Python
8//! `diagnose` tool ships with.
9
10use serde::{Deserialize, Serialize};
11
12use crate::report::{Error, IterRecord, SolveReport};
13
14/// Compact view-model derived from a [`SolveReport`]. Suitable as the
15/// "headline summary" that an LLM or dashboard reads first.
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct Summary {
18    pub schema: String,
19    pub result_id: String,
20    pub solver: String,
21    pub solver_version: String,
22    pub elapsed_seconds: f64,
23    pub n_variables: i32,
24    pub n_constraints: i32,
25    pub status: String,
26    pub final_objective: f64,
27    pub iteration_count: i32,
28    pub final_kkt_error: f64,
29    pub final_dual_inf: f64,
30    pub final_constr_viol: f64,
31    pub final_compl: f64,
32    pub restoration_calls: i32,
33    pub restoration_outer_iters: i32,
34    pub restoration_wall_secs: f64,
35    pub iterations_captured: usize,
36}
37
38pub fn summarize(report: &SolveReport) -> Summary {
39    Summary {
40        schema: report.schema.clone(),
41        result_id: report.fair_metadata.result_id.clone(),
42        solver: report.fair_metadata.solver.name.clone(),
43        solver_version: report.fair_metadata.solver.version.clone(),
44        elapsed_seconds: report.fair_metadata.elapsed_seconds,
45        n_variables: report.problem.n_variables,
46        n_constraints: report.problem.n_constraints,
47        status: report.solution.status.clone(),
48        final_objective: report.statistics.final_objective,
49        iteration_count: report.statistics.iteration_count,
50        final_kkt_error: report.statistics.final_kkt_error,
51        final_dual_inf: report.statistics.final_dual_inf,
52        final_constr_viol: report.statistics.final_constr_viol,
53        final_compl: report.statistics.final_compl,
54        restoration_calls: report.statistics.restoration_calls,
55        restoration_outer_iters: report.statistics.restoration_outer_iters,
56        restoration_wall_secs: report.statistics.restoration_wall_secs,
57        iterations_captured: report.iterations.len(),
58    }
59}
60
61/// Per-iteration trajectory in column-oriented form. More compact than
62/// a `Vec<IterRecord>` when serialised, since the column names are
63/// emitted once.
64#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct ConvergenceTrace {
66    pub iter: Vec<i32>,
67    pub objective: Vec<f64>,
68    pub inf_pr: Vec<f64>,
69    pub inf_du: Vec<f64>,
70    pub mu: Vec<f64>,
71    pub d_norm: Vec<f64>,
72    pub regularization: Vec<f64>,
73    pub alpha_dual: Vec<f64>,
74    pub alpha_primal: Vec<f64>,
75    pub alpha_primal_char: Vec<char>,
76    pub ls_trials: Vec<i32>,
77}
78
79pub fn convergence_trace(report: &SolveReport) -> ConvergenceTrace {
80    let n = report.iterations.len();
81    let mut t = ConvergenceTrace {
82        iter: Vec::with_capacity(n),
83        objective: Vec::with_capacity(n),
84        inf_pr: Vec::with_capacity(n),
85        inf_du: Vec::with_capacity(n),
86        mu: Vec::with_capacity(n),
87        d_norm: Vec::with_capacity(n),
88        regularization: Vec::with_capacity(n),
89        alpha_dual: Vec::with_capacity(n),
90        alpha_primal: Vec::with_capacity(n),
91        alpha_primal_char: Vec::with_capacity(n),
92        ls_trials: Vec::with_capacity(n),
93    };
94    for r in &report.iterations {
95        t.iter.push(r.iter);
96        t.objective.push(r.objective);
97        t.inf_pr.push(r.inf_pr);
98        t.inf_du.push(r.inf_du);
99        t.mu.push(r.mu);
100        t.d_norm.push(r.d_norm);
101        t.regularization.push(r.regularization);
102        t.alpha_dual.push(r.alpha_dual);
103        t.alpha_primal.push(r.alpha_primal);
104        t.alpha_primal_char.push(r.alpha_primal_char);
105        t.ls_trials.push(r.ls_trials);
106    }
107    t
108}
109
110/// One stalled-progress window: consecutive iterations whose
111/// log10-residual moved by less than the configured threshold.
112#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
113pub struct Stall {
114    pub start_iter: i32,
115    pub end_iter: i32,
116    pub metric: &'static str,
117    pub delta_log10: f64,
118}
119
120/// Default stall detection: 5+ consecutive iters with <0.3 orders of
121/// magnitude movement in either `inf_pr` or `inf_du`.
122pub fn find_stalls(report: &SolveReport) -> Vec<Stall> {
123    find_stalls_with(report, 5, 0.3)
124}
125
126/// Smallest meaningful stall window.
127///
128/// A stall is lack of progress *between* iterations, so it takes at least two
129/// of them to observe one. With `min_window <= 1` the window test
130/// `j - i + 1 >= min_window` is trivially true and every single iterate is
131/// reported as a stall — on a healthy monotone solve that means every
132/// iteration, each as a degenerate `start_iter == end_iter` window with
133/// `delta_log10 == 0`. Callers must reject smaller values at their boundary;
134/// see `MIN_STALL_WINDOW` uses in the pyo3 and CLI front ends.
135pub const MIN_STALL_WINDOW: usize = 2;
136
137/// `min_window` below [`MIN_STALL_WINDOW`] is meaningless; it is raised to it
138/// rather than producing degenerate single-iterate windows. Front ends reject
139/// such values outright, so this is a backstop for other Rust callers.
140pub fn find_stalls_with(
141    report: &SolveReport,
142    min_window: usize,
143    max_log10_progress: f64,
144) -> Vec<Stall> {
145    let min_window = min_window.max(MIN_STALL_WINDOW);
146    let mut out = Vec::new();
147    for (metric, series) in [
148        ("inf_pr", series_log10(&report.iterations, |r| r.inf_pr)),
149        ("inf_du", series_log10(&report.iterations, |r| r.inf_du)),
150    ] {
151        scan_stalls(
152            &series,
153            &report.iterations,
154            metric,
155            min_window,
156            max_log10_progress,
157            &mut out,
158        );
159    }
160    out
161}
162
163fn series_log10<F: Fn(&IterRecord) -> f64>(iters: &[IterRecord], f: F) -> Vec<Option<f64>> {
164    iters
165        .iter()
166        .map(|r| {
167            let v = f(r);
168            if v > 0.0 && v.is_finite() {
169                Some(v.log10())
170            } else {
171                None
172            }
173        })
174        .collect()
175}
176
177fn scan_stalls(
178    series: &[Option<f64>],
179    iters: &[IterRecord],
180    metric: &'static str,
181    min_window: usize,
182    max_log10_progress: f64,
183    out: &mut Vec<Stall>,
184) {
185    let mut i = 0;
186    let n = series.len();
187    while i < n {
188        if series[i].is_none() {
189            i += 1;
190            continue;
191        }
192        // Greedy: extend j while [i..=j] remains a stall.
193        let mut j = i;
194        let mut win_min = series[i].unwrap_or(0.0);
195        let mut win_max = win_min;
196        while j + 1 < n {
197            let Some(next) = series[j + 1] else {
198                break;
199            };
200            let new_min = win_min.min(next);
201            let new_max = win_max.max(next);
202            if new_max - new_min > max_log10_progress {
203                break;
204            }
205            win_min = new_min;
206            win_max = new_max;
207            j += 1;
208        }
209        if j - i + 1 >= min_window {
210            out.push(Stall {
211                start_iter: iters[i].iter,
212                end_iter: iters[j].iter,
213                metric,
214                delta_log10: win_max - win_min,
215            });
216            i = j + 1;
217        } else {
218            i += 1;
219        }
220    }
221}
222
223/// Contiguous runs of iters tagged `'r'` in the alpha-primal char
224/// column — one entry per restoration entry → exit cycle.
225#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
226pub struct RestorationWindow {
227    pub start_iter: i32,
228    pub end_iter: i32,
229}
230
231pub fn restoration_windows(report: &SolveReport) -> Vec<RestorationWindow> {
232    let mut out: Vec<RestorationWindow> = Vec::new();
233    let mut current: Option<RestorationWindow> = None;
234    for r in &report.iterations {
235        if r.alpha_primal_char.to_ascii_lowercase() == 'r' {
236            match &mut current {
237                Some(w) => w.end_iter = r.iter,
238                None => {
239                    current = Some(RestorationWindow {
240                        start_iter: r.iter,
241                        end_iter: r.iter,
242                    })
243                }
244            }
245        } else if let Some(w) = current.take() {
246            out.push(w);
247        }
248    }
249    if let Some(w) = current {
250        out.push(w);
251    }
252    out
253}
254
255#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
256#[serde(rename_all = "lowercase")]
257pub enum Severity {
258    Info,
259    Warning,
260    Error,
261}
262
263/// One finding from [`diagnose`].
264#[derive(Debug, Clone, Serialize, Deserialize)]
265pub struct Finding {
266    pub severity: Severity,
267    /// Stable machine-readable identifier (e.g. `"max_iter_exceeded"`).
268    pub code: &'static str,
269    pub message: String,
270}
271
272/// Run common Ipopt-failure heuristics and return all findings.
273///
274/// Heuristics:
275/// - `converged` (info): solver succeeded
276/// - `max_iter_exceeded` (error): hit max_iter without converging
277/// - `restoration_used` (warning): restoration phase entered ≥1 times
278/// - `restoration_loop` (warning): multiple restoration entries
279/// - `mu_stuck` (warning): barrier parameter barely decreased
280/// - `heavy_line_search` (warning): backtracking ≥10 trials
281/// - `hessian_regularized` (info): δ_w applied on any iter
282/// - `convergence_stall` (warning): suppressed on clean convergence
283///   unless the stall window is long (≥8 iters)
284pub fn diagnose(report: &SolveReport) -> Vec<Finding> {
285    let mut findings = Vec::new();
286    let stats = &report.statistics;
287    let solution = &report.solution;
288    let iters = &report.iterations;
289    let status = solution.status.as_str();
290
291    if status == "SolveSucceeded" {
292        findings.push(Finding {
293            severity: Severity::Info,
294            code: "converged",
295            message: format!(
296                "Solver converged in {} iterations to objective {:.6e}; KKT error {:.2e}.",
297                stats.iteration_count, stats.final_objective, stats.final_kkt_error,
298            ),
299        });
300    } else if status == "MaximumIterationsExceeded" {
301        findings.push(Finding {
302            severity: Severity::Error,
303            code: "max_iter_exceeded",
304            message: format!(
305                "Hit max_iter without converging. KKT error at termination: {:.2e}. \
306                 Consider raising max_iter, tightening initial guess, or relaxing tol.",
307                stats.final_kkt_error,
308            ),
309        });
310    }
311
312    if stats.restoration_calls > 0 {
313        findings.push(Finding {
314            severity: Severity::Warning,
315            code: "restoration_used",
316            message: format!(
317                "Restoration phase entered {} time(s); {} outer iters spent in \
318                 restoration ({:.3}s). Indicates the line search couldn't make \
319                 progress on the original problem.",
320                stats.restoration_calls, stats.restoration_outer_iters, stats.restoration_wall_secs,
321            ),
322        });
323    }
324
325    if iters.len() >= 10 {
326        let mu_first = iters[..3].iter().map(|r| r.mu).fold(0.0_f64, f64::max);
327        let mu_last = iters[iters.len() - 3..]
328            .iter()
329            .map(|r| r.mu)
330            .fold(f64::INFINITY, f64::min);
331        if mu_first > 0.0 && mu_last > 0.0 {
332            let log_drop = mu_first.log10() - mu_last.log10();
333            if log_drop < 1.0 {
334                findings.push(Finding {
335                    severity: Severity::Warning,
336                    code: "mu_stuck",
337                    message: format!(
338                        "Barrier parameter μ dropped only {log_drop:.2} orders of magnitude across \
339                         {} iterations (from {mu_first:.2e} to {mu_last:.2e}). Try \
340                         mu_strategy=adaptive or a smaller mu_init.",
341                        iters.len(),
342                    ),
343                });
344            }
345        }
346    }
347
348    let heavy_ls: Vec<&IterRecord> = iters.iter().filter(|r| r.ls_trials >= 10).collect();
349    if let Some(worst) = heavy_ls.iter().max_by_key(|r| r.ls_trials) {
350        findings.push(Finding {
351            severity: Severity::Warning,
352            code: "heavy_line_search",
353            message: format!(
354                "{} iteration(s) needed >=10 backtracking trials (worst: iter {} with {} \
355                 trials). Search direction quality may be poor — check Hessian accuracy.",
356                heavy_ls.len(),
357                worst.iter,
358                worst.ls_trials,
359            ),
360        });
361    }
362
363    let big_reg: Vec<f64> = iters
364        .iter()
365        .map(|r| r.regularization)
366        .filter(|&r| r > 1e-4)
367        .collect();
368    if !big_reg.is_empty() {
369        let max_reg = big_reg.iter().copied().fold(0.0_f64, f64::max);
370        findings.push(Finding {
371            severity: Severity::Info,
372            code: "hessian_regularized",
373            message: format!(
374                "Hessian regularization applied on {} iteration(s) (max δ_w = {max_reg:.2e}). \
375                 The KKT system was indefinite; this is normal near saddle points but \
376                 persistent regularization suggests a problematic Hessian.",
377                big_reg.len(),
378            ),
379        });
380    }
381
382    let rwins = restoration_windows(report);
383    if rwins.len() > 1 {
384        findings.push(Finding {
385            severity: Severity::Warning,
386            code: "restoration_loop",
387            message: format!(
388                "Restoration was entered {} separate times. Repeated re-entry often means \
389                 the problem is infeasible at the working point. Verify constraints.",
390                rwins.len(),
391            ),
392        });
393    }
394
395    let stalls = find_stalls(report);
396    if !stalls.is_empty() {
397        let longest = stalls
398            .iter()
399            .map(|s| (s.end_iter - s.start_iter + 1) as usize)
400            .max()
401            .unwrap_or(0);
402        if status != "SolveSucceeded" || longest >= 8 {
403            findings.push(Finding {
404                severity: Severity::Warning,
405                code: "convergence_stall",
406                message: format!(
407                    "Detected {} stall window(s) where log-residual barely moved (longest: {} \
408                     iters). Either the problem is ill-conditioned, scaling is off, or \
409                     termination tolerance is too tight.",
410                    stalls.len(),
411                    longest,
412                ),
413            });
414        }
415    }
416
417    findings
418}
419
420/// Augmented [`IterRecord`] returned by [`get_iterate`]: the raw row
421/// plus derived log10 values handy for tooltip / LLM rendering.
422#[derive(Debug, Clone, Serialize, Deserialize)]
423pub struct AugmentedIterate {
424    #[serde(flatten)]
425    pub raw: IterRecord,
426    pub log10_inf_pr: Option<f64>,
427    pub log10_inf_du: Option<f64>,
428    pub log10_mu: Option<f64>,
429}
430
431pub fn get_iterate(report: &SolveReport, k: usize) -> Result<AugmentedIterate, Error> {
432    let n = report.iterations.len();
433    if n == 0 {
434        return Err(Error::NoIterations);
435    }
436    if k >= n {
437        return Err(Error::IterOutOfRange { k, n });
438    }
439    let raw = report.iterations[k].clone();
440    Ok(AugmentedIterate {
441        log10_inf_pr: safe_log10(raw.inf_pr),
442        log10_inf_du: safe_log10(raw.inf_du),
443        log10_mu: safe_log10(raw.mu),
444        raw,
445    })
446}
447
448fn safe_log10(x: f64) -> Option<f64> {
449    if x > 0.0 && x.is_finite() {
450        Some(x.log10())
451    } else {
452        None
453    }
454}
455
456/// One row in a side-by-side comparison.
457#[derive(Debug, Clone, Serialize, Deserialize)]
458pub struct CompareRow {
459    pub label: String,
460    pub status: String,
461    pub iter_count: i32,
462    pub final_objective: f64,
463    pub final_kkt_error: f64,
464    pub restoration_calls: i32,
465    pub elapsed_seconds: f64,
466}
467
468pub fn compare_runs<'a, I>(runs: I) -> Vec<CompareRow>
469where
470    I: IntoIterator<Item = (&'a str, &'a SolveReport)>,
471{
472    runs.into_iter()
473        .map(|(label, r)| CompareRow {
474            label: label.to_string(),
475            status: r.solution.status.clone(),
476            iter_count: r.statistics.iteration_count,
477            final_objective: r.statistics.final_objective,
478            final_kkt_error: r.statistics.final_kkt_error,
479            restoration_calls: r.statistics.restoration_calls,
480            elapsed_seconds: r.fair_metadata.elapsed_seconds,
481        })
482        .collect()
483}
484
485#[cfg(test)]
486mod tests {
487    use super::*;
488    use crate::report::IterRecord;
489
490    fn iter(idx: i32, mu: f64, inf_du: f64) -> IterRecord {
491        IterRecord {
492            iter: idx,
493            inf_du,
494            mu,
495            alpha_primal_char: 'f',
496            ..IterRecord::default()
497        }
498    }
499
500    fn report_with(iters: Vec<IterRecord>) -> SolveReport {
501        use crate::report::*;
502        SolveReport {
503            schema: SOLVE_REPORT_SCHEMA.into(),
504            fair_metadata: FairMetadata {
505                result_id: "t".into(),
506                created_at_iso: "2026-05-24T00:00:00.000Z".into(),
507                created_at_unix_nanos: 0,
508                elapsed_seconds: 0.0,
509                solver: SolverIdentity {
510                    name: "pounce".into(),
511                    version: "0.0.0".into(),
512                    git_commit: None,
513                    target_triple: "test".into(),
514                },
515                license: "EPL-2.0".into(),
516                input: InputDescriptor::TnlpDirect,
517            },
518            problem: ProblemInfo {
519                n_variables: 1,
520                n_constraints: 0,
521                n_objectives: 1,
522                minimize: true,
523                nnz_jac_g: None,
524                nnz_h_lag: None,
525            },
526            solution: SolutionInfo {
527                status: "SolveSucceeded".into(),
528                solve_result_num: 0,
529                objective: 0.0,
530                x: vec![],
531                lambda: vec![],
532                suffixes: vec![],
533            },
534            statistics: StatisticsInfo {
535                iteration_count: iters.len() as i32,
536                final_objective: 0.0,
537                final_scaled_objective: 0.0,
538                final_dual_inf: 0.0,
539                final_constr_viol: 0.0,
540                final_compl: 0.0,
541                final_kkt_error: 0.0,
542                num_obj_evals: 0,
543                num_constr_evals: 0,
544                num_obj_grad_evals: 0,
545                num_constr_jac_evals: 0,
546                num_hess_evals: 0,
547                total_wallclock_time_secs: 0.0,
548                restoration_calls: 0,
549                restoration_inner_iters: 0,
550                restoration_outer_iters: 0,
551                restoration_wall_secs: 0.0,
552            },
553            iterations: iters,
554            linear_solver: None,
555        }
556    }
557
558    #[test]
559    fn stall_detection_flat_residual() {
560        // 5 iters where inf_du barely moves -> one stall.
561        let iters = (0..5)
562            .map(|i| iter(i, 0.1, 1e-3 + (i as f64) * 1e-6))
563            .collect();
564        let stalls = find_stalls(&report_with(iters));
565        assert_eq!(stalls.len(), 1);
566        assert_eq!(stalls[0].start_iter, 0);
567        assert_eq!(stalls[0].end_iter, 4);
568    }
569
570    #[test]
571    fn stall_detection_progress_not_flagged() {
572        // 5 iters where inf_du drops by orders of magnitude each step.
573        let iters = (0..5).map(|i| iter(i, 0.1, 10f64.powi(-i))).collect();
574        let stalls = find_stalls(&report_with(iters));
575        assert!(stalls.is_empty(), "got {stalls:?}");
576    }
577
578    #[test]
579    fn restoration_windows_grouped() {
580        let mut iters = vec![iter(0, 0.1, 1e-2), iter(1, 0.1, 1e-3)];
581        for i in 2..5 {
582            let mut r = iter(i, 0.1, 1e-3);
583            r.alpha_primal_char = 'r';
584            iters.push(r);
585        }
586        iters.push(iter(5, 0.1, 1e-4));
587        let windows = restoration_windows(&report_with(iters));
588        assert_eq!(windows.len(), 1);
589        assert_eq!(windows[0].start_iter, 2);
590        assert_eq!(windows[0].end_iter, 4);
591    }
592
593    #[test]
594    fn get_iterate_out_of_range() {
595        let report = report_with(vec![iter(0, 0.1, 1e-3)]);
596        assert!(matches!(
597            get_iterate(&report, 5),
598            Err(Error::IterOutOfRange { k: 5, n: 1 }),
599        ));
600    }
601
602    #[test]
603    fn diagnose_clean_convergence_no_stall_warning() {
604        // Quick converging run: just the `converged` finding, no stall noise.
605        let iters: Vec<IterRecord> = (0..5)
606            .map(|i| iter(i, 10f64.powi(-(i + 1)), 10f64.powi(-i)))
607            .collect();
608        let findings = diagnose(&report_with(iters));
609        let codes: Vec<&str> = findings.iter().map(|f| f.code).collect();
610        assert!(codes.contains(&"converged"), "got {codes:?}");
611        assert!(
612            !codes.contains(&"convergence_stall"),
613            "stall shouldn't trip on healthy convergence: {codes:?}",
614        );
615    }
616
617    #[test]
618    fn degenerate_min_window_cannot_report_single_iterate_stalls() {
619        // gh: an adversary probe passed `min_window = 0` to a monotonically
620        // converging solve with no stalls and got back one "stall" per
621        // iteration, 18 of 20 with `start_iter == end_iter` and
622        // `delta_log10 == 0`. A stall is an absence of progress BETWEEN
623        // iterations, so a single iterate cannot be one; the window test
624        // `j - i + 1 >= min_window` is just trivially true below 2.
625        //
626        // Front ends now reject `min_window < 2` outright. This asserts the
627        // core's backstop for other Rust callers: it must never manufacture a
628        // degenerate window, whatever it is handed.
629        let iters: Vec<IterRecord> = (0..8)
630            .map(|i| iter(i, 10f64.powi(-(i + 1)), 10f64.powi(-i)))
631            .collect();
632        let report = report_with(iters);
633
634        for bad in [0usize, 1] {
635            let windows = find_stalls_with(&report, bad, 0.3);
636            assert!(
637                windows.iter().all(|w| w.end_iter > w.start_iter),
638                "min_window={bad} produced a degenerate single-iterate window: {windows:?}"
639            );
640            assert_eq!(
641                windows,
642                find_stalls_with(&report, MIN_STALL_WINDOW, 0.3),
643                "min_window={bad} must behave as MIN_STALL_WINDOW, not as its literal value"
644            );
645        }
646        // A healthy monotone solve still reports nothing at the default.
647        assert!(find_stalls(&report).is_empty());
648    }
649}