use std::collections::HashMap;
use std::fs;
use std::io::{BufWriter, Write};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, AtomicI32, Ordering};
#[derive(Debug, Hash, Eq, PartialEq, Clone, Copy)]
pub enum DiagCategory {
Kkt,
Iterate,
Step,
Mu,
Ls,
Resto,
Convergence,
Timing,
}
impl DiagCategory {
pub fn as_str(self) -> &'static str {
match self {
DiagCategory::Kkt => "kkt",
DiagCategory::Iterate => "iterate",
DiagCategory::Step => "step",
DiagCategory::Mu => "mu",
DiagCategory::Ls => "ls",
DiagCategory::Resto => "resto",
DiagCategory::Convergence => "convergence",
DiagCategory::Timing => "timing",
}
}
pub fn parse(s: &str) -> Result<Self, String> {
match s {
"kkt" => Ok(DiagCategory::Kkt),
"iterate" => Ok(DiagCategory::Iterate),
"step" => Ok(DiagCategory::Step),
"mu" => Ok(DiagCategory::Mu),
"ls" => Ok(DiagCategory::Ls),
"resto" => Ok(DiagCategory::Resto),
"convergence" => Ok(DiagCategory::Convergence),
"timing" => Ok(DiagCategory::Timing),
other => Err(format!(
"unknown dump category '{other}' (expected one of: kkt, iterate, step, mu, ls, resto, convergence, timing)"
)),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum IterSpec {
All,
Single(i32),
Range(Option<i32>, Option<i32>),
}
impl IterSpec {
pub fn includes(&self, iter: i32) -> bool {
match self {
IterSpec::All => true,
IterSpec::Single(n) => iter == *n,
IterSpec::Range(lo, hi) => lo.is_none_or(|l| iter >= l) && hi.is_none_or(|h| iter <= h),
}
}
pub fn parse(s: &str) -> Result<Self, String> {
let s = s.trim();
if s.is_empty() || s == "all" {
return Ok(IterSpec::All);
}
if let Some(rest) = s.strip_prefix('-') {
let hi: i32 = rest.parse().map_err(|_| {
format!("invalid iter-spec '{s}': expected '-M' with non-negative integer M")
})?;
if hi < 0 {
return Err(format!(
"invalid iter-spec '{s}': iter must be non-negative"
));
}
return Ok(IterSpec::Range(None, Some(hi)));
}
if let Some((a, b)) = s.split_once('-') {
let lo: i32 = a
.parse()
.map_err(|_| format!("invalid iter-spec '{s}': '{a}' is not an integer"))?;
if lo < 0 {
return Err(format!(
"invalid iter-spec '{s}': iter must be non-negative"
));
}
if b.is_empty() {
return Ok(IterSpec::Range(Some(lo), None));
}
let hi: i32 = b
.parse()
.map_err(|_| format!("invalid iter-spec '{s}': '{b}' is not an integer"))?;
if hi < 0 {
return Err(format!(
"invalid iter-spec '{s}': iter must be non-negative"
));
}
if hi < lo {
return Err(format!(
"invalid iter-spec '{s}': end ({hi}) is below start ({lo})"
));
}
return Ok(IterSpec::Range(Some(lo), Some(hi)));
}
let n: i32 = s.parse().map_err(|_| {
format!("invalid iter-spec '{s}': expected 'all', 'N', 'N-M', 'N-', or '-M'")
})?;
if n < 0 {
return Err(format!(
"invalid iter-spec '{s}': iter must be non-negative"
));
}
Ok(IterSpec::Single(n))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DumpFormat {
Jsonl,
}
impl DumpFormat {
pub fn parse(s: &str) -> Result<Self, String> {
match s {
"jsonl" => Ok(DumpFormat::Jsonl),
other => Err(format!("unknown dump format '{other}' (expected: jsonl)")),
}
}
}
#[derive(Debug, Clone)]
pub struct DiagnosticsConfig {
pub dump_dir: PathBuf,
pub format: DumpFormat,
pub categories: HashMap<DiagCategory, IterSpec>,
}
impl DiagnosticsConfig {
pub fn new(dump_dir: PathBuf) -> Self {
Self {
dump_dir,
format: DumpFormat::Jsonl,
categories: HashMap::new(),
}
}
pub fn with_category(mut self, cat: DiagCategory, spec: IterSpec) -> Self {
self.categories.insert(cat, spec);
self
}
pub fn is_empty(&self) -> bool {
self.categories.is_empty()
}
}
pub struct DiagnosticsState {
pub config: DiagnosticsConfig,
current_iter: AtomicI32,
solves_this_iter: AtomicI32,
in_restoration: AtomicBool,
resto_parent_iter: AtomicI32,
resto_inner_iter: AtomicI32,
resto_solves_this_iter: AtomicI32,
}
impl DiagnosticsState {
pub fn new(config: DiagnosticsConfig) -> std::io::Result<Self> {
fs::create_dir_all(&config.dump_dir)?;
Ok(Self {
config,
current_iter: AtomicI32::new(-1),
solves_this_iter: AtomicI32::new(0),
in_restoration: AtomicBool::new(false),
resto_parent_iter: AtomicI32::new(-1),
resto_inner_iter: AtomicI32::new(-1),
resto_solves_this_iter: AtomicI32::new(0),
})
}
pub fn want(&self, cat: DiagCategory) -> bool {
let iter = self.effective_iter();
if iter < 0 {
return false;
}
self.config
.categories
.get(&cat)
.map(|spec| spec.includes(iter))
.unwrap_or(false)
}
pub fn bump_iter(&self) {
if self.in_restoration.load(Ordering::SeqCst) {
self.resto_inner_iter.fetch_add(1, Ordering::SeqCst);
self.resto_solves_this_iter.store(0, Ordering::SeqCst);
} else {
self.current_iter.fetch_add(1, Ordering::SeqCst);
self.solves_this_iter.store(0, Ordering::SeqCst);
}
}
pub fn next_solve_index(&self) -> i32 {
let counter = if self.in_restoration.load(Ordering::SeqCst) {
&self.resto_solves_this_iter
} else {
&self.solves_this_iter
};
counter.fetch_add(1, Ordering::SeqCst) + 1
}
pub fn enter_restoration(&self) {
let parent = self.current_iter.load(Ordering::SeqCst);
self.resto_parent_iter.store(parent, Ordering::SeqCst);
self.resto_inner_iter.store(-1, Ordering::SeqCst);
self.resto_solves_this_iter.store(0, Ordering::SeqCst);
self.in_restoration.store(true, Ordering::SeqCst);
}
pub fn exit_restoration(&self) {
self.in_restoration.store(false, Ordering::SeqCst);
}
pub fn current_iter(&self) -> i32 {
self.effective_iter()
}
fn effective_iter(&self) -> i32 {
if self.in_restoration.load(Ordering::SeqCst) {
self.resto_inner_iter.load(Ordering::SeqCst)
} else {
self.current_iter.load(Ordering::SeqCst)
}
}
pub fn iter_dir(&self) -> Option<PathBuf> {
let dir = if self.in_restoration.load(Ordering::SeqCst) {
let parent = self.resto_parent_iter.load(Ordering::SeqCst);
let inner = self.resto_inner_iter.load(Ordering::SeqCst).max(0);
self.config
.dump_dir
.join(format!("resto/parent_iter_{parent:03}/iter_{inner:03}"))
} else {
let iter = self.current_iter.load(Ordering::SeqCst).max(0);
self.config.dump_dir.join(format!("iter_{iter:03}"))
};
fs::create_dir_all(&dir).ok()?;
Some(dir)
}
pub fn open_writer(&self, filename: &str) -> Option<BufWriter<fs::File>> {
let dir = self.iter_dir()?;
let path = dir.join(filename);
fs::File::create(path).ok().map(BufWriter::new)
}
pub fn write_top_level(&self, filename: &str, contents: &str) -> std::io::Result<()> {
let path = self.config.dump_dir.join(filename);
let mut f = fs::File::create(path)?;
f.write_all(contents.as_bytes())?;
f.flush()
}
pub fn dump_dir(&self) -> &Path {
&self.config.dump_dir
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn iter_spec_parses_all_grammar_forms() {
assert_eq!(IterSpec::parse("").unwrap(), IterSpec::All);
assert_eq!(IterSpec::parse("all").unwrap(), IterSpec::All);
assert_eq!(IterSpec::parse("5").unwrap(), IterSpec::Single(5));
assert_eq!(
IterSpec::parse("5-10").unwrap(),
IterSpec::Range(Some(5), Some(10))
);
assert_eq!(
IterSpec::parse("5-").unwrap(),
IterSpec::Range(Some(5), None)
);
assert_eq!(
IterSpec::parse("-10").unwrap(),
IterSpec::Range(None, Some(10))
);
}
#[test]
fn iter_spec_rejects_malformed_input() {
assert!(IterSpec::parse("abc").is_err());
assert!(IterSpec::parse("5-3").is_err()); assert!(IterSpec::parse("-x").is_err());
assert!(IterSpec::parse("5--10").is_err()); }
#[test]
fn iter_spec_includes_matches_grammar() {
assert!(IterSpec::All.includes(0));
assert!(IterSpec::All.includes(1000));
assert!(IterSpec::Single(5).includes(5));
assert!(!IterSpec::Single(5).includes(4));
let r = IterSpec::Range(Some(5), Some(10));
assert!(!r.includes(4));
assert!(r.includes(5));
assert!(r.includes(7));
assert!(r.includes(10));
assert!(!r.includes(11));
assert!(IterSpec::Range(Some(5), None).includes(1_000_000));
assert!(IterSpec::Range(None, Some(5)).includes(0));
}
#[test]
fn category_parses_known_names() {
assert_eq!(DiagCategory::parse("kkt").unwrap(), DiagCategory::Kkt);
assert_eq!(
DiagCategory::parse("iterate").unwrap(),
DiagCategory::Iterate
);
assert!(DiagCategory::parse("bogus").is_err());
}
#[test]
fn state_gates_on_iter_spec() {
let tmp = tempdir();
let cfg = DiagnosticsConfig::new(tmp.clone())
.with_category(DiagCategory::Kkt, IterSpec::Range(Some(2), Some(4)));
let state = DiagnosticsState::new(cfg).unwrap();
assert!(!state.want(DiagCategory::Kkt));
state.bump_iter(); assert!(!state.want(DiagCategory::Kkt));
state.bump_iter(); assert!(!state.want(DiagCategory::Kkt));
state.bump_iter(); assert!(state.want(DiagCategory::Kkt));
state.bump_iter(); assert!(state.want(DiagCategory::Kkt));
state.bump_iter(); assert!(state.want(DiagCategory::Kkt));
state.bump_iter(); assert!(!state.want(DiagCategory::Kkt));
assert!(!state.want(DiagCategory::Iterate));
fs::remove_dir_all(tmp).ok();
}
#[test]
fn state_emits_solve_indices_and_iter_dirs() {
let tmp = tempdir();
let cfg =
DiagnosticsConfig::new(tmp.clone()).with_category(DiagCategory::Kkt, IterSpec::All);
let state = DiagnosticsState::new(cfg).unwrap();
state.bump_iter(); assert_eq!(state.next_solve_index(), 1);
assert_eq!(state.next_solve_index(), 2);
state.bump_iter(); assert_eq!(state.next_solve_index(), 1);
let dir = state.iter_dir().unwrap();
assert!(dir.ends_with("iter_001"));
fs::remove_dir_all(tmp).ok();
}
#[test]
fn restoration_dumps_live_under_resto_subtree() {
let tmp = tempdir();
let cfg =
DiagnosticsConfig::new(tmp.clone()).with_category(DiagCategory::Kkt, IterSpec::All);
let state = DiagnosticsState::new(cfg).unwrap();
state.bump_iter(); state.bump_iter(); state.enter_restoration();
state.bump_iter(); let dir = state.iter_dir().unwrap();
assert!(
dir.ends_with("resto/parent_iter_001/iter_000"),
"got {dir:?}"
);
assert_eq!(state.next_solve_index(), 1);
state.exit_restoration();
let dir = state.iter_dir().unwrap();
assert!(dir.ends_with("iter_001"), "got {dir:?}");
fs::remove_dir_all(tmp).ok();
}
fn tempdir() -> PathBuf {
let p = std::env::temp_dir().join(format!(
"pounce-diag-test-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
fs::create_dir_all(&p).unwrap();
p
}
}