Skip to main content

powerio_cli/corpus/
mod.rs

1//! The corpus harness: the conversion invariants, run over a directory of
2//! case files.
3//!
4//! Three commands, each writing what the next one reads:
5//!
6//! ```text
7//! corpus dir (read-only, outside the repo)
8//!   │  powerio corpus ingest <dir> --work <scratch>
9//!   ▼
10//! buckets: case-000, case-001, …   (grouped by electrical fingerprint,
11//!   │                               never by filename)
12//!   ▼  powerio corpus compare --work <scratch>
13//! per bucket: round trip per sibling, cross-write per sibling pair,
14//!   │         sibling agreement between the two readers
15//!   ▼  powerio corpus report --work <scratch> -o findings.jsonl
16//! findings.jsonl + summary.md      (codes, ordinals, deltas)
17//! ```
18//!
19//! The corpus directory is opened read-only and never written. The work
20//! directory is disposable and holds raw values, so it stays on the machine
21//! that owns the corpus. The report is the boundary: [`anonymize`] states what
22//! may cross it, and the reporter audits its own output against every string
23//! the corpus taught the harness before writing a byte.
24//!
25//! See `docs/src/corpus-harness.md` for the design and the session protocol
26//! that consumes the findings.
27
28pub mod anonymize;
29pub mod fingerprint;
30pub mod walk;
31
32use std::collections::BTreeMap;
33use std::path::{Path, PathBuf};
34
35use anyhow::{Context, Result};
36use powerio::BalancedNetwork;
37use powerio_tx::TargetFormat;
38use serde::{Deserialize, Serialize};
39
40use crate::invariants::{self, YbusUnavailable};
41use anonymize::Sanitizer;
42use fingerprint::Fingerprint;
43
44const INGEST_FILE: &str = "ingest.json";
45const COMPARE_FILE: &str = "comparisons.json";
46
47/// Every key and enum spelling the findings file itself uses. Declared as
48/// vocabulary so the leak audit never mistakes the report's own schema for
49/// case data; keep it in step with [`Finding`], [`findings_for`] and
50/// [`walk_findings`].
51const SCHEMA_KEYS: [&str; 83] = [
52    // The summary's own prose, title case included: the audit matches exact
53    // tokens.
54    "Corpus",
55    "run",
56    "files",
57    "seen",
58    "unreadable",
59    "Buckets",
60    "siblings",
61    "Findings",
62    "count",
63    // JSON's own literals: a corpus value spelled "false" must not make the
64    // report's booleans unwritable.
65    "true",
66    "false",
67    "null",
68    // Every finding code. `allow` licenses each dot- and dash-separated
69    // component, so a corpus value spelled "terminal" cannot make
70    // `electrical.dc-terminal` unwritable.
71    "parse.panic",
72    "parse.failure",
73    "leg.panic",
74    "leg.failure",
75    "leg.unresolved-include",
76    "sibling.different-data",
77    "sibling.status",
78    "sibling.ybus",
79    "electrical.unavailable",
80    "electrical.ybus",
81    "electrical.injection",
82    "electrical.dc-terminal",
83    "core.power",
84    "core.regrouped",
85    "loss.declared",
86    "loss.undeclared",
87    "warning.observed",
88    "walk.panic",
89    "walk.failure",
90    "walk.drift",
91    "walk.path-dependent",
92    "walk.resurrection",
93    "walk.absorbed",
94    // The remaining severities ("declared" is below with the schema keys).
95    "crash",
96    "silent-drop",
97    "silent-value-change",
98    "undeclared-loss",
99    // The `core_delta` labels.
100    "buses",
101    "branches",
102    "generators",
103    "loads",
104    "shunts",
105    "load",
106    "gen",
107    "base",
108    "mva",
109    "hop",
110    "hops",
111    "path",
112    "origin",
113    "direct",
114    "emptied",
115    "resurrected",
116    "seed",
117    "revisit_of",
118    "walk",
119    "bucket",
120    "code",
121    "severity",
122    "leg",
123    "from",
124    "to",
125    "via",
126    "from_ordinal",
127    "to_ordinal",
128    "detail",
129    "templates",
130    "paths",
131    "delta",
132    "message",
133    "side",
134    "status_only",
135    "entries_changed",
136    "round-trip",
137    "cross-write",
138    "sibling",
139    "declared",
140    "elements",
141    "compared",
142    "unresolved-include",
143    "different_case_data",
144];
145
146/// One readable case in the corpus, as ingest found it.
147#[derive(Debug, Clone, Serialize, Deserialize)]
148pub struct Member {
149    /// Position within the bucket; the only name this file has downstream.
150    pub ordinal: usize,
151    /// The format powerio read it as. Format vocabulary, not case data.
152    pub format: String,
153    /// Read back by `compare`. Never leaves the work directory.
154    pub path: PathBuf,
155    pub warnings: Vec<String>,
156}
157
158/// Which model a case parsed into. Kept out of the sibling comparison
159/// entirely: a balanced network and a multiconductor one answer different
160/// questions and share no invariant.
161#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
162#[serde(rename_all = "kebab-case")]
163pub enum Domain {
164    Transmission,
165    Distribution,
166}
167
168/// Cases that fingerprint alike.
169#[derive(Debug, Clone, Serialize, Deserialize)]
170pub struct Bucket {
171    pub id: String,
172    pub domain: Domain,
173    pub fingerprint: Fingerprint,
174    pub members: Vec<Member>,
175}
176
177/// A file the harness could not turn into a network.
178#[derive(Debug, Clone, Serialize, Deserialize)]
179pub struct Unreadable {
180    pub path: PathBuf,
181    pub error: String,
182    /// Set when the reader panicked rather than returning an error.
183    pub panicked: bool,
184}
185
186#[derive(Debug, Clone, Serialize, Deserialize)]
187pub struct Ingest {
188    pub buckets: Vec<Bucket>,
189    pub unreadable: Vec<Unreadable>,
190    pub files_seen: usize,
191    /// Files no reader claimed and whose name did not announce a case: a
192    /// corpus's licences, notes and spreadsheets.
193    pub skipped: usize,
194    /// Entries that resolved outside the corpus root and were not read.
195    pub escaped: usize,
196}
197
198/// Whether `path` really lives under `root` once symlinks are resolved.
199///
200/// Both sides are canonicalized: comparing the walked path textually would
201/// accept a symlink whose name sits under the root and whose target does not.
202/// A path that cannot be canonicalized is treated as outside, since a file the
203/// harness cannot resolve is one it should not open.
204fn within(root: &Path, path: &Path) -> bool {
205    match (root.canonicalize(), path.canonicalize()) {
206        (Ok(root), Ok(path)) => path.starts_with(&root),
207        _ => false,
208    }
209}
210
211/// Whether a path announces itself as a case file. Used only to decide whether
212/// a parse failure is worth reporting, never to decide whether to try.
213fn names_a_case(path: &Path) -> bool {
214    const CASE_EXTENSIONS: [&str; 9] = [
215        "m", "raw", "rawx", "epc", "aux", "pwb", "uct", "json", "dss",
216    ];
217    path.extension()
218        .and_then(|e| e.to_str())
219        .map(str::to_ascii_lowercase)
220        .is_some_and(|ext| CASE_EXTENSIONS.contains(&ext.as_str()))
221}
222
223/// Walk `corpus`, parse everything readable, and bucket it by electrical
224/// fingerprint.
225///
226/// `max_bytes` skips any file larger, counted into `skipped`: the pairwise
227/// compare is quadratic in a bucket and a walk re-parses per hop, so one
228/// interconnection scale case in an otherwise moderate corpus would own the
229/// whole run. `None` reads everything.
230///
231/// # Errors
232///
233/// Fails when the corpus directory cannot be walked or the work directory
234/// cannot be written.
235pub fn ingest(corpus: &Path, work: &Path, max_bytes: Option<u64>) -> Result<Ingest> {
236    std::fs::create_dir_all(work)
237        .with_context(|| format!("create work directory {}", work.display()))?;
238    let mut files_seen = 0usize;
239    let mut skipped = 0usize;
240    let mut escaped = 0usize;
241    let mut unreadable = Vec::new();
242    let mut parsed: Vec<Parsed> = Vec::new();
243
244    for entry in walkdir::WalkDir::new(corpus)
245        .follow_links(false)
246        .sort_by_file_name()
247    {
248        let entry = match entry {
249            Ok(entry) => entry,
250            Err(err) => {
251                unreadable.push(Unreadable {
252                    path: err.path().unwrap_or(corpus).to_path_buf(),
253                    error: err.to_string(),
254                    panicked: false,
255                });
256                continue;
257            }
258        };
259        let path = entry.path();
260        // `follow_links(false)` stops the walk descending a symlinked
261        // directory, but a symlinked FILE is still handed over and reading it
262        // reads its target. A corpus is not trusted to point only at itself,
263        // so anything resolving outside the root is skipped rather than read.
264        if !within(corpus, path) {
265            escaped += 1;
266            continue;
267        }
268        let is_pypsa_dir = entry.file_type().is_dir() && path.join("network.csv").is_file();
269        if entry.file_type().is_dir() && !is_pypsa_dir {
270            continue;
271        }
272        files_seen += 1;
273        if let Some(cap) = max_bytes {
274            let over = entry.metadata().is_ok_and(|m| m.is_file() && m.len() > cap);
275            if over {
276                skipped += 1;
277                continue;
278            }
279        }
280        match read_case(path) {
281            // A file that parses to no buses is a library, not a case: dss
282            // linecode and wiredata decks parse happily and carry no network.
283            // Bucketing them groups every such library in a corpus into one
284            // meaningless bucket.
285            Ok(case) if case.fingerprint().buses == 0 => skipped += 1,
286            Ok(case) => {
287                parsed.push(Parsed {
288                    domain: case.domain(),
289                    fingerprint: case.fingerprint(),
290                    format: case.format(),
291                    path: path.to_path_buf(),
292                    warnings: case.warnings(),
293                });
294            }
295            // Everything is tried, so a case under an unexpected extension is
296            // still found; only a file that announced itself as a case is
297            // reported when it fails. Otherwise a corpus's licences and notes
298            // would bury the findings that matter.
299            Err(bad) if names_a_case(path) || bad.panicked => unreadable.push(bad),
300            Err(_) => skipped += 1,
301        }
302    }
303
304    let mut buckets = bucket(parsed);
305    buckets.sort_by(|a, b| {
306        a.fingerprint
307            .primary()
308            .cmp(&b.fingerprint.primary())
309            .then_with(|| a.fingerprint.cmp(&b.fingerprint))
310    });
311    for (i, bucket) in buckets.iter_mut().enumerate() {
312        bucket.id = format!("case-{i:03}");
313        for (ordinal, member) in bucket.members.iter_mut().enumerate() {
314            member.ordinal = ordinal;
315        }
316    }
317
318    let ingest = Ingest {
319        buckets,
320        unreadable,
321        files_seen,
322        skipped,
323        escaped,
324    };
325    write_json(&work.join(INGEST_FILE), &ingest)?;
326    Ok(ingest)
327}
328
329struct Parsed {
330    domain: Domain,
331    fingerprint: Fingerprint,
332    format: String,
333    path: PathBuf,
334    warnings: Vec<String>,
335}
336
337/// Group by the exact half of the fingerprint, then split each group where the
338/// tolerant half disagrees.
339fn bucket(parsed: Vec<Parsed>) -> Vec<Bucket> {
340    let mut groups: BTreeMap<(Domain, fingerprint::PrimaryKey), Vec<Parsed>> = BTreeMap::new();
341    for item in parsed {
342        groups
343            .entry((item.domain, item.fingerprint.primary()))
344            .or_default()
345            .push(item);
346    }
347    let mut out = Vec::new();
348    for ((domain, _), group) in groups {
349        // Within one primary key, the first member of each split is its
350        // representative; a later member joins the first split it agrees with.
351        let mut splits: Vec<Bucket> = Vec::new();
352        for item in group {
353            let member = Member {
354                ordinal: 0,
355                format: item.format,
356                path: item.path,
357                warnings: item.warnings,
358            };
359            match splits
360                .iter_mut()
361                .find(|split| split.fingerprint.agrees_with(&item.fingerprint))
362            {
363                Some(split) => split.members.push(member),
364                None => splits.push(Bucket {
365                    id: String::new(),
366                    domain,
367                    fingerprint: item.fingerprint,
368                    members: vec![member],
369                }),
370            }
371        }
372        out.extend(splits);
373    }
374    out
375}
376
377/// One parsed case, in whichever model claimed it.
378pub enum Case {
379    Balanced(Box<BalancedNetwork>, Vec<String>),
380    Multiconductor(Box<powerio_dist::MulticonductorNetwork>, Vec<String>),
381}
382
383impl Case {
384    fn domain(&self) -> Domain {
385        match self {
386            Self::Balanced(..) => Domain::Transmission,
387            Self::Multiconductor(..) => Domain::Distribution,
388        }
389    }
390
391    fn fingerprint(&self) -> Fingerprint {
392        match self {
393            Self::Balanced(net, _) => Fingerprint::of(net),
394            Self::Multiconductor(net, _) => Fingerprint::of_distribution(net),
395        }
396    }
397
398    fn format(&self) -> String {
399        match self {
400            Self::Balanced(net, _) => net.source_format().name().to_string(),
401            Self::Multiconductor(net, _) => net
402                .source_format()
403                .as_ref()
404                .map_or("dss", |f| f.name())
405                .to_string(),
406        }
407    }
408
409    fn warnings(&self) -> Vec<String> {
410        match self {
411            Self::Balanced(_, warnings) | Self::Multiconductor(_, warnings) => warnings.clone(),
412        }
413    }
414}
415
416/// Parse one path, turning a reader panic into a result rather than taking the
417/// process down. A crash on real input is the most valuable finding the
418/// harness can produce, so it must survive to the report.
419///
420/// The balanced reader is tried first and the multiconductor reader second:
421/// the two claim disjoint formats, and the balanced one names the other in its
422/// error when a distribution file reaches it.
423fn read_case(path: &Path) -> std::result::Result<Case, Unreadable> {
424    let unreadable = |error: String, panicked: bool| Unreadable {
425        path: path.to_path_buf(),
426        error,
427        panicked,
428    };
429    let balanced_error = match catch_panic(|| crate::module_io::load_balanced_module(path, None)) {
430        Ok(Ok(parsed)) => {
431            let rendered = powerio_core::render_diagnostics(parsed.diagnostics());
432            return Ok(Case::Balanced(Box::new(parsed.into_value()), rendered));
433        }
434        Err(message) => return Err(unreadable(message, true)),
435        Ok(Err(err)) => err.to_string(),
436    };
437    match catch_panic(|| crate::module_io::load_multiconductor_module(path, None)) {
438        Ok(Ok(parsed)) => {
439            let warnings = powerio_core::render_diagnostics(parsed.diagnostics());
440            Ok(Case::Multiconductor(
441                Box::new(parsed.into_value()),
442                warnings,
443            ))
444        }
445        // A `.m` that failed the MATPOWER parse used to report "unknown
446        // distribution format `m`": the fallback reader's refusal displaced
447        // the diagnosis. When the distribution reader does not even claim the
448        // format, the transmission error is the one that says what is wrong.
449        Ok(Err(err)) => {
450            let message = err.to_string();
451            Err(unreadable(
452                if message.contains("unknown distribution format")
453                    || message.contains("REQUEST.FORMAT.UNKNOWN")
454                {
455                    balanced_error
456                } else {
457                    message
458                },
459                false,
460            ))
461        }
462        Err(message) => Err(unreadable(message, true)),
463    }
464}
465
466/// Re-read a member as a balanced network, or nothing when it is not one.
467fn balanced(path: &Path) -> Option<BalancedNetwork> {
468    match read_case(path) {
469        Ok(Case::Balanced(net, _)) => Some(*net),
470        _ => None,
471    }
472}
473
474/// Re-read a member as a multiconductor network, or nothing when it is not
475/// one.
476fn multiconductor(path: &Path) -> Option<powerio_dist::MulticonductorNetwork> {
477    match read_case(path) {
478        Ok(Case::Multiconductor(net, _)) => Some(*net),
479        _ => None,
480    }
481}
482
483/// Run `f`, returning the panic message instead of unwinding. The default hook
484/// is silenced for the duration so a corpus of unparseable files does not
485/// print a backtrace per file.
486fn catch_panic<T>(
487    f: impl FnOnce() -> T + std::panic::UnwindSafe,
488) -> std::result::Result<T, String> {
489    let previous = std::panic::take_hook();
490    std::panic::set_hook(Box::new(|_| {}));
491    let result = std::panic::catch_unwind(f);
492    std::panic::set_hook(previous);
493    result.map_err(|payload| {
494        payload
495            .downcast_ref::<&str>()
496            .map(|s| (*s).to_string())
497            .or_else(|| payload.downcast_ref::<String>().cloned())
498            .unwrap_or_else(|| "panic".to_string())
499    })
500}
501
502/// How two networks in one bucket were brought together.
503#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
504#[serde(rename_all = "kebab-case")]
505pub enum Via {
506    /// Parse, write in the same format, read back.
507    RoundTrip,
508    /// Parse one sibling, write it in another sibling's format, read back.
509    CrossWrite,
510    /// Two independent reads of the same case in two formats, compared
511    /// directly. The only leg that can catch a reader both directions of a
512    /// round trip agree about.
513    Sibling,
514    /// One step of a [`walk`] chain. Its `from` is whatever the walk was in,
515    /// which is a network several formats deep rather than a corpus file, and
516    /// the ordinals are hop positions rather than bucket members.
517    Walk,
518}
519
520#[derive(Debug, Clone, Serialize, Deserialize)]
521pub struct Leg {
522    pub from: String,
523    pub to: String,
524    pub via: Via,
525    pub from_ordinal: usize,
526    pub to_ordinal: usize,
527}
528
529/// Everything one leg produced, still in raw values. Stays in the work
530/// directory; [`report`] is what sanitizes it.
531#[derive(Debug, Clone, Serialize, Deserialize)]
532pub struct Comparison {
533    pub bucket: String,
534    pub leg: Leg,
535    pub warnings: Vec<String>,
536    pub core_changed: Option<String>,
537    pub ybus: Option<String>,
538    pub ybus_unavailable: Option<String>,
539    pub injection: Option<String>,
540    pub injection_status_only: bool,
541    /// A DC line's terminal power moved. Its own field because HVDC is an
542    /// injection no admittance matrix sees and several formats cannot state at
543    /// all, so it is graded separately from AC power.
544    pub dc_terminal: Option<String>,
545    /// Serde paths that changed, already collapsed to their class.
546    pub model_diffs: Vec<String>,
547    pub failure: Option<String>,
548    /// Two members of one bucket describe related cases but contain different
549    /// limits or other declared case data.
550    pub different_case_data: bool,
551    /// A written deck that pulls in other files, which a string readback
552    /// cannot resolve. Recorded for the same reason as
553    /// [`different_case_data`](Self::different_case_data).
554    pub unresolved_include: bool,
555    /// Elements the two sides disagree about the service status of.
556    pub status_changed: usize,
557}
558
559#[derive(Debug, Clone, Serialize, Deserialize)]
560pub struct Comparisons {
561    pub comparisons: Vec<Comparison>,
562}
563
564/// Run the invariants over every bucket in the work directory.
565///
566/// # Errors
567///
568/// Fails when the work directory has no ingest output or cannot be written.
569pub fn compare(work: &Path) -> Result<Comparisons> {
570    let ingest: Ingest = read_json(&work.join(INGEST_FILE))?;
571    let mut comparisons = Vec::new();
572
573    for bucket in &ingest.buckets {
574        match bucket.domain {
575            Domain::Transmission => compare_transmission(bucket, &mut comparisons),
576            Domain::Distribution => compare_distribution(bucket, &mut comparisons),
577        }
578    }
579
580    let out = Comparisons { comparisons };
581    write_json(&work.join(COMPARE_FILE), &out)?;
582    Ok(out)
583}
584
585/// The largest bucket compared pairwise.
586///
587/// Every member is written into every other member's format and compared
588/// against every other member, so the work is quadratic in the bucket size and
589/// each leg re-parses. An archive holding hundreds of spellings of one case
590/// would otherwise run for hours with no way to tell it apart from a hang.
591/// Bigger buckets keep the first [`MAX_BUCKET_MEMBERS`] members and say so.
592const MAX_BUCKET_MEMBERS: usize = 24;
593
594/// The members of a bucket that take part in the pairwise comparison.
595fn compared_members(bucket: &Bucket) -> &[Member] {
596    let n = bucket.members.len().min(MAX_BUCKET_MEMBERS);
597    &bucket.members[..n]
598}
599
600fn compare_transmission(bucket: &Bucket, out: &mut Vec<Comparison>) {
601    // Re-read rather than trusting a cached copy: the corpus is the authority
602    // and reading it again costs nothing next to the writes.
603    let members = compared_members(bucket);
604    let networks: Vec<Option<BalancedNetwork>> =
605        members.iter().map(|m| balanced(&m.path)).collect();
606    for (i, member) in members.iter().enumerate() {
607        let Some(source) = networks[i].as_ref() else {
608            continue;
609        };
610        for (j, other) in members.iter().enumerate() {
611            let Some(target) = powerio_tx::format::parse_target_format(&other.format) else {
612                continue;
613            };
614            let via = if i == j {
615                Via::RoundTrip
616            } else {
617                Via::CrossWrite
618            };
619            out.push(convert_leg(bucket, member, other, via, source, target));
620        }
621        for (j, other) in members.iter().enumerate().skip(i + 1) {
622            let Some(sibling) = networks[j].as_ref() else {
623                continue;
624            };
625            // Two files in one bucket that state different limits are a case
626            // and its derivative, and their differences are honest. They are
627            // still compared — a reader defect hides just as well between a
628            // case and its derivative — but the pair is labelled so the severity
629            // says which kind of difference this is.
630            let twin = fingerprint::same_case_data(source, sibling);
631            let mut leg = sibling_leg(bucket, member, other, source, sibling);
632            leg.different_case_data = !twin;
633            out.push(leg);
634        }
635    }
636}
637
638/// The distribution half runs the two properties that mean anything for a
639/// multiconductor model: element counts and the typed-model diff. There is no
640/// `Y_bus` or per-bus injection here — a distribution line carries an
641/// impedance matrix per phase pair, and powerio builds no admittance matrix
642/// for it.
643fn compare_distribution(bucket: &Bucket, out: &mut Vec<Comparison>) {
644    let members = compared_members(bucket);
645    let networks: Vec<Option<powerio_dist::MulticonductorNetwork>> =
646        members.iter().map(|m| multiconductor(&m.path)).collect();
647    for (i, member) in members.iter().enumerate() {
648        let Some(source) = networks[i].as_ref() else {
649            continue;
650        };
651        for (j, other) in members.iter().enumerate() {
652            let Some(target) = powerio_dist::parse_dist_target_format(&other.format) else {
653                continue;
654            };
655            let via = if i == j {
656                Via::RoundTrip
657            } else {
658                Via::CrossWrite
659            };
660            out.push(dist_convert_leg(bucket, member, other, via, source, target));
661        }
662    }
663}
664
665fn dist_convert_leg(
666    bucket: &Bucket,
667    from: &Member,
668    to: &Member,
669    via: Via,
670    source: &powerio_dist::MulticonductorNetwork,
671    target: powerio_dist::DistTargetFormat,
672) -> Comparison {
673    let mut out = empty_comparison(
674        bucket,
675        Leg {
676            from: from.format.clone(),
677            to: to.format.clone(),
678            via,
679            from_ordinal: from.ordinal,
680            to_ordinal: to.ordinal,
681        },
682    );
683    let source_module = powerio_core::PioModule::new(source.clone());
684    let emission = match catch_panic(|| {
685        crate::module_io::emit_multiconductor_module(&source_module, target)
686    }) {
687        Ok(Ok(emission)) => emission,
688        Ok(Err(error)) => {
689            out.failure = Some(format!("emit: {error}"));
690            return out;
691        }
692        Err(message) => {
693            out.failure = Some(format!("emit panicked: {message}"));
694            return out;
695        }
696    };
697    out.warnings.extend(emission.render_diagnostics());
698    let token = to.format.clone();
699    let text = emission.text;
700    // A deck that pulls in other files cannot be read back from a string:
701    // `Redirect` and `Compile` resolve against a directory, and the corpus is
702    // read-only, so there is nowhere to put the written master beside its
703    // includes. Reading it anyway loses every object the includes carry and
704    // reports it as a conversion loss, which is a statement about the harness
705    // rather than about powerio.
706    if has_include(&text) {
707        out.unresolved_include = true;
708        return out;
709    }
710    let parsed = match catch_panic(|| crate::module_io::load_multiconductor_memory(&text, &token)) {
711        Ok(Ok(parsed)) => parsed,
712        Ok(Err(err)) => {
713            out.failure = Some(format!("readback: {err}"));
714            return out;
715        }
716        Err(message) => {
717            out.failure = Some(format!("readback panicked: {message}"));
718            return out;
719        }
720    };
721    // The readback's own declarations count toward warning parity, exactly as
722    // the transmission leg counts them; without this a loss the reader states
723    // on re-parse was graded undeclared.
724    out.warnings
725        .extend(powerio_core::render_diagnostics(parsed.diagnostics()));
726    let before = invariants::distribution_core(source);
727    let after = invariants::distribution_core(parsed.value());
728    if before != after {
729        out.core_changed = Some(dist_core_delta(&before, &after));
730    }
731    let clean = parsed.into_value();
732    let diffs = invariants::model_diffs(
733        &invariants::distribution_value(source),
734        &invariants::distribution_value(&clean),
735    );
736    out.model_diffs = diffs
737        .iter()
738        .map(|d| anonymize::collapse_path(&d.path))
739        .collect();
740    out.model_diffs.sort();
741    out.model_diffs.dedup();
742    out
743}
744
745/// Whether a written OpenDSS deck pulls in files a string readback cannot
746/// resolve. Shared by the pairwise compare and the walk, which must skip the
747/// same decks for the same reason.
748fn has_include(text: &str) -> bool {
749    text.lines().any(|line| {
750        let word = line.split_whitespace().next().unwrap_or("");
751        word.eq_ignore_ascii_case("redirect")
752            || word.eq_ignore_ascii_case("compile")
753            // A `Buscoords` reference names a sidecar file the same way, so a
754            // string readback would lose every location and report it as a
755            // conversion loss that is really the harness's own limitation.
756            || word.eq_ignore_ascii_case("buscoords")
757    })
758}
759
760fn dist_core_delta(
761    before: &invariants::DistributionCore,
762    after: &invariants::DistributionCore,
763) -> String {
764    let mut parts = Vec::new();
765    for (label, a, b) in [
766        ("buses", before.buses, after.buses),
767        ("loads", before.loads, after.loads),
768        ("generators", before.generators, after.generators),
769        ("shunts", before.shunts, after.shunts),
770    ] {
771        if a != b {
772            parts.push(format!("{label} {}", count_delta(a, b)));
773        }
774    }
775    if before.load_p != after.load_p {
776        parts.push("load p".to_string());
777    }
778    if before.load_q != after.load_q {
779        parts.push("load q".to_string());
780    }
781    parts.join(", ")
782}
783
784fn empty_comparison(bucket: &Bucket, leg: Leg) -> Comparison {
785    Comparison {
786        bucket: bucket.id.clone(),
787        leg,
788        warnings: Vec::new(),
789        core_changed: None,
790        ybus: None,
791        ybus_unavailable: None,
792        injection: None,
793        injection_status_only: false,
794        dc_terminal: None,
795        model_diffs: Vec::new(),
796        failure: None,
797        different_case_data: false,
798        unresolved_include: false,
799        status_changed: 0,
800    }
801}
802
803fn convert_leg(
804    bucket: &Bucket,
805    from: &Member,
806    to: &Member,
807    via: Via,
808    source: &BalancedNetwork,
809    target: TargetFormat,
810) -> Comparison {
811    let mut out = empty_comparison(
812        bucket,
813        Leg {
814            from: from.format.clone(),
815            to: to.format.clone(),
816            via,
817            from_ordinal: from.ordinal,
818            to_ordinal: to.ordinal,
819        },
820    );
821    let source_module = powerio_core::PioModule::new(source.clone());
822    let emission = match catch_panic(|| {
823        crate::module_io::emit_balanced_module(
824            &source_module,
825            target,
826            &powerio_tx::EmitOptions::default(),
827        )
828    }) {
829        Ok(Ok(emission)) => emission,
830        Ok(Err(err)) => {
831            out.failure = Some(format!("emit: {err}"));
832            return out;
833        }
834        Err(message) => {
835            out.failure = Some(format!("emit panicked: {message}"));
836            return out;
837        }
838    };
839    out.warnings.extend(emission.render_diagnostics());
840    let token = to.format.clone();
841    let text = emission.text;
842    let parsed = match catch_panic(|| crate::module_io::load_balanced_memory(&text, &token)) {
843        Ok(Ok(parsed)) => parsed,
844        Ok(Err(err)) => {
845            out.failure = Some(format!("readback: {err}"));
846            return out;
847        }
848        Err(message) => {
849            out.failure = Some(format!("readback panicked: {message}"));
850            return out;
851        }
852    };
853    out.warnings
854        .extend(powerio_core::render_diagnostics(parsed.diagnostics()));
855    fill_invariants(&mut out, source, parsed.value());
856    out
857}
858
859fn sibling_leg(
860    bucket: &Bucket,
861    from: &Member,
862    to: &Member,
863    source: &BalancedNetwork,
864    sibling: &BalancedNetwork,
865) -> Comparison {
866    let mut out = empty_comparison(
867        bucket,
868        Leg {
869            from: from.format.clone(),
870            to: to.format.clone(),
871            via: Via::Sibling,
872            from_ordinal: from.ordinal,
873            to_ordinal: to.ordinal,
874        },
875    );
876    // Two readers, one case: only the electrical invariants apply. Two formats
877    // legitimately carry different fields, so a typed-model diff between them
878    // says nothing.
879    //
880    // Service status is compared by count rather than through the injections,
881    // because a machine that is out of service usually states zero output: the
882    // injections agree while the two files disagree about what the operator
883    // can dispatch. That difference is invisible to every other property here.
884    out.status_changed = invariants::status_disagreements(source, sibling);
885    let before = invariants::transmission_core(source);
886    let after = invariants::transmission_core(sibling);
887    if before != after {
888        out.core_changed = Some(core_delta(&before, &after));
889    }
890    match invariants::ybus_change(source, sibling) {
891        Ok(Some(change)) => out.ybus = Some(change.to_string()),
892        Ok(None) => {}
893        Err(side) => out.ybus_unavailable = Some(unavailable_label(&side)),
894    }
895    if let Some(change) = invariants::injection_change(source, sibling) {
896        out.injection_status_only = change.status_only;
897        out.injection = Some(change.to_string());
898    }
899    if let Some(change) = invariants::dc_terminal_change(source, sibling) {
900        out.dc_terminal = Some(change.to_string());
901    }
902    out
903}
904
905fn fill_invariants(out: &mut Comparison, source: &BalancedNetwork, result: &BalancedNetwork) {
906    let before = invariants::transmission_core(source);
907    let after = invariants::transmission_core(result);
908    if before != after {
909        out.core_changed = Some(core_delta(&before, &after));
910    }
911    match invariants::ybus_change(source, result) {
912        Ok(Some(change)) => out.ybus = Some(change.to_string()),
913        Ok(None) => {}
914        Err(side) => out.ybus_unavailable = Some(unavailable_label(&side)),
915    }
916    if let Some(change) = invariants::injection_change(source, result) {
917        out.injection_status_only = change.status_only;
918        out.injection = Some(change.to_string());
919    }
920    if let Some(change) = invariants::dc_terminal_change(source, result) {
921        out.dc_terminal = Some(change.to_string());
922    }
923    let diffs = invariants::model_diffs(
924        &invariants::transmission_value(source),
925        &invariants::transmission_value(result),
926    );
927    out.model_diffs = diffs
928        .iter()
929        .map(|d| anonymize::collapse_path(&d.path))
930        .collect();
931    out.model_diffs.sort();
932    out.model_diffs.dedup();
933}
934
935/// A signed count difference, stated without a cast that could wrap on a
936/// corpus large enough to matter.
937fn count_delta(before: usize, after: usize) -> String {
938    if after >= before {
939        format!("+{}", after - before)
940    } else {
941        format!("-{}", before - after)
942    }
943}
944
945fn unavailable_label(side: &YbusUnavailable) -> String {
946    match side {
947        YbusUnavailable::Before => "source".to_string(),
948        YbusUnavailable::After => "result".to_string(),
949    }
950}
951
952/// Element-count and total deltas, never the absolute counts: how many loads a
953/// utility runs is case data, how many a conversion lost is a defect.
954/// Whether a [`core_delta`] string reports power moving rather than elements
955/// being regrouped.
956///
957/// Counts alone moving is a merge or a split, which formats do legitimately
958/// (MATPOWER states one bus demand where PSS/E states three loads). Power
959/// moving is a loss. The labels live in `core_delta` and `dist_core_delta`
960/// just above, so the test belongs beside them: read off the delta in two
961/// places, it is one rule that a fifth power-bearing field would silently
962/// break in whichever site nobody updated.
963pub(super) fn power_moved(core: &str) -> bool {
964    ["load p", "load q", "gen p", "base mva"]
965        .iter()
966        .any(|label| core.contains(label))
967}
968
969fn core_delta(
970    before: &invariants::TransmissionCore,
971    after: &invariants::TransmissionCore,
972) -> String {
973    let mut parts = Vec::new();
974    let mut delta = |label: &str, a: usize, b: usize| {
975        if a != b {
976            parts.push(format!("{label} {}", count_delta(a, b)));
977        }
978    };
979    delta("buses", before.buses, after.buses);
980    delta("branches", before.branches, after.branches);
981    delta("generators", before.generators, after.generators);
982    delta("loads", before.loads, after.loads);
983    delta("shunts", before.shunts, after.shunts);
984    if before.load_p != after.load_p {
985        parts.push("load p".to_string());
986    }
987    if before.load_q != after.load_q {
988        parts.push("load q".to_string());
989    }
990    if before.gen_p != after.gen_p {
991        parts.push("gen p".to_string());
992    }
993    if before.base_mva != after.base_mva {
994        parts.push("base mva".to_string());
995    }
996    parts.join(", ")
997}
998
999fn write_json<T: Serialize>(path: &Path, value: &T) -> Result<()> {
1000    let text = serde_json::to_string_pretty(value)?;
1001    std::fs::write(path, text).with_context(|| format!("write {}", path.display()))?;
1002    Ok(())
1003}
1004
1005fn read_json<T: for<'de> Deserialize<'de>>(path: &Path) -> Result<T> {
1006    let text = std::fs::read_to_string(path).with_context(|| {
1007        format!(
1008            "read {} (run `powerio corpus ingest` first)",
1009            path.display()
1010        )
1011    })?;
1012    Ok(serde_json::from_str(&text)?)
1013}
1014
1015/// Sanitize the comparisons into findings and write them out.
1016///
1017/// # Errors
1018///
1019/// Fails when the work directory is incomplete, when the output cannot be
1020/// written, or when the emitted report fails its own leak audit.
1021pub fn report(work: &Path, findings_path: &Path, summary_path: Option<&Path>) -> Result<usize> {
1022    let ingest: Ingest = read_json(&work.join(INGEST_FILE))?;
1023    // Either analysis feeds the report on its own: a run may compare, walk, or
1024    // both. Only both missing means there is nothing to report yet.
1025    let comparisons: Option<Comparisons> = read_json(&work.join(COMPARE_FILE)).ok();
1026    let walks: Option<walk::Walks> = read_json(&work.join(walk::WALK_FILE)).ok();
1027    if comparisons.is_none() && walks.is_none() {
1028        anyhow::bail!(
1029            "nothing to report in {}: run `powerio corpus compare` or `powerio corpus walk` first",
1030            work.display()
1031        );
1032    }
1033    let comparisons = comparisons.unwrap_or(Comparisons {
1034        comparisons: Vec::new(),
1035    });
1036
1037    let mut sanitizer = Sanitizer::new();
1038    // The report's own schema. Without this a deck stating `delta` for a
1039    // winding makes the harness's `delta` key look like a leak, and the run
1040    // stops on its own field names.
1041    for key in SCHEMA_KEYS {
1042        sanitizer.allow(key);
1043    }
1044    for bucket in &ingest.buckets {
1045        sanitizer.allow(&bucket.id);
1046        for member in &bucket.members {
1047            sanitizer.allow(&member.format);
1048            sanitizer.learn_path(&member.path);
1049            match read_case(&member.path) {
1050                Ok(Case::Balanced(net, _)) => {
1051                    sanitizer.learn_buses(net.buses().iter().map(|b| b.id.0));
1052                    sanitizer.learn_network(&serde_json::to_value(&*net)?);
1053                }
1054                Ok(Case::Multiconductor(net, _)) => {
1055                    sanitizer.learn_network(&serde_json::to_value(&net)?);
1056                }
1057                Err(_) => {}
1058            }
1059        }
1060    }
1061    for bad in &ingest.unreadable {
1062        sanitizer.learn_path(&bad.path);
1063    }
1064
1065    let mut findings = Vec::new();
1066    for bad in &ingest.unreadable {
1067        findings.push(Finding {
1068            bucket: None,
1069            code: if bad.panicked {
1070                "parse.panic"
1071            } else {
1072                "parse.failure"
1073            },
1074            severity: if bad.panicked { "crash" } else { "declared" },
1075            leg: None,
1076            detail: serde_json::json!({ "message": sanitizer.template(&bad.error) }),
1077        });
1078    }
1079    for comparison in &comparisons.comparisons {
1080        findings.extend(findings_for(comparison, &sanitizer));
1081    }
1082    if let Some(walks) = &walks {
1083        for w in &walks.walks {
1084            findings.extend(walk_findings(w, &sanitizer));
1085        }
1086    }
1087
1088    let jsonl = render_jsonl(&findings)?;
1089    audit_or_bail(&sanitizer, &jsonl, findings_path)?;
1090    std::fs::write(findings_path, &jsonl)
1091        .with_context(|| format!("write {}", findings_path.display()))?;
1092
1093    if let Some(path) = summary_path {
1094        let summary = render_summary(&ingest, &findings);
1095        audit_or_bail(&sanitizer, &summary, path)?;
1096        std::fs::write(path, summary).with_context(|| format!("write {}", path.display()))?;
1097    }
1098    Ok(findings.len())
1099}
1100
1101fn audit_or_bail(sanitizer: &Sanitizer, text: &str, path: &Path) -> Result<()> {
1102    if let Err(leaks) = sanitizer.audit(text) {
1103        // Names neither the string nor the file it came from: this message is
1104        // as public as the report would have been.
1105        anyhow::bail!(
1106            "refusing to write {}: {} corpus string(s) reached it, first at {}",
1107            path.display(),
1108            leaks.len(),
1109            leaks[0]
1110        );
1111    }
1112    Ok(())
1113}
1114
1115#[derive(Debug, Clone, Serialize)]
1116pub struct Finding {
1117    #[serde(skip_serializing_if = "Option::is_none")]
1118    pub bucket: Option<String>,
1119    pub code: &'static str,
1120    pub severity: &'static str,
1121    #[serde(skip_serializing_if = "Option::is_none")]
1122    pub leg: Option<Leg>,
1123    pub detail: serde_json::Value,
1124}
1125
1126// One arm per finding class; splitting it would scatter the severity rules
1127// that only make sense read together.
1128#[allow(clippy::too_many_lines)]
1129fn findings_for(comparison: &Comparison, sanitizer: &Sanitizer) -> Vec<Finding> {
1130    let mut out = Vec::new();
1131    let bucket = Some(comparison.bucket.clone());
1132    let leg = Some(comparison.leg.clone());
1133    let mut push = |code: &'static str, severity: &'static str, detail: serde_json::Value| {
1134        out.push(Finding {
1135            bucket: bucket.clone(),
1136            code,
1137            severity,
1138            leg: leg.clone(),
1139            detail,
1140        });
1141    };
1142
1143    if comparison.unresolved_include {
1144        push(
1145            "leg.unresolved-include",
1146            "declared",
1147            serde_json::json!({ "compared": false }),
1148        );
1149        return out;
1150    }
1151    if comparison.different_case_data {
1152        push(
1153            "sibling.different-data",
1154            "declared",
1155            serde_json::json!({ "compared": true }),
1156        );
1157    }
1158    if let Some(failure) = &comparison.failure {
1159        let panicked = failure.contains("panicked");
1160        push(
1161            if panicked { "leg.panic" } else { "leg.failure" },
1162            if panicked { "crash" } else { "silent-drop" },
1163            serde_json::json!({ "message": sanitizer.template(failure) }),
1164        );
1165    }
1166    if let Some(side) = &comparison.ybus_unavailable {
1167        push(
1168            "electrical.unavailable",
1169            "silent-value-change",
1170            serde_json::json!({ "side": side }),
1171        );
1172    }
1173    if comparison.ybus.is_some() {
1174        let honest_difference = comparison.leg.via == Via::Sibling
1175            && (comparison.different_case_data || comparison.status_changed > 0);
1176        // The changed entry's bus pair and magnitudes stay in the work
1177        // directory: a `Y_bus` entry is grid data. The finding states that the
1178        // admittance moved on this leg, which is what makes it actionable.
1179        push(
1180            if honest_difference {
1181                "sibling.ybus"
1182            } else {
1183                "electrical.ybus"
1184            },
1185            if honest_difference {
1186                "declared"
1187            } else {
1188                "silent-value-change"
1189            },
1190            serde_json::json!({ "entries_changed": 1 }),
1191        );
1192    }
1193    if comparison.injection.is_some() {
1194        // On a conversion leg powerio moved the power itself, so any move is a
1195        // defect. On a sibling leg the two files may honestly hold different
1196        // operating points — a base case and its contingency case sit side
1197        // by side in every planning archive — so a move that is only a status
1198        // disagreement is reported for triage rather than as a defect.
1199        let honest_difference = comparison.leg.via == Via::Sibling
1200            && (comparison.injection_status_only || comparison.different_case_data);
1201        push(
1202            if honest_difference {
1203                "sibling.status"
1204            } else {
1205                "electrical.injection"
1206            },
1207            if honest_difference {
1208                "declared"
1209            } else {
1210                "silent-value-change"
1211            },
1212            serde_json::json!({ "status_only": comparison.injection_status_only }),
1213        );
1214    }
1215    if comparison.dc_terminal.is_some() {
1216        let declared = !comparison.warnings.is_empty();
1217        push(
1218            "electrical.dc-terminal",
1219            if declared {
1220                "declared"
1221            } else {
1222                "silent-value-change"
1223            },
1224            serde_json::json!({ "declared": declared }),
1225        );
1226    }
1227    if let Some(core) = &comparison.core_changed {
1228        let power_moved = power_moved(core);
1229        push(
1230            if power_moved {
1231                "core.power"
1232            } else {
1233                "core.regrouped"
1234            },
1235            if power_moved {
1236                "silent-drop"
1237            } else {
1238                "declared"
1239            },
1240            serde_json::json!({ "delta": core }),
1241        );
1242    }
1243    if comparison.status_changed > 0 {
1244        push(
1245            "sibling.status",
1246            "declared",
1247            serde_json::json!({ "elements": comparison.status_changed }),
1248        );
1249    }
1250    if !comparison.model_diffs.is_empty() {
1251        // A serde path is mostly powerio's own field names, but an `Extras`
1252        // map contributes whatever token the source stated, so the path is
1253        // templated like any other text the corpus touched.
1254        let paths: Vec<String> = comparison
1255            .model_diffs
1256            .iter()
1257            .map(|p| sanitizer.template(p))
1258            .collect();
1259        let declared = !comparison.warnings.is_empty();
1260        push(
1261            if declared {
1262                "loss.declared"
1263            } else {
1264                "loss.undeclared"
1265            },
1266            if declared {
1267                "declared"
1268            } else {
1269                "undeclared-loss"
1270            },
1271            serde_json::json!({ "paths": paths }),
1272        );
1273    }
1274    let mut templates: BTreeMap<String, usize> = BTreeMap::new();
1275    for warning in &comparison.warnings {
1276        *templates.entry(sanitizer.template(warning)).or_default() += 1;
1277    }
1278    if !templates.is_empty() {
1279        push(
1280            "warning.observed",
1281            "declared",
1282            serde_json::json!({ "templates": templates }),
1283        );
1284    }
1285    out
1286}
1287
1288/// Findings for one walk.
1289///
1290/// Only the properties a chain can state are reported here. A hop's own loss
1291/// is the same quantity [`findings_for`] already grades, and re-reporting it
1292/// per hop would bury the three findings that are new under a copy of the
1293/// pairwise report.
1294///
1295/// Every finding carries the path that produced it, as format tokens, and the
1296/// seed that replays it. Both are harness vocabulary rather than case data.
1297fn walk_findings(w: &walk::Walk, sanitizer: &Sanitizer) -> Vec<Finding> {
1298    let mut out = Vec::new();
1299    let path: Vec<String> = w.hops.iter().map(|h| h.to.clone()).collect();
1300    for (index, hop) in w.hops.iter().enumerate() {
1301        let leg = Some(Leg {
1302            from: if index == 0 {
1303                w.origin.clone()
1304            } else {
1305                w.hops[index - 1].to.clone()
1306            },
1307            to: hop.to.clone(),
1308            via: Via::Walk,
1309            from_ordinal: index,
1310            to_ordinal: index + 1,
1311        });
1312        let mut push =
1313            |code: &'static str, severity: &'static str, mut detail: serde_json::Value| {
1314                if let Some(map) = detail.as_object_mut() {
1315                    map.insert("origin".into(), serde_json::json!(w.origin));
1316                    map.insert("path".into(), serde_json::json!(path));
1317                    map.insert("hop".into(), serde_json::json!(index));
1318                    map.insert("seed".into(), serde_json::json!(w.seed.to_string()));
1319                }
1320                out.push(Finding {
1321                    bucket: Some(w.bucket.clone()),
1322                    code,
1323                    severity,
1324                    leg: leg.clone(),
1325                    detail,
1326                });
1327            };
1328
1329        if let Some(failure) = &hop.failure {
1330            let panicked = failure.contains("panicked");
1331            push(
1332                if panicked {
1333                    "walk.panic"
1334                } else {
1335                    "walk.failure"
1336                },
1337                if panicked { "crash" } else { "silent-drop" },
1338                serde_json::json!({ "message": sanitizer.template(failure) }),
1339            );
1340        }
1341        if let Some(drift) = &hop.drift {
1342            // Conversion should settle. A second pass through a format that
1343            // lands somewhere else means the reader and writer are not a
1344            // projection, and every later hop inherits the drift. Drift in
1345            // retained extras alone is churn worth seeing; drift in an
1346            // electrical property is a defect.
1347            push(
1348                "walk.drift",
1349                if hop.drift_electrical {
1350                    "silent-value-change"
1351                } else {
1352                    "declared"
1353                },
1354                serde_json::json!({
1355                    "revisit_of": hop.revisit_of,
1356                    "delta": sanitizer.template(drift),
1357                }),
1358            );
1359        }
1360        if let Some(delta) = &hop.path_dependent {
1361            // The destination format is the same and the answer is not, so the
1362            // route through the graph changed the result. No single leg can
1363            // state this, which is the whole reason walks exist. Retention
1364            // honestly thins along a chain, so extras-only path dependence is
1365            // reported for triage rather than as a defect.
1366            push(
1367                "walk.path-dependent",
1368                if hop.path_dependent_electrical {
1369                    "silent-value-change"
1370                } else {
1371                    "declared"
1372                },
1373                serde_json::json!({ "direct": sanitizer.template(delta) }),
1374            );
1375        }
1376        if !hop.resurrected.is_empty() {
1377            // A table was empty and is not. When the hop's own electrical
1378            // properties held, the rows are a regrouping the target format
1379            // demands (pandapower states transformer charging as bus shunts);
1380            // when they moved too, a writer invented data no reader gave it.
1381            push(
1382                "walk.resurrection",
1383                if hop.leg.electrical() {
1384                    "silent-value-change"
1385                } else {
1386                    "declared"
1387                },
1388                serde_json::json!({ "resurrected": hop.resurrected }),
1389            );
1390        }
1391        if !hop.emptied.is_empty() {
1392            // Not a defect. It says the hops after this one are graded against
1393            // an empty table, so their silence is not evidence.
1394            push(
1395                "walk.absorbed",
1396                "declared",
1397                serde_json::json!({ "emptied": hop.emptied }),
1398            );
1399        }
1400    }
1401    out
1402}
1403
1404fn render_jsonl(findings: &[Finding]) -> Result<String> {
1405    let mut out = String::new();
1406    for finding in findings {
1407        out.push_str(&serde_json::to_string(finding)?);
1408        out.push('\n');
1409    }
1410    Ok(out)
1411}
1412
1413fn render_summary(ingest: &Ingest, findings: &[Finding]) -> String {
1414    use std::fmt::Write as _;
1415    let mut out = String::new();
1416    let _ = writeln!(out, "# Corpus run\n");
1417    let _ = writeln!(
1418        out,
1419        "{} files seen, {} buckets, {} unreadable.\n",
1420        ingest.files_seen,
1421        ingest.buckets.len(),
1422        ingest.unreadable.len()
1423    );
1424    let mut sizes: BTreeMap<usize, usize> = BTreeMap::new();
1425    for bucket in &ingest.buckets {
1426        *sizes.entry(bucket.members.len()).or_default() += 1;
1427    }
1428    let _ = writeln!(out, "## Buckets by sibling count\n");
1429    let _ = writeln!(out, "| siblings | buckets |");
1430    let _ = writeln!(out, "| --- | --- |");
1431    for (size, count) in &sizes {
1432        let _ = writeln!(out, "| {size} | {count} |");
1433    }
1434    let mut by_code: BTreeMap<(&str, &str), usize> = BTreeMap::new();
1435    for finding in findings {
1436        *by_code.entry((finding.severity, finding.code)).or_default() += 1;
1437    }
1438    let _ = writeln!(out, "\n## Findings\n");
1439    let _ = writeln!(out, "| severity | code | count |");
1440    let _ = writeln!(out, "| --- | --- | --- |");
1441    for ((severity, code), count) in &by_code {
1442        let _ = writeln!(out, "| {severity} | {code} | {count} |");
1443    }
1444    out
1445}
1446
1447#[cfg(test)]
1448mod tests {
1449    use std::collections::BTreeSet;
1450    use std::path::Path;
1451
1452    use super::{Case, read_case};
1453
1454    /// Every serde key of `value` outside maps whose keys come from case data.
1455    fn model_keys(value: &serde_json::Value, in_case_map: bool, out: &mut BTreeSet<String>) {
1456        match value {
1457            serde_json::Value::Array(xs) => {
1458                for x in xs {
1459                    model_keys(x, in_case_map, out);
1460                }
1461            }
1462            serde_json::Value::Object(xs) => {
1463                for (key, x) in xs {
1464                    if !in_case_map {
1465                        out.insert(key.clone());
1466                    }
1467                    model_keys(
1468                        x,
1469                        in_case_map || matches!(key.as_str(), "extras" | "properties"),
1470                        out,
1471                    );
1472                }
1473            }
1474            _ => {}
1475        }
1476    }
1477
1478    /// The vocabulary's completeness gate: parse every fixture the readers
1479    /// accept, union their serde keys, and require every one to be
1480    /// vocabulary. Keys in `extras` and `properties` maps come from case data
1481    /// and remain subject to anonymization.
1482    #[test]
1483    fn every_fixture_field_name_is_vocabulary() {
1484        let data = Path::new(env!("CARGO_MANIFEST_DIR"))
1485            .parent()
1486            .unwrap()
1487            .join("tests/data");
1488        let mut keys = BTreeSet::new();
1489        let mut parsed = 0usize;
1490        for entry in walkdir::WalkDir::new(&data)
1491            .into_iter()
1492            .filter_map(Result::ok)
1493            .filter(|e| e.file_type().is_file())
1494        {
1495            let Ok(case) = read_case(entry.path()) else {
1496                continue;
1497            };
1498            let value = match case {
1499                Case::Balanced(net, _) => serde_json::to_value(&*net),
1500                Case::Multiconductor(net, _) => serde_json::to_value(&*net),
1501            }
1502            .expect("a parsed fixture serializes");
1503            model_keys(&value, false, &mut keys);
1504            parsed += 1;
1505        }
1506        assert!(parsed >= 18, "only {parsed} fixtures parsed");
1507        let vocabulary = super::anonymize::vocabulary();
1508        let missing: Vec<&String> = keys.iter().filter(|k| !vocabulary.contains(*k)).collect();
1509        assert!(
1510            missing.is_empty(),
1511            "{} fixture field name(s) outside the vocabulary would mask as corpus secrets: {missing:?}",
1512            missing.len()
1513        );
1514    }
1515}