1use 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#[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 "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#[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 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 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 return Ok(IterSpec::Range(Some(lo), None));
159 }
160 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 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 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#[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#[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 pub fn wants_l_pattern(self) -> bool {
264 matches!(self, KktVariant::WithLPattern | KktVariant::WithLValues)
265 }
266
267 pub fn wants_l_values(self) -> bool {
269 matches!(self, KktVariant::WithLValues)
270 }
271}
272
273pub fn parse_kkt_spec(s: &str) -> Result<(IterSpec, KktVariant), String> {
288 let s = s.trim();
289 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
321pub fn parse_iterate_spec(s: &str) -> Result<(IterSpec, IterateVariant), String> {
334 let s = s.trim();
335 if s == "summary" {
337 return Ok((IterSpec::All, IterateVariant::Summary));
338 }
339 if s == "full" {
340 return Ok((IterSpec::All, IterateVariant::Full));
341 }
342 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#[derive(Debug, Clone)]
363pub struct DiagnosticsConfig {
364 pub dump_dir: PathBuf,
365 pub format: DumpFormat,
366 pub categories: HashMap<DiagCategory, IterSpec>,
367 pub iterate_variant: IterateVariant,
370 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
406pub 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 iterates_writer: RefCell<Option<BufWriter<fs::File>>>,
424}
425
426impl DiagnosticsState {
427 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 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 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 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 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 pub fn in_restoration(&self) -> bool {
505 self.in_restoration.load(Ordering::SeqCst)
506 }
507
508 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 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 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 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 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()); assert!(IterSpec::parse("-x").is_err());
616 assert!(IterSpec::parse("5--10").is_err()); }
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 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 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 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 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 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 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 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 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 assert!(!state.want(DiagCategory::Kkt));
778
779 state.bump_iter(); assert!(!state.want(DiagCategory::Kkt));
781 state.bump_iter(); assert!(!state.want(DiagCategory::Kkt));
783 state.bump_iter(); assert!(state.want(DiagCategory::Kkt));
785 state.bump_iter(); assert!(state.want(DiagCategory::Kkt));
787 state.bump_iter(); assert!(state.want(DiagCategory::Kkt));
789 state.bump_iter(); assert!(!state.want(DiagCategory::Kkt));
791
792 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(); assert_eq!(state.next_solve_index(), 1);
806 assert_eq!(state.next_solve_index(), 2);
807 state.bump_iter(); 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(); state.bump_iter(); state.enter_restoration();
824 state.bump_iter(); 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}