use std::collections::HashMap;
fn main() {
let args: Vec<String> = std::env::args().collect();
if args.len() != 3 {
eprintln!("usage: compare <before.txt> <after.txt>");
std::process::exit(2);
}
let a = parse(&args[1]);
let b = parse(&args[2]);
let mut worst_overall = 0.0f64;
let mut failed = false;
let mut names: Vec<&String> = a.keys().collect();
names.sort();
for name in names {
let na = &a[name];
let Some(nb) = b.get(name) else {
println!("{name}: MISSING from second file");
failed = true;
continue;
};
if na.len() != nb.len() {
println!("{name}: NODE COUNT {} -> {}", na.len(), nb.len());
failed = true;
continue;
}
let mut used = vec![false; nb.len()];
let mut worst = 0.0f64;
let mut unmatched = 0;
for x in na {
let mut best: Option<(f64, usize)> = None;
for (j, y) in nb.iter().enumerate() {
if used[j] || x.sources != y.sources {
continue;
}
let d = ((x.pos.0 - y.pos.0).powi(2) + (x.pos.1 - y.pos.1).powi(2)).sqrt();
let d = d.max((x.offset - y.offset).abs());
if best.map_or(true, |(bd, _)| d < bd) {
best = Some((d, j));
}
}
match best {
Some((d, j)) => {
used[j] = true;
worst = worst.max(d);
}
None => unmatched += 1,
}
}
worst_overall = worst_overall.max(worst);
if unmatched > 0 {
println!("{name}: {unmatched} node(s) with no counterpart at all");
failed = true;
} else {
println!(
"{name}: {} nodes matched, worst drift {worst:.2e}",
na.len()
);
}
}
println!("---");
println!("worst drift across every shape: {worst_overall:.3e}");
if failed {
println!("VERDICT: shapes differ structurally");
std::process::exit(1);
}
if worst_overall < 1e-2 {
println!("VERDICT: identical up to f32 noise");
} else {
println!("VERDICT: drift exceeds f32 noise - investigate");
std::process::exit(1);
}
}
struct Node {
pos: (f64, f64),
offset: f64,
sources: String,
}
fn parse(path: &str) -> HashMap<String, Vec<Node>> {
let text = std::fs::read_to_string(path).unwrap_or_else(|e| panic!("{path}: {e}"));
let mut out: HashMap<String, Vec<Node>> = HashMap::new();
let mut cur = String::new();
for line in text.lines() {
let line = line.trim_end();
if let Some(colon) = line.find(": ") {
if !line.starts_with(' ') && line.contains(" nodes, ") {
cur = line[..colon].to_string();
out.insert(cur.clone(), Vec::new());
continue;
}
}
let t = line.trim_start();
if let Some(rest) = t.strip_prefix("N ") {
let exact = rest
.split("exact=(")
.nth(1)
.and_then(|s| s.split(')').next())
.expect("exact field");
let (xh, yh) = exact.split_once(',').expect("two components");
let off = rest
.split("off=")
.nth(1)
.and_then(|s| s.split(' ').next())
.expect("off field");
let src = rest.split("src=").nth(1).expect("src field").to_string();
out.get_mut(&cur).expect("node before header").push(Node {
pos: (bits(xh) as f64, bits(yh) as f64),
offset: bits(off) as f64,
sources: src,
});
}
}
out
}
fn bits(hex: &str) -> f32 {
f32::from_bits(u32::from_str_radix(hex.trim(), 16).expect("hex float"))
}