1use serde::{Deserialize, Serialize};
11
12use crate::report::{Error, IterRecord, SolveReport};
13
14#[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#[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#[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
120pub fn find_stalls(report: &SolveReport) -> Vec<Stall> {
123 find_stalls_with(report, 5, 0.3)
124}
125
126pub const MIN_STALL_WINDOW: usize = 2;
136
137pub 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 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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
265pub struct Finding {
266 pub severity: Severity,
267 pub code: &'static str,
269 pub message: String,
270}
271
272pub 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#[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#[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 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 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 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 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 assert!(find_stalls(&report).is_empty());
648 }
649}