pub mod anonymize;
pub mod fingerprint;
pub mod walk;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use powerio_matrix::{BalancedNetwork, TargetFormat};
use serde::{Deserialize, Serialize};
use crate::invariants::{self, YbusUnavailable};
use anonymize::Sanitizer;
use fingerprint::Fingerprint;
const INGEST_FILE: &str = "ingest.json";
const COMPARE_FILE: &str = "comparisons.json";
const SCHEMA_KEYS: [&str; 83] = [
"Corpus",
"run",
"files",
"seen",
"unreadable",
"Buckets",
"siblings",
"Findings",
"count",
"true",
"false",
"null",
"parse.panic",
"parse.failure",
"leg.panic",
"leg.failure",
"leg.unresolved-include",
"sibling.variant",
"sibling.status",
"sibling.ybus",
"electrical.unavailable",
"electrical.ybus",
"electrical.injection",
"electrical.dc-terminal",
"core.power",
"core.regrouped",
"loss.declared",
"loss.undeclared",
"warning.observed",
"walk.panic",
"walk.failure",
"walk.drift",
"walk.path-dependent",
"walk.resurrection",
"walk.absorbed",
"crash",
"silent-drop",
"silent-value-change",
"undeclared-loss",
"buses",
"branches",
"generators",
"loads",
"shunts",
"load",
"gen",
"base",
"mva",
"hop",
"hops",
"path",
"origin",
"direct",
"emptied",
"resurrected",
"seed",
"revisit_of",
"walk",
"bucket",
"code",
"severity",
"leg",
"from",
"to",
"via",
"from_ordinal",
"to_ordinal",
"detail",
"templates",
"paths",
"delta",
"message",
"side",
"status_only",
"entries_changed",
"round-trip",
"cross-write",
"sibling",
"declared",
"elements",
"compared",
"unresolved-include",
"variant",
];
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Member {
pub ordinal: usize,
pub format: String,
pub path: PathBuf,
pub warnings: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Domain {
Transmission,
Distribution,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Bucket {
pub id: String,
pub domain: Domain,
pub fingerprint: Fingerprint,
pub members: Vec<Member>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Unreadable {
pub path: PathBuf,
pub error: String,
pub panicked: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Ingest {
pub buckets: Vec<Bucket>,
pub unreadable: Vec<Unreadable>,
pub files_seen: usize,
pub skipped: usize,
pub escaped: usize,
}
fn within(root: &Path, path: &Path) -> bool {
match (root.canonicalize(), path.canonicalize()) {
(Ok(root), Ok(path)) => path.starts_with(&root),
_ => false,
}
}
fn names_a_case(path: &Path) -> bool {
const CASE_EXTENSIONS: [&str; 8] = ["m", "raw", "rawx", "epc", "aux", "pwb", "json", "dss"];
path.extension()
.and_then(|e| e.to_str())
.map(str::to_ascii_lowercase)
.is_some_and(|ext| CASE_EXTENSIONS.contains(&ext.as_str()))
}
pub fn ingest(corpus: &Path, work: &Path, max_bytes: Option<u64>) -> Result<Ingest> {
std::fs::create_dir_all(work)
.with_context(|| format!("create work directory {}", work.display()))?;
let mut files_seen = 0usize;
let mut skipped = 0usize;
let mut escaped = 0usize;
let mut unreadable = Vec::new();
let mut parsed: Vec<Parsed> = Vec::new();
for entry in walkdir::WalkDir::new(corpus)
.follow_links(false)
.sort_by_file_name()
{
let entry = match entry {
Ok(entry) => entry,
Err(err) => {
unreadable.push(Unreadable {
path: err.path().unwrap_or(corpus).to_path_buf(),
error: err.to_string(),
panicked: false,
});
continue;
}
};
let path = entry.path();
if !within(corpus, path) {
escaped += 1;
continue;
}
let is_pypsa_dir = entry.file_type().is_dir() && path.join("network.csv").is_file();
if entry.file_type().is_dir() && !is_pypsa_dir {
continue;
}
files_seen += 1;
if let Some(cap) = max_bytes {
let over = entry.metadata().is_ok_and(|m| m.is_file() && m.len() > cap);
if over {
skipped += 1;
continue;
}
}
match read_case(path) {
Ok(case) if case.fingerprint().buses == 0 => skipped += 1,
Ok(case) => {
parsed.push(Parsed {
domain: case.domain(),
fingerprint: case.fingerprint(),
format: case.format(),
path: path.to_path_buf(),
warnings: case.warnings(),
});
}
Err(bad) if names_a_case(path) || bad.panicked => unreadable.push(bad),
Err(_) => skipped += 1,
}
}
let mut buckets = bucket(parsed);
buckets.sort_by(|a, b| {
a.fingerprint
.primary()
.cmp(&b.fingerprint.primary())
.then_with(|| a.fingerprint.cmp(&b.fingerprint))
});
for (i, bucket) in buckets.iter_mut().enumerate() {
bucket.id = format!("case-{i:03}");
for (ordinal, member) in bucket.members.iter_mut().enumerate() {
member.ordinal = ordinal;
}
}
let ingest = Ingest {
buckets,
unreadable,
files_seen,
skipped,
escaped,
};
write_json(&work.join(INGEST_FILE), &ingest)?;
Ok(ingest)
}
struct Parsed {
domain: Domain,
fingerprint: Fingerprint,
format: String,
path: PathBuf,
warnings: Vec<String>,
}
fn bucket(parsed: Vec<Parsed>) -> Vec<Bucket> {
let mut groups: BTreeMap<(Domain, fingerprint::PrimaryKey), Vec<Parsed>> = BTreeMap::new();
for item in parsed {
groups
.entry((item.domain, item.fingerprint.primary()))
.or_default()
.push(item);
}
let mut out = Vec::new();
for ((domain, _), group) in groups {
let mut splits: Vec<Bucket> = Vec::new();
for item in group {
let member = Member {
ordinal: 0,
format: item.format,
path: item.path,
warnings: item.warnings,
};
match splits
.iter_mut()
.find(|split| split.fingerprint.agrees_with(&item.fingerprint))
{
Some(split) => split.members.push(member),
None => splits.push(Bucket {
id: String::new(),
domain,
fingerprint: item.fingerprint,
members: vec![member],
}),
}
}
out.extend(splits);
}
out
}
pub enum Case {
Balanced(Box<BalancedNetwork>, Vec<String>),
Multiconductor(Box<powerio_dist::MulticonductorNetwork>),
}
impl Case {
fn domain(&self) -> Domain {
match self {
Self::Balanced(..) => Domain::Transmission,
Self::Multiconductor(_) => Domain::Distribution,
}
}
fn fingerprint(&self) -> Fingerprint {
match self {
Self::Balanced(net, _) => Fingerprint::of(net),
Self::Multiconductor(net) => Fingerprint::of_distribution(net),
}
}
fn format(&self) -> String {
match self {
Self::Balanced(net, _) => net.source_format.name().to_string(),
Self::Multiconductor(net) => net
.source_format
.as_ref()
.map_or("dss", |f| f.name())
.to_string(),
}
}
fn warnings(&self) -> Vec<String> {
match self {
Self::Balanced(_, warnings) => warnings.clone(),
Self::Multiconductor(net) => net.warnings.clone(),
}
}
}
fn read_case(path: &Path) -> std::result::Result<Case, Unreadable> {
let unreadable = |error: String, panicked: bool| Unreadable {
path: path.to_path_buf(),
error,
panicked,
};
let balanced_error = match catch_panic(|| powerio_matrix::parse_file(path, None)) {
Ok(Ok(parsed)) => return Ok(Case::Balanced(Box::new(parsed.network), parsed.warnings)),
Err(message) => return Err(unreadable(message, true)),
Ok(Err(err)) => err.to_string(),
};
match catch_panic(|| powerio_dist::parse_file(path, None)) {
Ok(Ok(net)) => Ok(Case::Multiconductor(Box::new(net))),
Ok(Err(err)) => {
let message = err.to_string();
Err(unreadable(
if message.starts_with("unknown distribution format") {
balanced_error
} else {
message
},
false,
))
}
Err(message) => Err(unreadable(message, true)),
}
}
fn balanced(path: &Path) -> Option<BalancedNetwork> {
match read_case(path) {
Ok(Case::Balanced(net, _)) => Some(*net),
_ => None,
}
}
fn multiconductor(path: &Path) -> Option<powerio_dist::MulticonductorNetwork> {
match read_case(path) {
Ok(Case::Multiconductor(net)) => Some(*net),
_ => None,
}
}
fn catch_panic<T>(
f: impl FnOnce() -> T + std::panic::UnwindSafe,
) -> std::result::Result<T, String> {
let previous = std::panic::take_hook();
std::panic::set_hook(Box::new(|_| {}));
let result = std::panic::catch_unwind(f);
std::panic::set_hook(previous);
result.map_err(|payload| {
payload
.downcast_ref::<&str>()
.map(|s| (*s).to_string())
.or_else(|| payload.downcast_ref::<String>().cloned())
.unwrap_or_else(|| "panic".to_string())
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Via {
RoundTrip,
CrossWrite,
Sibling,
Walk,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Leg {
pub from: String,
pub to: String,
pub via: Via,
pub from_ordinal: usize,
pub to_ordinal: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Comparison {
pub bucket: String,
pub leg: Leg,
pub warnings: Vec<String>,
pub core_changed: Option<String>,
pub ybus: Option<String>,
pub ybus_unavailable: Option<String>,
pub injection: Option<String>,
pub injection_status_only: bool,
pub dc_terminal: Option<String>,
pub model_diffs: Vec<String>,
pub failure: Option<String>,
pub variant: bool,
pub unresolved_include: bool,
pub status_changed: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Comparisons {
pub comparisons: Vec<Comparison>,
}
pub fn compare(work: &Path) -> Result<Comparisons> {
let ingest: Ingest = read_json(&work.join(INGEST_FILE))?;
let mut comparisons = Vec::new();
for bucket in &ingest.buckets {
match bucket.domain {
Domain::Transmission => compare_transmission(bucket, &mut comparisons),
Domain::Distribution => compare_distribution(bucket, &mut comparisons),
}
}
let out = Comparisons { comparisons };
write_json(&work.join(COMPARE_FILE), &out)?;
Ok(out)
}
const MAX_BUCKET_MEMBERS: usize = 24;
fn compared_members(bucket: &Bucket) -> &[Member] {
let n = bucket.members.len().min(MAX_BUCKET_MEMBERS);
&bucket.members[..n]
}
fn compare_transmission(bucket: &Bucket, out: &mut Vec<Comparison>) {
let members = compared_members(bucket);
let networks: Vec<Option<BalancedNetwork>> =
members.iter().map(|m| balanced(&m.path)).collect();
for (i, member) in members.iter().enumerate() {
let Some(source) = networks[i].as_ref() else {
continue;
};
for (j, other) in members.iter().enumerate() {
let Some(target) = powerio_matrix::target_format_from_name(&other.format) else {
continue;
};
let via = if i == j {
Via::RoundTrip
} else {
Via::CrossWrite
};
out.push(convert_leg(bucket, member, other, via, source, target));
}
for (j, other) in members.iter().enumerate().skip(i + 1) {
let Some(sibling) = networks[j].as_ref() else {
continue;
};
let twin = fingerprint::same_data(source, sibling);
let mut leg = sibling_leg(bucket, member, other, source, sibling);
leg.variant = !twin;
out.push(leg);
}
}
}
fn compare_distribution(bucket: &Bucket, out: &mut Vec<Comparison>) {
let members = compared_members(bucket);
let networks: Vec<Option<powerio_dist::MulticonductorNetwork>> =
members.iter().map(|m| multiconductor(&m.path)).collect();
for (i, member) in members.iter().enumerate() {
let Some(source) = networks[i].as_ref() else {
continue;
};
for (j, other) in members.iter().enumerate() {
let Some(target) = powerio_dist::dist_target_from_name(&other.format) else {
continue;
};
let via = if i == j {
Via::RoundTrip
} else {
Via::CrossWrite
};
out.push(dist_convert_leg(bucket, member, other, via, source, target));
}
}
}
fn dist_convert_leg(
bucket: &Bucket,
from: &Member,
to: &Member,
via: Via,
source: &powerio_dist::MulticonductorNetwork,
target: powerio_dist::DistTargetFormat,
) -> Comparison {
let mut out = empty_comparison(
bucket,
Leg {
from: from.format.clone(),
to: to.format.clone(),
via,
from_ordinal: from.ordinal,
to_ordinal: to.ordinal,
},
);
let written = match catch_panic(|| source.to_format(target)) {
Ok(conversion) => conversion,
Err(message) => {
out.failure = Some(format!("write panicked: {message}"));
return out;
}
};
out.warnings.extend(written.warnings.iter().cloned());
let token = to.format.clone();
let text = written.text;
if has_include(&text) {
out.unresolved_include = true;
return out;
}
let parsed = match catch_panic(|| powerio_dist::parse_str(&text, &token)) {
Ok(Ok(parsed)) => parsed,
Ok(Err(err)) => {
out.failure = Some(format!("readback: {err}"));
return out;
}
Err(message) => {
out.failure = Some(format!("readback panicked: {message}"));
return out;
}
};
out.warnings.extend(parsed.warnings.iter().cloned());
let before = invariants::distribution_core(source);
let after = invariants::distribution_core(&parsed);
if before != after {
out.core_changed = Some(dist_core_delta(&before, &after));
}
let mut clean = parsed;
clean.warnings.clear();
let diffs = invariants::model_diffs(
&invariants::distribution_value(source),
&invariants::distribution_value(&clean),
);
out.model_diffs = diffs
.iter()
.map(|d| anonymize::collapse_path(&d.path))
.collect();
out.model_diffs.sort();
out.model_diffs.dedup();
out
}
fn has_include(text: &str) -> bool {
text.lines().any(|line| {
let word = line.split_whitespace().next().unwrap_or("");
word.eq_ignore_ascii_case("redirect")
|| word.eq_ignore_ascii_case("compile")
|| word.eq_ignore_ascii_case("buscoords")
})
}
fn dist_core_delta(
before: &invariants::DistributionCore,
after: &invariants::DistributionCore,
) -> String {
let mut parts = Vec::new();
for (label, a, b) in [
("buses", before.buses, after.buses),
("loads", before.loads, after.loads),
("generators", before.generators, after.generators),
("shunts", before.shunts, after.shunts),
] {
if a != b {
parts.push(format!("{label} {}", count_delta(a, b)));
}
}
if before.load_p != after.load_p {
parts.push("load p".to_string());
}
if before.load_q != after.load_q {
parts.push("load q".to_string());
}
parts.join(", ")
}
fn empty_comparison(bucket: &Bucket, leg: Leg) -> Comparison {
Comparison {
bucket: bucket.id.clone(),
leg,
warnings: Vec::new(),
core_changed: None,
ybus: None,
ybus_unavailable: None,
injection: None,
injection_status_only: false,
dc_terminal: None,
model_diffs: Vec::new(),
failure: None,
variant: false,
unresolved_include: false,
status_changed: 0,
}
}
fn convert_leg(
bucket: &Bucket,
from: &Member,
to: &Member,
via: Via,
source: &BalancedNetwork,
target: TargetFormat,
) -> Comparison {
let mut out = empty_comparison(
bucket,
Leg {
from: from.format.clone(),
to: to.format.clone(),
via,
from_ordinal: from.ordinal,
to_ordinal: to.ordinal,
},
);
let written = match catch_panic(|| powerio_matrix::write_as(source, target)) {
Ok(Ok(conversion)) => conversion,
Ok(Err(err)) => {
out.failure = Some(format!("write: {err}"));
return out;
}
Err(message) => {
out.failure = Some(format!("write panicked: {message}"));
return out;
}
};
out.warnings.extend(written.warnings.iter().cloned());
let token = to.format.clone();
let text = written.text;
let parsed = match catch_panic(|| powerio_matrix::parse_str(&text, &token)) {
Ok(Ok(parsed)) => parsed,
Ok(Err(err)) => {
out.failure = Some(format!("readback: {err}"));
return out;
}
Err(message) => {
out.failure = Some(format!("readback panicked: {message}"));
return out;
}
};
out.warnings.extend(parsed.warnings.iter().cloned());
fill_invariants(&mut out, source, &parsed.network);
out
}
fn sibling_leg(
bucket: &Bucket,
from: &Member,
to: &Member,
source: &BalancedNetwork,
sibling: &BalancedNetwork,
) -> Comparison {
let mut out = empty_comparison(
bucket,
Leg {
from: from.format.clone(),
to: to.format.clone(),
via: Via::Sibling,
from_ordinal: from.ordinal,
to_ordinal: to.ordinal,
},
);
out.status_changed = invariants::status_disagreements(source, sibling);
let before = invariants::transmission_core(source);
let after = invariants::transmission_core(sibling);
if before != after {
out.core_changed = Some(core_delta(&before, &after));
}
match invariants::ybus_change(source, sibling) {
Ok(Some(change)) => out.ybus = Some(change.to_string()),
Ok(None) => {}
Err(side) => out.ybus_unavailable = Some(unavailable_label(&side)),
}
if let Some(change) = invariants::injection_change(source, sibling) {
out.injection_status_only = change.status_only;
out.injection = Some(change.to_string());
}
if let Some(change) = invariants::dc_terminal_change(source, sibling) {
out.dc_terminal = Some(change.to_string());
}
out
}
fn fill_invariants(out: &mut Comparison, source: &BalancedNetwork, result: &BalancedNetwork) {
let before = invariants::transmission_core(source);
let after = invariants::transmission_core(result);
if before != after {
out.core_changed = Some(core_delta(&before, &after));
}
match invariants::ybus_change(source, result) {
Ok(Some(change)) => out.ybus = Some(change.to_string()),
Ok(None) => {}
Err(side) => out.ybus_unavailable = Some(unavailable_label(&side)),
}
if let Some(change) = invariants::injection_change(source, result) {
out.injection_status_only = change.status_only;
out.injection = Some(change.to_string());
}
if let Some(change) = invariants::dc_terminal_change(source, result) {
out.dc_terminal = Some(change.to_string());
}
let diffs = invariants::model_diffs(
&invariants::transmission_value(source),
&invariants::transmission_value(result),
);
out.model_diffs = diffs
.iter()
.map(|d| anonymize::collapse_path(&d.path))
.collect();
out.model_diffs.sort();
out.model_diffs.dedup();
}
fn count_delta(before: usize, after: usize) -> String {
if after >= before {
format!("+{}", after - before)
} else {
format!("-{}", before - after)
}
}
fn unavailable_label(side: &YbusUnavailable) -> String {
match side {
YbusUnavailable::Before => "source".to_string(),
YbusUnavailable::After => "result".to_string(),
}
}
pub(super) fn power_moved(core: &str) -> bool {
["load p", "load q", "gen p", "base mva"]
.iter()
.any(|label| core.contains(label))
}
fn core_delta(
before: &invariants::TransmissionCore,
after: &invariants::TransmissionCore,
) -> String {
let mut parts = Vec::new();
let mut delta = |label: &str, a: usize, b: usize| {
if a != b {
parts.push(format!("{label} {}", count_delta(a, b)));
}
};
delta("buses", before.buses, after.buses);
delta("branches", before.branches, after.branches);
delta("generators", before.generators, after.generators);
delta("loads", before.loads, after.loads);
delta("shunts", before.shunts, after.shunts);
if before.load_p != after.load_p {
parts.push("load p".to_string());
}
if before.load_q != after.load_q {
parts.push("load q".to_string());
}
if before.gen_p != after.gen_p {
parts.push("gen p".to_string());
}
if before.base_mva != after.base_mva {
parts.push("base mva".to_string());
}
parts.join(", ")
}
fn write_json<T: Serialize>(path: &Path, value: &T) -> Result<()> {
let text = serde_json::to_string_pretty(value)?;
std::fs::write(path, text).with_context(|| format!("write {}", path.display()))?;
Ok(())
}
fn read_json<T: for<'de> Deserialize<'de>>(path: &Path) -> Result<T> {
let text = std::fs::read_to_string(path).with_context(|| {
format!(
"read {} (run `powerio corpus ingest` first)",
path.display()
)
})?;
Ok(serde_json::from_str(&text)?)
}
pub fn report(work: &Path, findings_path: &Path, summary_path: Option<&Path>) -> Result<usize> {
let ingest: Ingest = read_json(&work.join(INGEST_FILE))?;
let comparisons: Option<Comparisons> = read_json(&work.join(COMPARE_FILE)).ok();
let walks: Option<walk::Walks> = read_json(&work.join(walk::WALK_FILE)).ok();
if comparisons.is_none() && walks.is_none() {
anyhow::bail!(
"nothing to report in {}: run `powerio corpus compare` or `powerio corpus walk` first",
work.display()
);
}
let comparisons = comparisons.unwrap_or(Comparisons {
comparisons: Vec::new(),
});
let mut sanitizer = Sanitizer::new();
for key in SCHEMA_KEYS {
sanitizer.allow(key);
}
for bucket in &ingest.buckets {
sanitizer.allow(&bucket.id);
for member in &bucket.members {
sanitizer.allow(&member.format);
sanitizer.learn_path(&member.path);
match read_case(&member.path) {
Ok(Case::Balanced(net, _)) => {
sanitizer.learn_buses(net.buses.iter().map(|b| b.id.0));
sanitizer.learn_network(&serde_json::to_value(&*net)?);
}
Ok(Case::Multiconductor(net)) => {
sanitizer.learn_network(&serde_json::to_value(&net)?);
}
Err(_) => {}
}
}
}
for bad in &ingest.unreadable {
sanitizer.learn_path(&bad.path);
}
let mut findings = Vec::new();
for bad in &ingest.unreadable {
findings.push(Finding {
bucket: None,
code: if bad.panicked {
"parse.panic"
} else {
"parse.failure"
},
severity: if bad.panicked { "crash" } else { "declared" },
leg: None,
detail: serde_json::json!({ "message": sanitizer.template(&bad.error) }),
});
}
for comparison in &comparisons.comparisons {
findings.extend(findings_for(comparison, &sanitizer));
}
if let Some(walks) = &walks {
for w in &walks.walks {
findings.extend(walk_findings(w, &sanitizer));
}
}
let jsonl = render_jsonl(&findings)?;
audit_or_bail(&sanitizer, &jsonl, findings_path)?;
std::fs::write(findings_path, &jsonl)
.with_context(|| format!("write {}", findings_path.display()))?;
if let Some(path) = summary_path {
let summary = render_summary(&ingest, &findings);
audit_or_bail(&sanitizer, &summary, path)?;
std::fs::write(path, summary).with_context(|| format!("write {}", path.display()))?;
}
Ok(findings.len())
}
fn audit_or_bail(sanitizer: &Sanitizer, text: &str, path: &Path) -> Result<()> {
if let Err(leaks) = sanitizer.audit(text) {
anyhow::bail!(
"refusing to write {}: {} corpus string(s) reached it, first at {}",
path.display(),
leaks.len(),
leaks[0]
);
}
Ok(())
}
#[derive(Debug, Clone, Serialize)]
pub struct Finding {
#[serde(skip_serializing_if = "Option::is_none")]
pub bucket: Option<String>,
pub code: &'static str,
pub severity: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
pub leg: Option<Leg>,
pub detail: serde_json::Value,
}
#[allow(clippy::too_many_lines)]
fn findings_for(comparison: &Comparison, sanitizer: &Sanitizer) -> Vec<Finding> {
let mut out = Vec::new();
let bucket = Some(comparison.bucket.clone());
let leg = Some(comparison.leg.clone());
let mut push = |code: &'static str, severity: &'static str, detail: serde_json::Value| {
out.push(Finding {
bucket: bucket.clone(),
code,
severity,
leg: leg.clone(),
detail,
});
};
if comparison.unresolved_include {
push(
"leg.unresolved-include",
"declared",
serde_json::json!({ "compared": false }),
);
return out;
}
if comparison.variant {
push(
"sibling.variant",
"declared",
serde_json::json!({ "compared": true }),
);
}
if let Some(failure) = &comparison.failure {
let panicked = failure.contains("panicked");
push(
if panicked { "leg.panic" } else { "leg.failure" },
if panicked { "crash" } else { "silent-drop" },
serde_json::json!({ "message": sanitizer.template(failure) }),
);
}
if let Some(side) = &comparison.ybus_unavailable {
push(
"electrical.unavailable",
"silent-value-change",
serde_json::json!({ "side": side }),
);
}
if comparison.ybus.is_some() {
let honest_difference = comparison.leg.via == Via::Sibling
&& (comparison.variant || comparison.status_changed > 0);
push(
if honest_difference {
"sibling.ybus"
} else {
"electrical.ybus"
},
if honest_difference {
"declared"
} else {
"silent-value-change"
},
serde_json::json!({ "entries_changed": 1 }),
);
}
if comparison.injection.is_some() {
let honest_difference = comparison.leg.via == Via::Sibling
&& (comparison.injection_status_only || comparison.variant);
push(
if honest_difference {
"sibling.status"
} else {
"electrical.injection"
},
if honest_difference {
"declared"
} else {
"silent-value-change"
},
serde_json::json!({ "status_only": comparison.injection_status_only }),
);
}
if comparison.dc_terminal.is_some() {
let declared = !comparison.warnings.is_empty();
push(
"electrical.dc-terminal",
if declared {
"declared"
} else {
"silent-value-change"
},
serde_json::json!({ "declared": declared }),
);
}
if let Some(core) = &comparison.core_changed {
let power_moved = power_moved(core);
push(
if power_moved {
"core.power"
} else {
"core.regrouped"
},
if power_moved {
"silent-drop"
} else {
"declared"
},
serde_json::json!({ "delta": core }),
);
}
if comparison.status_changed > 0 {
push(
"sibling.status",
"declared",
serde_json::json!({ "elements": comparison.status_changed }),
);
}
if !comparison.model_diffs.is_empty() {
let paths: Vec<String> = comparison
.model_diffs
.iter()
.map(|p| sanitizer.template(p))
.collect();
let declared = !comparison.warnings.is_empty();
push(
if declared {
"loss.declared"
} else {
"loss.undeclared"
},
if declared {
"declared"
} else {
"undeclared-loss"
},
serde_json::json!({ "paths": paths }),
);
}
let mut templates: BTreeMap<String, usize> = BTreeMap::new();
for warning in &comparison.warnings {
*templates.entry(sanitizer.template(warning)).or_default() += 1;
}
if !templates.is_empty() {
push(
"warning.observed",
"declared",
serde_json::json!({ "templates": templates }),
);
}
out
}
fn walk_findings(w: &walk::Walk, sanitizer: &Sanitizer) -> Vec<Finding> {
let mut out = Vec::new();
let path: Vec<String> = w.hops.iter().map(|h| h.to.clone()).collect();
for (index, hop) in w.hops.iter().enumerate() {
let leg = Some(Leg {
from: if index == 0 {
w.origin.clone()
} else {
w.hops[index - 1].to.clone()
},
to: hop.to.clone(),
via: Via::Walk,
from_ordinal: index,
to_ordinal: index + 1,
});
let mut push =
|code: &'static str, severity: &'static str, mut detail: serde_json::Value| {
if let Some(map) = detail.as_object_mut() {
map.insert("origin".into(), serde_json::json!(w.origin));
map.insert("path".into(), serde_json::json!(path));
map.insert("hop".into(), serde_json::json!(index));
map.insert("seed".into(), serde_json::json!(w.seed.to_string()));
}
out.push(Finding {
bucket: Some(w.bucket.clone()),
code,
severity,
leg: leg.clone(),
detail,
});
};
if let Some(failure) = &hop.failure {
let panicked = failure.contains("panicked");
push(
if panicked {
"walk.panic"
} else {
"walk.failure"
},
if panicked { "crash" } else { "silent-drop" },
serde_json::json!({ "message": sanitizer.template(failure) }),
);
}
if let Some(drift) = &hop.drift {
push(
"walk.drift",
if hop.drift_electrical {
"silent-value-change"
} else {
"declared"
},
serde_json::json!({
"revisit_of": hop.revisit_of,
"delta": sanitizer.template(drift),
}),
);
}
if let Some(delta) = &hop.path_dependent {
push(
"walk.path-dependent",
if hop.path_dependent_electrical {
"silent-value-change"
} else {
"declared"
},
serde_json::json!({ "direct": sanitizer.template(delta) }),
);
}
if !hop.resurrected.is_empty() {
push(
"walk.resurrection",
if hop.leg.electrical() {
"silent-value-change"
} else {
"declared"
},
serde_json::json!({ "resurrected": hop.resurrected }),
);
}
if !hop.emptied.is_empty() {
push(
"walk.absorbed",
"declared",
serde_json::json!({ "emptied": hop.emptied }),
);
}
}
out
}
fn render_jsonl(findings: &[Finding]) -> Result<String> {
let mut out = String::new();
for finding in findings {
out.push_str(&serde_json::to_string(finding)?);
out.push('\n');
}
Ok(out)
}
fn render_summary(ingest: &Ingest, findings: &[Finding]) -> String {
use std::fmt::Write as _;
let mut out = String::new();
let _ = writeln!(out, "# Corpus run\n");
let _ = writeln!(
out,
"{} files seen, {} buckets, {} unreadable.\n",
ingest.files_seen,
ingest.buckets.len(),
ingest.unreadable.len()
);
let mut sizes: BTreeMap<usize, usize> = BTreeMap::new();
for bucket in &ingest.buckets {
*sizes.entry(bucket.members.len()).or_default() += 1;
}
let _ = writeln!(out, "## Buckets by sibling count\n");
let _ = writeln!(out, "| siblings | buckets |");
let _ = writeln!(out, "| --- | --- |");
for (size, count) in &sizes {
let _ = writeln!(out, "| {size} | {count} |");
}
let mut by_code: BTreeMap<(&str, &str), usize> = BTreeMap::new();
for finding in findings {
*by_code.entry((finding.severity, finding.code)).or_default() += 1;
}
let _ = writeln!(out, "\n## Findings\n");
let _ = writeln!(out, "| severity | code | count |");
let _ = writeln!(out, "| --- | --- | --- |");
for ((severity, code), count) in &by_code {
let _ = writeln!(out, "| {severity} | {code} | {count} |");
}
out
}
#[cfg(test)]
mod tests {
use std::collections::BTreeSet;
use std::path::Path;
use super::{Case, read_case};
fn model_keys(value: &serde_json::Value, in_extras: bool, out: &mut BTreeSet<String>) {
match value {
serde_json::Value::Array(xs) => {
for x in xs {
model_keys(x, in_extras, out);
}
}
serde_json::Value::Object(xs) => {
for (key, x) in xs {
if !in_extras {
out.insert(key.clone());
}
model_keys(x, in_extras || key == "extras", out);
}
}
_ => {}
}
}
#[test]
fn every_fixture_field_name_is_vocabulary() {
let data = Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.join("tests/data");
let mut keys = BTreeSet::new();
let mut parsed = 0usize;
for entry in walkdir::WalkDir::new(&data)
.into_iter()
.filter_map(Result::ok)
.filter(|e| e.file_type().is_file())
{
let Ok(case) = read_case(entry.path()) else {
continue;
};
let value = match case {
Case::Balanced(net, _) => serde_json::to_value(&*net),
Case::Multiconductor(net) => serde_json::to_value(&*net),
}
.expect("a parsed fixture serializes");
model_keys(&value, false, &mut keys);
parsed += 1;
}
assert!(parsed >= 18, "only {parsed} fixtures parsed");
let vocabulary = super::anonymize::vocabulary();
let missing: Vec<&String> = keys.iter().filter(|k| !vocabulary.contains(*k)).collect();
assert!(
missing.is_empty(),
"{} fixture field name(s) outside the vocabulary would mask as corpus secrets: {missing:?}",
missing.len()
);
}
}