Skip to main content

pounce_common/
diagnostics.rs

1//! Diagnostic-dump infrastructure shared by the solver and the CLI.
2//!
3//! # Why this exists
4//!
5//! Debugging a stalled solve or a perf regression usually means
6//! capturing the inner state of the IPM at specific iterations:
7//! the augmented-system KKT matrix, the iterate, the search step,
8//! the line-search trace. Historically this lived as a scatter of
9//! `POUNCE_DBG_*` env-vars across the codebase, each with bespoke
10//! semantics. This module centralizes the surface so the CLI
11//! (`--dump kkt:5-10`) and the dump sites (deep in the linear
12//! solver) speak the same vocabulary.
13//!
14//! # Lifecycle
15//!
16//! 1. The CLI parses `--dump <cat>[:<spec>]` flags into a
17//!    [`DiagnosticsConfig`] and constructs a [`DiagnosticsState`].
18//! 2. The application installs the state via
19//!    `IpoptApplication::set_diagnostics`, then propagates an
20//!    `Rc<DiagnosticsState>` through the solve in the same way
21//!    [`crate::timing::TimingStatistics`] is propagated.
22//! 3. At the top of each outer iteration, the IPM calls
23//!    [`DiagnosticsState::bump_iter`] to advance the current-iter
24//!    counter and reset the per-iter solve index.
25//! 4. Every dump site (KKT solver, line search, μ oracle, ...) calls
26//!    [`DiagnosticsState::want`] to gate the dump, then
27//!    [`DiagnosticsState::open_writer`] to obtain a file handle in
28//!    the right `iter_NNN/` sub-directory.
29//!
30//! # File layout
31//!
32//! ```text
33//! <dump_dir>/
34//!   manifest.json
35//!   iter_005/
36//!     kkt_solve_001.jsonl
37//!     iterate.json
38//!   iter_006/...
39//!   resto/
40//!     parent_iter_007/
41//!       iter_000/kkt_solve_001.jsonl
42//!   timing.json
43//! ```
44//!
45//! The `solve_NNN` suffix disambiguates the multi-solve-per-iter case
46//! (second-order corrections and perturbation re-solves issue extra
47//! factorizations inside one outer iteration). The
48//! `resto/parent_iter_NNN/` hierarchy keeps the restoration sub-IPM
49//! trace separate from the main solve trace.
50
51use std::cell::RefCell;
52use std::collections::HashMap;
53use std::fs;
54use std::io::{BufWriter, Write};
55use std::path::{Path, PathBuf};
56use std::sync::atomic::{AtomicBool, AtomicI32, Ordering};
57
58/// Single diagnostic category the user can request.
59///
60/// Categories map roughly one-to-one to dump sites in the solver.
61/// `Kkt` is the only one actually wired in PR-A; the rest are
62/// declared up front so `--dump iterate:all` parses today and the
63/// follow-up PRs only need to flip the dump-site switch.
64#[derive(Debug, Hash, Eq, PartialEq, Clone, Copy)]
65pub enum DiagCategory {
66    Kkt,
67    Iterate,
68    Step,
69    Mu,
70    Ls,
71    Resto,
72    Convergence,
73    Timing,
74}
75
76impl DiagCategory {
77    pub fn as_str(self) -> &'static str {
78        match self {
79            DiagCategory::Kkt => "kkt",
80            DiagCategory::Iterate => "iterate",
81            DiagCategory::Step => "step",
82            DiagCategory::Mu => "mu",
83            DiagCategory::Ls => "ls",
84            DiagCategory::Resto => "resto",
85            DiagCategory::Convergence => "convergence",
86            DiagCategory::Timing => "timing",
87        }
88    }
89
90    pub fn parse(s: &str) -> Result<Self, String> {
91        match s {
92            "kkt" => Ok(DiagCategory::Kkt),
93            // Accept both "iterate" (legacy) and "iterates" (the
94            // public name from issue #68's contract). Internally one
95            // enum variant.
96            "iterate" | "iterates" => Ok(DiagCategory::Iterate),
97            "step" => Ok(DiagCategory::Step),
98            "mu" => Ok(DiagCategory::Mu),
99            "ls" => Ok(DiagCategory::Ls),
100            "resto" => Ok(DiagCategory::Resto),
101            "convergence" => Ok(DiagCategory::Convergence),
102            "timing" => Ok(DiagCategory::Timing),
103            other => Err(format!(
104                "unknown dump category '{other}' (expected one of: kkt, iterate, step, mu, ls, resto, convergence, timing)"
105            )),
106        }
107    }
108}
109
110/// Iteration filter attached to a category. `None` endpoints denote
111/// open-ended ranges (`N-` is `Range(Some(N), None)`).
112#[derive(Debug, Clone, Copy, PartialEq, Eq)]
113pub enum IterSpec {
114    All,
115    Single(i32),
116    Range(Option<i32>, Option<i32>),
117}
118
119impl IterSpec {
120    pub fn includes(&self, iter: i32) -> bool {
121        match self {
122            IterSpec::All => true,
123            IterSpec::Single(n) => iter == *n,
124            IterSpec::Range(lo, hi) => lo.is_none_or(|l| iter >= l) && hi.is_none_or(|h| iter <= h),
125        }
126    }
127
128    /// Parse the grammar `all | N | N-M | N- | -M`. Negative ints
129    /// aren't accepted — iter counts are non-negative by definition.
130    pub fn parse(s: &str) -> Result<Self, String> {
131        let s = s.trim();
132        if s.is_empty() || s == "all" {
133            return Ok(IterSpec::All);
134        }
135        if let Some(rest) = s.strip_prefix('-') {
136            // "-M"
137            let hi: i32 = rest.parse().map_err(|_| {
138                format!("invalid iter-spec '{s}': expected '-M' with non-negative integer M")
139            })?;
140            if hi < 0 {
141                return Err(format!(
142                    "invalid iter-spec '{s}': iter must be non-negative"
143                ));
144            }
145            return Ok(IterSpec::Range(None, Some(hi)));
146        }
147        if let Some((a, b)) = s.split_once('-') {
148            let lo: i32 = a
149                .parse()
150                .map_err(|_| format!("invalid iter-spec '{s}': '{a}' is not an integer"))?;
151            if lo < 0 {
152                return Err(format!(
153                    "invalid iter-spec '{s}': iter must be non-negative"
154                ));
155            }
156            if b.is_empty() {
157                // "N-"
158                return Ok(IterSpec::Range(Some(lo), None));
159            }
160            // "N-M"
161            let hi: i32 = b
162                .parse()
163                .map_err(|_| format!("invalid iter-spec '{s}': '{b}' is not an integer"))?;
164            if hi < 0 {
165                return Err(format!(
166                    "invalid iter-spec '{s}': iter must be non-negative"
167                ));
168            }
169            if hi < lo {
170                return Err(format!(
171                    "invalid iter-spec '{s}': end ({hi}) is below start ({lo})"
172                ));
173            }
174            return Ok(IterSpec::Range(Some(lo), Some(hi)));
175        }
176        // Bare "N"
177        let n: i32 = s.parse().map_err(|_| {
178            format!("invalid iter-spec '{s}': expected 'all', 'N', 'N-M', 'N-', or '-M'")
179        })?;
180        if n < 0 {
181            return Err(format!(
182                "invalid iter-spec '{s}': iter must be non-negative"
183            ));
184        }
185        Ok(IterSpec::Single(n))
186    }
187}
188
189#[derive(Debug, Clone, Copy, PartialEq, Eq)]
190pub enum DumpFormat {
191    /// Newline-delimited JSON records. One record per dump call.
192    /// Hackable from a shell one-liner; the default.
193    Jsonl,
194}
195
196impl DumpFormat {
197    pub fn parse(s: &str) -> Result<Self, String> {
198        match s {
199            "jsonl" => Ok(DumpFormat::Jsonl),
200            other => Err(format!("unknown dump format '{other}' (expected: jsonl)")),
201        }
202    }
203}
204
205/// Payload-detail variant for the `iterate` dump category.
206///
207/// Iterate trajectories come in two sizes. `Summary` is small and
208/// always cheap (m bits of active-set bitmap + a handful of scalars
209/// per iteration); `Full` adds the full `x` and `slack` vectors per
210/// iteration, which is the studio-grade payload but can run into
211/// hundreds of MB on large problems.
212#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
213pub enum IterateVariant {
214    #[default]
215    Summary,
216    Full,
217}
218
219impl IterateVariant {
220    pub fn as_str(self) -> &'static str {
221        match self {
222            IterateVariant::Summary => "summary",
223            IterateVariant::Full => "full",
224        }
225    }
226}
227
228/// Payload-detail variant for the `kkt` dump category.
229///
230/// `KOnly` (the default) emits only the K matrix and the solve's
231/// RHS/solution. `WithLPattern` additionally emits the LDLᵀ factor's
232/// strict-lower nonzero pattern (`L_irn` / `L_jcn`) and the fill-
233/// reducing permutation `perm`. `WithLValues` further adds `L_vals`
234/// in the same order as the pattern.
235///
236/// The L fields are emitted in *permuted* coordinates — the column /
237/// row indices reference the permuted system K' = Pᵀ K P, and the
238/// `perm` array carries the mapping back to original-variable space
239/// (`perm[k] = original_row` for the k-th permuted row).
240///
241/// Backends that don't expose the factor pattern (e.g. MA57) silently
242/// skip the L fields even when this variant requests them.
243#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
244pub enum KktVariant {
245    #[default]
246    KOnly,
247    WithLPattern,
248    WithLValues,
249}
250
251impl KktVariant {
252    pub fn as_str(self) -> &'static str {
253        match self {
254            KktVariant::KOnly => "k-only",
255            KktVariant::WithLPattern => "with-l-pattern",
256            KktVariant::WithLValues => "with-l-values",
257        }
258    }
259
260    /// True if the variant asks for the L pattern (with or without
261    /// values). Used by the dump site to short-circuit the
262    /// `factor_pattern()` call when only K is wanted.
263    pub fn wants_l_pattern(self) -> bool {
264        matches!(self, KktVariant::WithLPattern | KktVariant::WithLValues)
265    }
266
267    /// True if the variant asks for the L numerical values.
268    pub fn wants_l_values(self) -> bool {
269        matches!(self, KktVariant::WithLValues)
270    }
271}
272
273/// Parse the `kkt:` spec grammar — `[<iter-filter>][+L][+Lvals]`.
274///
275/// Recognised forms:
276///
277/// - empty / `all` / `5` / `5-10` / `5-` / `-10` → corresponding
278///   filter, K-only (no L pattern).
279/// - `<filter>+L` → K + LDLᵀ pattern + permutation.
280/// - `<filter>+L+Lvals` → K + L pattern + L values.
281/// - bare `+L` / `+L+Lvals` → all iters with the requested variant.
282///
283/// The suffixes are stripped *right-to-left* so the order
284/// `<filter>+L+Lvals` is the only accepted spelling for values; the
285/// reverse (`+Lvals+L`) is not recognised. `+Lvals` without `+L` is
286/// also rejected — values without a pattern is meaningless.
287pub fn parse_kkt_spec(s: &str) -> Result<(IterSpec, KktVariant), String> {
288    let s = s.trim();
289    // Strip `+Lvals` first (rightmost suffix), then `+L`. The order
290    // matters: `+L+Lvals` should parse as both, `+L` alone as pattern-
291    // only, `+Lvals` alone as an error.
292    let (rest, has_lvals) = match s.strip_suffix("+Lvals") {
293        Some(r) => (r, true),
294        None => (s, false),
295    };
296    let (filter_str, has_l) = match rest.strip_suffix("+L") {
297        Some(r) => (r, true),
298        None => (rest, false),
299    };
300    if has_lvals && !has_l {
301        return Err(format!(
302            "invalid kkt-spec '{s}': '+Lvals' requires '+L' (use '+L+Lvals' for L pattern with values)"
303        ));
304    }
305    let variant = if has_lvals {
306        KktVariant::WithLValues
307    } else if has_l {
308        KktVariant::WithLPattern
309    } else {
310        KktVariant::KOnly
311    };
312    let filter_str = if filter_str.is_empty() {
313        "all"
314    } else {
315        filter_str
316    };
317    let filter = IterSpec::parse(filter_str)?;
318    Ok((filter, variant))
319}
320
321/// Parse the `iterate:` spec grammar — `[<iter-filter>[:<variant>]]`.
322///
323/// Recognised forms:
324///
325/// - empty / `all` / `5` / `5-10` / `5-` / `-10` → corresponding
326///   filter, variant defaults to `summary`.
327/// - `summary` / `full` → all iters, named variant.
328/// - `<filter>:summary` / `<filter>:full` → both.
329///
330/// This is the only `DiagCategory` whose spec carries more than an
331/// iter filter. Keeping the parser local to the iterate site avoids
332/// growing every category's grammar.
333pub fn parse_iterate_spec(s: &str) -> Result<(IterSpec, IterateVariant), String> {
334    let s = s.trim();
335    // Bare `summary` / `full` (no filter portion).
336    if s == "summary" {
337        return Ok((IterSpec::All, IterateVariant::Summary));
338    }
339    if s == "full" {
340        return Ok((IterSpec::All, IterateVariant::Full));
341    }
342    // `<filter>:summary` / `<filter>:full`.
343    let (filter_str, variant) = if let Some(rest) = s.strip_suffix(":summary") {
344        (rest, IterateVariant::Summary)
345    } else if let Some(rest) = s.strip_suffix(":full") {
346        (rest, IterateVariant::Full)
347    } else {
348        (s, IterateVariant::Summary)
349    };
350    let filter_str = if filter_str.is_empty() {
351        "all"
352    } else {
353        filter_str
354    };
355    let filter = IterSpec::parse(filter_str)?;
356    Ok((filter, variant))
357}
358
359/// Static configuration: where to dump, in what format, with what
360/// per-category iter filters. Constructed by the CLI, held by the
361/// application, frozen for the duration of a solve.
362#[derive(Debug, Clone)]
363pub struct DiagnosticsConfig {
364    pub dump_dir: PathBuf,
365    pub format: DumpFormat,
366    pub categories: HashMap<DiagCategory, IterSpec>,
367    /// Payload-detail for `DiagCategory::Iterate`. Only consulted
368    /// when `Iterate` is in `categories`.
369    pub iterate_variant: IterateVariant,
370    /// Payload-detail for `DiagCategory::Kkt`. Only consulted when
371    /// `Kkt` is in `categories`.
372    pub kkt_variant: KktVariant,
373}
374
375impl DiagnosticsConfig {
376    pub fn new(dump_dir: PathBuf) -> Self {
377        Self {
378            dump_dir,
379            format: DumpFormat::Jsonl,
380            categories: HashMap::new(),
381            iterate_variant: IterateVariant::Summary,
382            kkt_variant: KktVariant::KOnly,
383        }
384    }
385
386    pub fn with_category(mut self, cat: DiagCategory, spec: IterSpec) -> Self {
387        self.categories.insert(cat, spec);
388        self
389    }
390
391    pub fn with_iterate_variant(mut self, v: IterateVariant) -> Self {
392        self.iterate_variant = v;
393        self
394    }
395
396    pub fn with_kkt_variant(mut self, v: KktVariant) -> Self {
397        self.kkt_variant = v;
398        self
399    }
400
401    pub fn is_empty(&self) -> bool {
402        self.categories.is_empty()
403    }
404}
405
406/// Live state threaded through the solve via `Rc`. The IPM mutates
407/// `current_iter` and `solves_this_iter`; the dump sites read them.
408/// All fields use atomics so the type is `Send + Sync` even though
409/// the solver itself is single-threaded — keeps the door open for
410/// future parallel inner solvers without an ABI rewrite.
411pub struct DiagnosticsState {
412    pub config: DiagnosticsConfig,
413    current_iter: AtomicI32,
414    solves_this_iter: AtomicI32,
415    in_restoration: AtomicBool,
416    resto_parent_iter: AtomicI32,
417    resto_inner_iter: AtomicI32,
418    resto_solves_this_iter: AtomicI32,
419    /// Lazily-opened, persistent writer for the top-level
420    /// `iterates.jsonl` stream. Opened on the first iterate emit and
421    /// kept open across iterations (and through resto) so each
422    /// outer/inner iteration appends one line in order.
423    iterates_writer: RefCell<Option<BufWriter<fs::File>>>,
424}
425
426impl DiagnosticsState {
427    /// Create a state and `mkdir -p` the dump directory. Failure to
428    /// create the directory bubbles up so the CLI can exit with a
429    /// clear error before the solve even starts.
430    pub fn new(config: DiagnosticsConfig) -> std::io::Result<Self> {
431        fs::create_dir_all(&config.dump_dir)?;
432        Ok(Self {
433            config,
434            current_iter: AtomicI32::new(-1),
435            solves_this_iter: AtomicI32::new(0),
436            in_restoration: AtomicBool::new(false),
437            resto_parent_iter: AtomicI32::new(-1),
438            resto_inner_iter: AtomicI32::new(-1),
439            resto_solves_this_iter: AtomicI32::new(0),
440            iterates_writer: RefCell::new(None),
441        })
442    }
443
444    /// True if the caller should dump `cat` at the current iter.
445    pub fn want(&self, cat: DiagCategory) -> bool {
446        let iter = self.effective_iter();
447        if iter < 0 {
448            return false;
449        }
450        self.config
451            .categories
452            .get(&cat)
453            .map(|spec| spec.includes(iter))
454            .unwrap_or(false)
455    }
456
457    /// Advance the outer-iteration counter and reset the per-iter
458    /// solve index. Called by `IpoptAlgorithm::optimize` at the top
459    /// of each outer iteration.
460    pub fn bump_iter(&self) {
461        if self.in_restoration.load(Ordering::SeqCst) {
462            self.resto_inner_iter.fetch_add(1, Ordering::SeqCst);
463            self.resto_solves_this_iter.store(0, Ordering::SeqCst);
464        } else {
465            self.current_iter.fetch_add(1, Ordering::SeqCst);
466            self.solves_this_iter.store(0, Ordering::SeqCst);
467        }
468    }
469
470    /// Reserve the next per-iter solve index. Returned value is
471    /// 1-based to match the filenames (`kkt_solve_001.jsonl`).
472    pub fn next_solve_index(&self) -> i32 {
473        let counter = if self.in_restoration.load(Ordering::SeqCst) {
474            &self.resto_solves_this_iter
475        } else {
476            &self.solves_this_iter
477        };
478        counter.fetch_add(1, Ordering::SeqCst) + 1
479    }
480
481    /// Mark the start of a restoration sub-IPM run. `parent_iter` is
482    /// the outer iter that triggered restoration; dumps from the
483    /// resto sub-solve land under `resto/parent_iter_NNN/iter_MMM/`.
484    pub fn enter_restoration(&self) {
485        let parent = self.current_iter.load(Ordering::SeqCst);
486        self.resto_parent_iter.store(parent, Ordering::SeqCst);
487        self.resto_inner_iter.store(-1, Ordering::SeqCst);
488        self.resto_solves_this_iter.store(0, Ordering::SeqCst);
489        self.in_restoration.store(true, Ordering::SeqCst);
490    }
491
492    pub fn exit_restoration(&self) {
493        self.in_restoration.store(false, Ordering::SeqCst);
494    }
495
496    pub fn current_iter(&self) -> i32 {
497        self.effective_iter()
498    }
499
500    /// True if the solver is currently inside a restoration sub-IPM
501    /// run. Public, side-effect-free probe for emitters that need to
502    /// tag rows with the restoration flag without mkdir-ing the iter
503    /// directory (which `iter_dir` does).
504    pub fn in_restoration(&self) -> bool {
505        self.in_restoration.load(Ordering::SeqCst)
506    }
507
508    /// The iter counter that gates current dumps — resto inner iter
509    /// when in restoration, main outer iter otherwise.
510    fn effective_iter(&self) -> i32 {
511        if self.in_restoration.load(Ordering::SeqCst) {
512            self.resto_inner_iter.load(Ordering::SeqCst)
513        } else {
514            self.current_iter.load(Ordering::SeqCst)
515        }
516    }
517
518    /// Resolve the directory a category's dump file should live in,
519    /// creating it if necessary. Returns `None` if the directory
520    /// cannot be created (e.g., filesystem full) — callers should
521    /// silently skip the dump in that case rather than fail the
522    /// solve.
523    pub fn iter_dir(&self) -> Option<PathBuf> {
524        let dir = if self.in_restoration.load(Ordering::SeqCst) {
525            let parent = self.resto_parent_iter.load(Ordering::SeqCst);
526            let inner = self.resto_inner_iter.load(Ordering::SeqCst).max(0);
527            self.config
528                .dump_dir
529                .join(format!("resto/parent_iter_{parent:03}/iter_{inner:03}"))
530        } else {
531            let iter = self.current_iter.load(Ordering::SeqCst).max(0);
532            self.config.dump_dir.join(format!("iter_{iter:03}"))
533        };
534        fs::create_dir_all(&dir).ok()?;
535        Some(dir)
536    }
537
538    /// Open a writer for `<iter_dir>/<filename>`. Caller picks the
539    /// filename so callers that produce multi-solve traces can use
540    /// `next_solve_index` to disambiguate.
541    pub fn open_writer(&self, filename: &str) -> Option<BufWriter<fs::File>> {
542        let dir = self.iter_dir()?;
543        let path = dir.join(filename);
544        fs::File::create(path).ok().map(BufWriter::new)
545    }
546
547    /// Write a one-shot top-level file (manifest, timing summary).
548    /// Always lands directly under `dump_dir`, never under an iter
549    /// sub-directory.
550    pub fn write_top_level(&self, filename: &str, contents: &str) -> std::io::Result<()> {
551        let path = self.config.dump_dir.join(filename);
552        let mut f = fs::File::create(path)?;
553        f.write_all(contents.as_bytes())?;
554        f.flush()
555    }
556
557    /// Append one JSONL line to the persistent top-level
558    /// `iterates.jsonl` stream, opening the file on first use. The
559    /// writer is held across iterations so the emitter doesn't
560    /// re-open the file every step (which would also truncate it).
561    ///
562    /// Caller supplies the already-encoded JSON record without a
563    /// trailing newline; this method appends `\n` and flushes the
564    /// buffer so a `SIGKILL`'d solve still leaves a parseable
565    /// partial trace.
566    pub fn append_iterate_line(&self, json: &str) -> std::io::Result<()> {
567        let mut slot = self.iterates_writer.borrow_mut();
568        if slot.is_none() {
569            let path = self.config.dump_dir.join("iterates.jsonl");
570            let f = fs::OpenOptions::new()
571                .create(true)
572                .truncate(true)
573                .write(true)
574                .open(path)?;
575            *slot = Some(BufWriter::new(f));
576        }
577        let w = slot.as_mut().expect("just initialized");
578        w.write_all(json.as_bytes())?;
579        w.write_all(b"\n")?;
580        w.flush()
581    }
582
583    pub fn dump_dir(&self) -> &Path {
584        &self.config.dump_dir
585    }
586}
587
588#[cfg(test)]
589mod tests {
590    use super::*;
591
592    #[test]
593    fn iter_spec_parses_all_grammar_forms() {
594        assert_eq!(IterSpec::parse("").unwrap(), IterSpec::All);
595        assert_eq!(IterSpec::parse("all").unwrap(), IterSpec::All);
596        assert_eq!(IterSpec::parse("5").unwrap(), IterSpec::Single(5));
597        assert_eq!(
598            IterSpec::parse("5-10").unwrap(),
599            IterSpec::Range(Some(5), Some(10))
600        );
601        assert_eq!(
602            IterSpec::parse("5-").unwrap(),
603            IterSpec::Range(Some(5), None)
604        );
605        assert_eq!(
606            IterSpec::parse("-10").unwrap(),
607            IterSpec::Range(None, Some(10))
608        );
609    }
610
611    #[test]
612    fn iter_spec_rejects_malformed_input() {
613        assert!(IterSpec::parse("abc").is_err());
614        assert!(IterSpec::parse("5-3").is_err()); // end below start
615        assert!(IterSpec::parse("-x").is_err());
616        assert!(IterSpec::parse("5--10").is_err()); // doubled separator → "-10" tail parse fails
617    }
618
619    #[test]
620    fn iter_spec_includes_matches_grammar() {
621        assert!(IterSpec::All.includes(0));
622        assert!(IterSpec::All.includes(1000));
623        assert!(IterSpec::Single(5).includes(5));
624        assert!(!IterSpec::Single(5).includes(4));
625        let r = IterSpec::Range(Some(5), Some(10));
626        assert!(!r.includes(4));
627        assert!(r.includes(5));
628        assert!(r.includes(7));
629        assert!(r.includes(10));
630        assert!(!r.includes(11));
631        assert!(IterSpec::Range(Some(5), None).includes(1_000_000));
632        assert!(IterSpec::Range(None, Some(5)).includes(0));
633    }
634
635    #[test]
636    fn category_parses_known_names() {
637        assert_eq!(DiagCategory::parse("kkt").unwrap(), DiagCategory::Kkt);
638        assert_eq!(
639            DiagCategory::parse("iterate").unwrap(),
640            DiagCategory::Iterate
641        );
642        assert!(DiagCategory::parse("bogus").is_err());
643    }
644
645    #[test]
646    fn iterate_spec_parses_all_combinations() {
647        // Bare variant words: "all" filter, named variant.
648        assert_eq!(
649            parse_iterate_spec("summary").unwrap(),
650            (IterSpec::All, IterateVariant::Summary)
651        );
652        assert_eq!(
653            parse_iterate_spec("full").unwrap(),
654            (IterSpec::All, IterateVariant::Full)
655        );
656        // Plain filter: defaults variant to Summary.
657        assert_eq!(
658            parse_iterate_spec("all").unwrap(),
659            (IterSpec::All, IterateVariant::Summary)
660        );
661        assert_eq!(
662            parse_iterate_spec("5").unwrap(),
663            (IterSpec::Single(5), IterateVariant::Summary)
664        );
665        assert_eq!(
666            parse_iterate_spec("5-10").unwrap(),
667            (IterSpec::Range(Some(5), Some(10)), IterateVariant::Summary)
668        );
669        // Filter + variant.
670        assert_eq!(
671            parse_iterate_spec("all:summary").unwrap(),
672            (IterSpec::All, IterateVariant::Summary)
673        );
674        assert_eq!(
675            parse_iterate_spec("all:full").unwrap(),
676            (IterSpec::All, IterateVariant::Full)
677        );
678        assert_eq!(
679            parse_iterate_spec("5-:full").unwrap(),
680            (IterSpec::Range(Some(5), None), IterateVariant::Full)
681        );
682        assert_eq!(
683            parse_iterate_spec("10-20:full").unwrap(),
684            (IterSpec::Range(Some(10), Some(20)), IterateVariant::Full)
685        );
686    }
687
688    #[test]
689    fn append_iterate_line_streams_rows_to_top_level() {
690        let tmp = tempdir();
691        let cfg =
692            DiagnosticsConfig::new(tmp.clone()).with_category(DiagCategory::Iterate, IterSpec::All);
693        let state = DiagnosticsState::new(cfg).unwrap();
694        state.append_iterate_line("{\"iter\":0}").unwrap();
695        state.append_iterate_line("{\"iter\":1}").unwrap();
696        // Resto rows live inline in the same stream — the writer
697        // doesn't care about the iter-dir nesting that other dump
698        // sites use.
699        state.enter_restoration();
700        state
701            .append_iterate_line("{\"iter\":0,\"restoration\":true}")
702            .unwrap();
703        state.exit_restoration();
704        state.append_iterate_line("{\"iter\":2}").unwrap();
705
706        let path = tmp.join("iterates.jsonl");
707        let contents = fs::read_to_string(&path).unwrap();
708        let lines: Vec<&str> = contents.lines().collect();
709        assert_eq!(lines.len(), 4);
710        assert_eq!(lines[0], "{\"iter\":0}");
711        assert_eq!(lines[2], "{\"iter\":0,\"restoration\":true}");
712        fs::remove_dir_all(tmp).ok();
713    }
714
715    #[test]
716    fn kkt_spec_parses_all_combinations() {
717        // Empty / bare filter → K-only.
718        assert_eq!(
719            parse_kkt_spec("").unwrap(),
720            (IterSpec::All, KktVariant::KOnly)
721        );
722        assert_eq!(
723            parse_kkt_spec("all").unwrap(),
724            (IterSpec::All, KktVariant::KOnly)
725        );
726        assert_eq!(
727            parse_kkt_spec("5-10").unwrap(),
728            (IterSpec::Range(Some(5), Some(10)), KktVariant::KOnly)
729        );
730        // +L pattern only.
731        assert_eq!(
732            parse_kkt_spec("+L").unwrap(),
733            (IterSpec::All, KktVariant::WithLPattern)
734        );
735        assert_eq!(
736            parse_kkt_spec("5-10+L").unwrap(),
737            (IterSpec::Range(Some(5), Some(10)), KktVariant::WithLPattern)
738        );
739        assert_eq!(
740            parse_kkt_spec("3+L").unwrap(),
741            (IterSpec::Single(3), KktVariant::WithLPattern)
742        );
743        // +L+Lvals.
744        assert_eq!(
745            parse_kkt_spec("+L+Lvals").unwrap(),
746            (IterSpec::All, KktVariant::WithLValues)
747        );
748        assert_eq!(
749            parse_kkt_spec("5-10+L+Lvals").unwrap(),
750            (IterSpec::Range(Some(5), Some(10)), KktVariant::WithLValues)
751        );
752    }
753
754    #[test]
755    fn kkt_spec_rejects_lvals_without_l() {
756        assert!(parse_kkt_spec("+Lvals").is_err());
757        assert!(parse_kkt_spec("5-10+Lvals").is_err());
758    }
759
760    #[test]
761    fn iterate_spec_rejects_garbage_and_unknown_variants() {
762        // Unknown variant after the colon: the parser strips no
763        // suffix, falls back to whole-string filter parsing, which
764        // then fails because "5-:bogus" is not a valid iter spec.
765        assert!(parse_iterate_spec("5-:bogus").is_err());
766        assert!(parse_iterate_spec("abc").is_err());
767    }
768
769    #[test]
770    fn state_gates_on_iter_spec() {
771        let tmp = tempdir();
772        let cfg = DiagnosticsConfig::new(tmp.clone())
773            .with_category(DiagCategory::Kkt, IterSpec::Range(Some(2), Some(4)));
774        let state = DiagnosticsState::new(cfg).unwrap();
775
776        // Before bump_iter, current_iter == -1 → no dumps.
777        assert!(!state.want(DiagCategory::Kkt));
778
779        state.bump_iter(); // iter 0
780        assert!(!state.want(DiagCategory::Kkt));
781        state.bump_iter(); // 1
782        assert!(!state.want(DiagCategory::Kkt));
783        state.bump_iter(); // 2
784        assert!(state.want(DiagCategory::Kkt));
785        state.bump_iter(); // 3
786        assert!(state.want(DiagCategory::Kkt));
787        state.bump_iter(); // 4
788        assert!(state.want(DiagCategory::Kkt));
789        state.bump_iter(); // 5
790        assert!(!state.want(DiagCategory::Kkt));
791
792        // Other categories silently skipped (not configured).
793        assert!(!state.want(DiagCategory::Iterate));
794
795        fs::remove_dir_all(tmp).ok();
796    }
797
798    #[test]
799    fn state_emits_solve_indices_and_iter_dirs() {
800        let tmp = tempdir();
801        let cfg =
802            DiagnosticsConfig::new(tmp.clone()).with_category(DiagCategory::Kkt, IterSpec::All);
803        let state = DiagnosticsState::new(cfg).unwrap();
804        state.bump_iter(); // iter 0
805        assert_eq!(state.next_solve_index(), 1);
806        assert_eq!(state.next_solve_index(), 2);
807        state.bump_iter(); // iter 1
808        assert_eq!(state.next_solve_index(), 1);
809
810        let dir = state.iter_dir().unwrap();
811        assert!(dir.ends_with("iter_001"));
812        fs::remove_dir_all(tmp).ok();
813    }
814
815    #[test]
816    fn restoration_dumps_live_under_resto_subtree() {
817        let tmp = tempdir();
818        let cfg =
819            DiagnosticsConfig::new(tmp.clone()).with_category(DiagCategory::Kkt, IterSpec::All);
820        let state = DiagnosticsState::new(cfg).unwrap();
821        state.bump_iter(); // main iter 0
822        state.bump_iter(); // main iter 1
823        state.enter_restoration();
824        state.bump_iter(); // resto inner 0
825        let dir = state.iter_dir().unwrap();
826        assert!(
827            dir.ends_with("resto/parent_iter_001/iter_000"),
828            "got {dir:?}"
829        );
830        assert_eq!(state.next_solve_index(), 1);
831        state.exit_restoration();
832        let dir = state.iter_dir().unwrap();
833        assert!(dir.ends_with("iter_001"), "got {dir:?}");
834        fs::remove_dir_all(tmp).ok();
835    }
836
837    fn tempdir() -> PathBuf {
838        let p = std::env::temp_dir().join(format!(
839            "pounce-diag-test-{}-{}",
840            std::process::id(),
841            std::time::SystemTime::now()
842                .duration_since(std::time::UNIX_EPOCH)
843                .unwrap()
844                .as_nanos()
845        ));
846        fs::create_dir_all(&p).unwrap();
847        p
848    }
849}