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