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>),
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) => warnings.clone(),
409            Self::Multiconductor(net) => net.warnings.clone(),
410        }
411    }
412}
413
414/// Parse one path, turning a reader panic into a result rather than taking the
415/// process down. A crash on real input is the most valuable finding the
416/// harness can produce, so it must survive to the report.
417///
418/// The balanced reader is tried first and the multiconductor reader second:
419/// the two claim disjoint formats, and the balanced one names the other in its
420/// error when a distribution file reaches it.
421fn read_case(path: &Path) -> std::result::Result<Case, Unreadable> {
422    let unreadable = |error: String, panicked: bool| Unreadable {
423        path: path.to_path_buf(),
424        error,
425        panicked,
426    };
427    let balanced_error = match catch_panic(|| powerio_matrix::parse_file(path, None)) {
428        Ok(Ok(parsed)) => return Ok(Case::Balanced(Box::new(parsed.network), parsed.warnings)),
429        Err(message) => return Err(unreadable(message, true)),
430        Ok(Err(err)) => err.to_string(),
431    };
432    match catch_panic(|| powerio_dist::parse_file(path, None)) {
433        Ok(Ok(net)) => Ok(Case::Multiconductor(Box::new(net))),
434        // A `.m` that failed the MATPOWER parse used to report "unknown
435        // distribution format `m`": the fallback reader's refusal displaced
436        // the diagnosis. When the distribution reader does not even claim the
437        // format, the transmission error is the one that says what is wrong.
438        Ok(Err(err)) => {
439            let message = err.to_string();
440            Err(unreadable(
441                if message.starts_with("unknown distribution format") {
442                    balanced_error
443                } else {
444                    message
445                },
446                false,
447            ))
448        }
449        Err(message) => Err(unreadable(message, true)),
450    }
451}
452
453/// Re-read a member as a balanced network, or nothing when it is not one.
454fn balanced(path: &Path) -> Option<BalancedNetwork> {
455    match read_case(path) {
456        Ok(Case::Balanced(net, _)) => Some(*net),
457        _ => None,
458    }
459}
460
461/// Re-read a member as a multiconductor network, or nothing when it is not
462/// one.
463fn multiconductor(path: &Path) -> Option<powerio_dist::MulticonductorNetwork> {
464    match read_case(path) {
465        Ok(Case::Multiconductor(net)) => Some(*net),
466        _ => None,
467    }
468}
469
470/// Run `f`, returning the panic message instead of unwinding. The default hook
471/// is silenced for the duration so a corpus of unparseable files does not
472/// print a backtrace per file.
473fn catch_panic<T>(
474    f: impl FnOnce() -> T + std::panic::UnwindSafe,
475) -> std::result::Result<T, String> {
476    let previous = std::panic::take_hook();
477    std::panic::set_hook(Box::new(|_| {}));
478    let result = std::panic::catch_unwind(f);
479    std::panic::set_hook(previous);
480    result.map_err(|payload| {
481        payload
482            .downcast_ref::<&str>()
483            .map(|s| (*s).to_string())
484            .or_else(|| payload.downcast_ref::<String>().cloned())
485            .unwrap_or_else(|| "panic".to_string())
486    })
487}
488
489/// How two networks in one bucket were brought together.
490#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
491#[serde(rename_all = "kebab-case")]
492pub enum Via {
493    /// Parse, write in the same format, read back.
494    RoundTrip,
495    /// Parse one sibling, write it in another sibling's format, read back.
496    CrossWrite,
497    /// Two independent reads of the same case in two formats, compared
498    /// directly. The only leg that can catch a reader both directions of a
499    /// round trip agree about.
500    Sibling,
501    /// One step of a [`walk`] chain. Its `from` is whatever the walk was in,
502    /// which is a network several formats deep rather than a corpus file, and
503    /// the ordinals are hop positions rather than bucket members.
504    Walk,
505}
506
507#[derive(Debug, Clone, Serialize, Deserialize)]
508pub struct Leg {
509    pub from: String,
510    pub to: String,
511    pub via: Via,
512    pub from_ordinal: usize,
513    pub to_ordinal: usize,
514}
515
516/// Everything one leg produced, still in raw values. Stays in the work
517/// directory; [`report`] is what sanitizes it.
518#[derive(Debug, Clone, Serialize, Deserialize)]
519pub struct Comparison {
520    pub bucket: String,
521    pub leg: Leg,
522    pub warnings: Vec<String>,
523    pub core_changed: Option<String>,
524    pub ybus: Option<String>,
525    pub ybus_unavailable: Option<String>,
526    pub injection: Option<String>,
527    pub injection_status_only: bool,
528    /// A DC line's terminal power moved. Its own field because HVDC is an
529    /// injection no admittance matrix sees and several formats cannot state at
530    /// all, so it is graded separately from AC power.
531    pub dc_terminal: Option<String>,
532    /// Serde paths that changed, already collapsed to their class.
533    pub model_diffs: Vec<String>,
534    pub failure: Option<String>,
535    /// Two members of one bucket that are related but not the same data: a
536    /// case and its derivative. Recorded so the run states what it declined to
537    /// compare rather than silently dropping the pair.
538    pub variant: bool,
539    /// A written deck that pulls in other files, which a string readback
540    /// cannot resolve. Recorded for the same reason as `variant`.
541    pub unresolved_include: bool,
542    /// Elements the two sides disagree about the service status of.
543    pub status_changed: usize,
544}
545
546#[derive(Debug, Clone, Serialize, Deserialize)]
547pub struct Comparisons {
548    pub comparisons: Vec<Comparison>,
549}
550
551/// Run the invariants over every bucket in the work directory.
552///
553/// # Errors
554///
555/// Fails when the work directory has no ingest output or cannot be written.
556pub fn compare(work: &Path) -> Result<Comparisons> {
557    let ingest: Ingest = read_json(&work.join(INGEST_FILE))?;
558    let mut comparisons = Vec::new();
559
560    for bucket in &ingest.buckets {
561        match bucket.domain {
562            Domain::Transmission => compare_transmission(bucket, &mut comparisons),
563            Domain::Distribution => compare_distribution(bucket, &mut comparisons),
564        }
565    }
566
567    let out = Comparisons { comparisons };
568    write_json(&work.join(COMPARE_FILE), &out)?;
569    Ok(out)
570}
571
572/// The largest bucket compared pairwise.
573///
574/// Every member is written into every other member's format and compared
575/// against every other member, so the work is quadratic in the bucket size and
576/// each leg re-parses. An archive holding hundreds of spellings of one case
577/// would otherwise run for hours with no way to tell it apart from a hang.
578/// Bigger buckets keep the first [`MAX_BUCKET_MEMBERS`] members and say so.
579const MAX_BUCKET_MEMBERS: usize = 24;
580
581/// The members of a bucket that take part in the pairwise comparison.
582fn compared_members(bucket: &Bucket) -> &[Member] {
583    let n = bucket.members.len().min(MAX_BUCKET_MEMBERS);
584    &bucket.members[..n]
585}
586
587fn compare_transmission(bucket: &Bucket, out: &mut Vec<Comparison>) {
588    // Re-read rather than trusting a cached copy: the corpus is the authority
589    // and reading it again costs nothing next to the writes.
590    let members = compared_members(bucket);
591    let networks: Vec<Option<BalancedNetwork>> =
592        members.iter().map(|m| balanced(&m.path)).collect();
593    for (i, member) in members.iter().enumerate() {
594        let Some(source) = networks[i].as_ref() else {
595            continue;
596        };
597        for (j, other) in members.iter().enumerate() {
598            let Some(target) = powerio_matrix::target_format_from_name(&other.format) else {
599                continue;
600            };
601            let via = if i == j {
602                Via::RoundTrip
603            } else {
604                Via::CrossWrite
605            };
606            out.push(convert_leg(bucket, member, other, via, source, target));
607        }
608        for (j, other) in members.iter().enumerate().skip(i + 1) {
609            let Some(sibling) = networks[j].as_ref() else {
610                continue;
611            };
612            // Two files in one bucket that state different limits are a case
613            // and its derivative, and their differences are honest. They are
614            // still compared — a reader defect hides just as well between a
615            // case and its variant — but the pair is labelled so the severity
616            // says which kind of difference this is.
617            let twin = fingerprint::same_data(source, sibling);
618            let mut leg = sibling_leg(bucket, member, other, source, sibling);
619            leg.variant = !twin;
620            out.push(leg);
621        }
622    }
623}
624
625/// The distribution half runs the two properties that mean anything for a
626/// multiconductor model: element counts and the typed-model diff. There is no
627/// `Y_bus` or per-bus injection here — a distribution line carries an
628/// impedance matrix per phase pair, and powerio builds no admittance matrix
629/// for it.
630fn compare_distribution(bucket: &Bucket, out: &mut Vec<Comparison>) {
631    let members = compared_members(bucket);
632    let networks: Vec<Option<powerio_dist::MulticonductorNetwork>> =
633        members.iter().map(|m| multiconductor(&m.path)).collect();
634    for (i, member) in members.iter().enumerate() {
635        let Some(source) = networks[i].as_ref() else {
636            continue;
637        };
638        for (j, other) in members.iter().enumerate() {
639            let Some(target) = powerio_dist::dist_target_from_name(&other.format) else {
640                continue;
641            };
642            let via = if i == j {
643                Via::RoundTrip
644            } else {
645                Via::CrossWrite
646            };
647            out.push(dist_convert_leg(bucket, member, other, via, source, target));
648        }
649    }
650}
651
652fn dist_convert_leg(
653    bucket: &Bucket,
654    from: &Member,
655    to: &Member,
656    via: Via,
657    source: &powerio_dist::MulticonductorNetwork,
658    target: powerio_dist::DistTargetFormat,
659) -> Comparison {
660    let mut out = empty_comparison(
661        bucket,
662        Leg {
663            from: from.format.clone(),
664            to: to.format.clone(),
665            via,
666            from_ordinal: from.ordinal,
667            to_ordinal: to.ordinal,
668        },
669    );
670    let written = match catch_panic(|| source.to_format(target)) {
671        Ok(conversion) => conversion,
672        Err(message) => {
673            out.failure = Some(format!("write panicked: {message}"));
674            return out;
675        }
676    };
677    out.warnings.extend(written.warnings.iter().cloned());
678    let token = to.format.clone();
679    let text = written.text;
680    // A deck that pulls in other files cannot be read back from a string:
681    // `Redirect` and `Compile` resolve against a directory, and the corpus is
682    // read-only, so there is nowhere to put the written master beside its
683    // includes. Reading it anyway loses every object the includes carry and
684    // reports it as a conversion loss, which is a statement about the harness
685    // rather than about powerio.
686    if has_include(&text) {
687        out.unresolved_include = true;
688        return out;
689    }
690    let parsed = match catch_panic(|| powerio_dist::parse_str(&text, &token)) {
691        Ok(Ok(parsed)) => parsed,
692        Ok(Err(err)) => {
693            out.failure = Some(format!("readback: {err}"));
694            return out;
695        }
696        Err(message) => {
697            out.failure = Some(format!("readback panicked: {message}"));
698            return out;
699        }
700    };
701    // The readback's own declarations count toward warning parity, exactly as
702    // the transmission leg counts them; without this a loss the reader states
703    // on re-parse was graded undeclared.
704    out.warnings.extend(parsed.warnings.iter().cloned());
705    let before = invariants::distribution_core(source);
706    let after = invariants::distribution_core(&parsed);
707    if before != after {
708        out.core_changed = Some(dist_core_delta(&before, &after));
709    }
710    let mut clean = parsed;
711    clean.warnings.clear();
712    let diffs = invariants::model_diffs(
713        &invariants::distribution_value(source),
714        &invariants::distribution_value(&clean),
715    );
716    out.model_diffs = diffs
717        .iter()
718        .map(|d| anonymize::collapse_path(&d.path))
719        .collect();
720    out.model_diffs.sort();
721    out.model_diffs.dedup();
722    out
723}
724
725/// Whether a written OpenDSS deck pulls in files a string readback cannot
726/// resolve. Shared by the pairwise compare and the walk, which must skip the
727/// same decks for the same reason.
728fn has_include(text: &str) -> bool {
729    text.lines().any(|line| {
730        let word = line.split_whitespace().next().unwrap_or("");
731        word.eq_ignore_ascii_case("redirect")
732            || word.eq_ignore_ascii_case("compile")
733            // A `Buscoords` reference names a sidecar file the same way, so a
734            // string readback would lose every location and report it as a
735            // conversion loss that is really the harness's own limitation.
736            || word.eq_ignore_ascii_case("buscoords")
737    })
738}
739
740fn dist_core_delta(
741    before: &invariants::DistributionCore,
742    after: &invariants::DistributionCore,
743) -> String {
744    let mut parts = Vec::new();
745    for (label, a, b) in [
746        ("buses", before.buses, after.buses),
747        ("loads", before.loads, after.loads),
748        ("generators", before.generators, after.generators),
749        ("shunts", before.shunts, after.shunts),
750    ] {
751        if a != b {
752            parts.push(format!("{label} {}", count_delta(a, b)));
753        }
754    }
755    if before.load_p != after.load_p {
756        parts.push("load p".to_string());
757    }
758    if before.load_q != after.load_q {
759        parts.push("load q".to_string());
760    }
761    parts.join(", ")
762}
763
764fn empty_comparison(bucket: &Bucket, leg: Leg) -> Comparison {
765    Comparison {
766        bucket: bucket.id.clone(),
767        leg,
768        warnings: Vec::new(),
769        core_changed: None,
770        ybus: None,
771        ybus_unavailable: None,
772        injection: None,
773        injection_status_only: false,
774        dc_terminal: None,
775        model_diffs: Vec::new(),
776        failure: None,
777        variant: false,
778        unresolved_include: false,
779        status_changed: 0,
780    }
781}
782
783fn convert_leg(
784    bucket: &Bucket,
785    from: &Member,
786    to: &Member,
787    via: Via,
788    source: &BalancedNetwork,
789    target: TargetFormat,
790) -> Comparison {
791    let mut out = empty_comparison(
792        bucket,
793        Leg {
794            from: from.format.clone(),
795            to: to.format.clone(),
796            via,
797            from_ordinal: from.ordinal,
798            to_ordinal: to.ordinal,
799        },
800    );
801    let written = match catch_panic(|| powerio_matrix::write_as(source, target)) {
802        Ok(Ok(conversion)) => conversion,
803        Ok(Err(err)) => {
804            out.failure = Some(format!("write: {err}"));
805            return out;
806        }
807        Err(message) => {
808            out.failure = Some(format!("write panicked: {message}"));
809            return out;
810        }
811    };
812    out.warnings.extend(written.warnings.iter().cloned());
813    let token = to.format.clone();
814    let text = written.text;
815    let parsed = match catch_panic(|| powerio_matrix::parse_str(&text, &token)) {
816        Ok(Ok(parsed)) => parsed,
817        Ok(Err(err)) => {
818            out.failure = Some(format!("readback: {err}"));
819            return out;
820        }
821        Err(message) => {
822            out.failure = Some(format!("readback panicked: {message}"));
823            return out;
824        }
825    };
826    out.warnings.extend(parsed.warnings.iter().cloned());
827    fill_invariants(&mut out, source, &parsed.network);
828    out
829}
830
831fn sibling_leg(
832    bucket: &Bucket,
833    from: &Member,
834    to: &Member,
835    source: &BalancedNetwork,
836    sibling: &BalancedNetwork,
837) -> Comparison {
838    let mut out = empty_comparison(
839        bucket,
840        Leg {
841            from: from.format.clone(),
842            to: to.format.clone(),
843            via: Via::Sibling,
844            from_ordinal: from.ordinal,
845            to_ordinal: to.ordinal,
846        },
847    );
848    // Two readers, one case: only the electrical invariants apply. Two formats
849    // legitimately carry different fields, so a typed-model diff between them
850    // says nothing.
851    //
852    // Service status is compared by count rather than through the injections,
853    // because a machine that is out of service usually states zero output: the
854    // injections agree while the two files disagree about what the operator
855    // can dispatch. That difference is invisible to every other property here.
856    out.status_changed = invariants::status_disagreements(source, sibling);
857    let before = invariants::transmission_core(source);
858    let after = invariants::transmission_core(sibling);
859    if before != after {
860        out.core_changed = Some(core_delta(&before, &after));
861    }
862    match invariants::ybus_change(source, sibling) {
863        Ok(Some(change)) => out.ybus = Some(change.to_string()),
864        Ok(None) => {}
865        Err(side) => out.ybus_unavailable = Some(unavailable_label(&side)),
866    }
867    if let Some(change) = invariants::injection_change(source, sibling) {
868        out.injection_status_only = change.status_only;
869        out.injection = Some(change.to_string());
870    }
871    if let Some(change) = invariants::dc_terminal_change(source, sibling) {
872        out.dc_terminal = Some(change.to_string());
873    }
874    out
875}
876
877fn fill_invariants(out: &mut Comparison, source: &BalancedNetwork, result: &BalancedNetwork) {
878    let before = invariants::transmission_core(source);
879    let after = invariants::transmission_core(result);
880    if before != after {
881        out.core_changed = Some(core_delta(&before, &after));
882    }
883    match invariants::ybus_change(source, result) {
884        Ok(Some(change)) => out.ybus = Some(change.to_string()),
885        Ok(None) => {}
886        Err(side) => out.ybus_unavailable = Some(unavailable_label(&side)),
887    }
888    if let Some(change) = invariants::injection_change(source, result) {
889        out.injection_status_only = change.status_only;
890        out.injection = Some(change.to_string());
891    }
892    if let Some(change) = invariants::dc_terminal_change(source, result) {
893        out.dc_terminal = Some(change.to_string());
894    }
895    let diffs = invariants::model_diffs(
896        &invariants::transmission_value(source),
897        &invariants::transmission_value(result),
898    );
899    out.model_diffs = diffs
900        .iter()
901        .map(|d| anonymize::collapse_path(&d.path))
902        .collect();
903    out.model_diffs.sort();
904    out.model_diffs.dedup();
905}
906
907/// A signed count difference, stated without a cast that could wrap on a
908/// corpus large enough to matter.
909fn count_delta(before: usize, after: usize) -> String {
910    if after >= before {
911        format!("+{}", after - before)
912    } else {
913        format!("-{}", before - after)
914    }
915}
916
917fn unavailable_label(side: &YbusUnavailable) -> String {
918    match side {
919        YbusUnavailable::Before => "source".to_string(),
920        YbusUnavailable::After => "result".to_string(),
921    }
922}
923
924/// Element-count and total deltas, never the absolute counts: how many loads a
925/// utility runs is case data, how many a conversion lost is a defect.
926/// Whether a [`core_delta`] string reports power moving rather than elements
927/// being regrouped.
928///
929/// Counts alone moving is a merge or a split, which formats do legitimately
930/// (MATPOWER states one bus demand where PSS/E states three loads). Power
931/// moving is a loss. The labels live in `core_delta` and `dist_core_delta`
932/// just above, so the test belongs beside them: read off the delta in two
933/// places, it is one rule that a fifth power-bearing field would silently
934/// break in whichever site nobody updated.
935pub(super) fn power_moved(core: &str) -> bool {
936    ["load p", "load q", "gen p", "base mva"]
937        .iter()
938        .any(|label| core.contains(label))
939}
940
941fn core_delta(
942    before: &invariants::TransmissionCore,
943    after: &invariants::TransmissionCore,
944) -> String {
945    let mut parts = Vec::new();
946    let mut delta = |label: &str, a: usize, b: usize| {
947        if a != b {
948            parts.push(format!("{label} {}", count_delta(a, b)));
949        }
950    };
951    delta("buses", before.buses, after.buses);
952    delta("branches", before.branches, after.branches);
953    delta("generators", before.generators, after.generators);
954    delta("loads", before.loads, after.loads);
955    delta("shunts", before.shunts, after.shunts);
956    if before.load_p != after.load_p {
957        parts.push("load p".to_string());
958    }
959    if before.load_q != after.load_q {
960        parts.push("load q".to_string());
961    }
962    if before.gen_p != after.gen_p {
963        parts.push("gen p".to_string());
964    }
965    if before.base_mva != after.base_mva {
966        parts.push("base mva".to_string());
967    }
968    parts.join(", ")
969}
970
971fn write_json<T: Serialize>(path: &Path, value: &T) -> Result<()> {
972    let text = serde_json::to_string_pretty(value)?;
973    std::fs::write(path, text).with_context(|| format!("write {}", path.display()))?;
974    Ok(())
975}
976
977fn read_json<T: for<'de> Deserialize<'de>>(path: &Path) -> Result<T> {
978    let text = std::fs::read_to_string(path).with_context(|| {
979        format!(
980            "read {} (run `powerio corpus ingest` first)",
981            path.display()
982        )
983    })?;
984    Ok(serde_json::from_str(&text)?)
985}
986
987/// Sanitize the comparisons into findings and write them out.
988///
989/// # Errors
990///
991/// Fails when the work directory is incomplete, when the output cannot be
992/// written, or when the emitted report fails its own leak audit.
993pub fn report(work: &Path, findings_path: &Path, summary_path: Option<&Path>) -> Result<usize> {
994    let ingest: Ingest = read_json(&work.join(INGEST_FILE))?;
995    // Either analysis feeds the report on its own: a run may compare, walk, or
996    // both. Only both missing means there is nothing to report yet.
997    let comparisons: Option<Comparisons> = read_json(&work.join(COMPARE_FILE)).ok();
998    let walks: Option<walk::Walks> = read_json(&work.join(walk::WALK_FILE)).ok();
999    if comparisons.is_none() && walks.is_none() {
1000        anyhow::bail!(
1001            "nothing to report in {}: run `powerio corpus compare` or `powerio corpus walk` first",
1002            work.display()
1003        );
1004    }
1005    let comparisons = comparisons.unwrap_or(Comparisons {
1006        comparisons: Vec::new(),
1007    });
1008
1009    let mut sanitizer = Sanitizer::new();
1010    // The report's own schema. Without this a deck stating `delta` for a
1011    // winding makes the harness's `delta` key look like a leak, and the run
1012    // stops on its own field names.
1013    for key in SCHEMA_KEYS {
1014        sanitizer.allow(key);
1015    }
1016    for bucket in &ingest.buckets {
1017        sanitizer.allow(&bucket.id);
1018        for member in &bucket.members {
1019            sanitizer.allow(&member.format);
1020            sanitizer.learn_path(&member.path);
1021            match read_case(&member.path) {
1022                Ok(Case::Balanced(net, _)) => {
1023                    sanitizer.learn_buses(net.buses.iter().map(|b| b.id.0));
1024                    sanitizer.learn_network(&serde_json::to_value(&*net)?);
1025                }
1026                Ok(Case::Multiconductor(net)) => {
1027                    sanitizer.learn_network(&serde_json::to_value(&net)?);
1028                }
1029                Err(_) => {}
1030            }
1031        }
1032    }
1033    for bad in &ingest.unreadable {
1034        sanitizer.learn_path(&bad.path);
1035    }
1036
1037    let mut findings = Vec::new();
1038    for bad in &ingest.unreadable {
1039        findings.push(Finding {
1040            bucket: None,
1041            code: if bad.panicked {
1042                "parse.panic"
1043            } else {
1044                "parse.failure"
1045            },
1046            severity: if bad.panicked { "crash" } else { "declared" },
1047            leg: None,
1048            detail: serde_json::json!({ "message": sanitizer.template(&bad.error) }),
1049        });
1050    }
1051    for comparison in &comparisons.comparisons {
1052        findings.extend(findings_for(comparison, &sanitizer));
1053    }
1054    if let Some(walks) = &walks {
1055        for w in &walks.walks {
1056            findings.extend(walk_findings(w, &sanitizer));
1057        }
1058    }
1059
1060    let jsonl = render_jsonl(&findings)?;
1061    audit_or_bail(&sanitizer, &jsonl, findings_path)?;
1062    std::fs::write(findings_path, &jsonl)
1063        .with_context(|| format!("write {}", findings_path.display()))?;
1064
1065    if let Some(path) = summary_path {
1066        let summary = render_summary(&ingest, &findings);
1067        audit_or_bail(&sanitizer, &summary, path)?;
1068        std::fs::write(path, summary).with_context(|| format!("write {}", path.display()))?;
1069    }
1070    Ok(findings.len())
1071}
1072
1073fn audit_or_bail(sanitizer: &Sanitizer, text: &str, path: &Path) -> Result<()> {
1074    if let Err(leaks) = sanitizer.audit(text) {
1075        // Names neither the string nor the file it came from: this message is
1076        // as public as the report would have been.
1077        anyhow::bail!(
1078            "refusing to write {}: {} corpus string(s) reached it, first at {}",
1079            path.display(),
1080            leaks.len(),
1081            leaks[0]
1082        );
1083    }
1084    Ok(())
1085}
1086
1087#[derive(Debug, Clone, Serialize)]
1088pub struct Finding {
1089    #[serde(skip_serializing_if = "Option::is_none")]
1090    pub bucket: Option<String>,
1091    pub code: &'static str,
1092    pub severity: &'static str,
1093    #[serde(skip_serializing_if = "Option::is_none")]
1094    pub leg: Option<Leg>,
1095    pub detail: serde_json::Value,
1096}
1097
1098// One arm per finding class; splitting it would scatter the severity rules
1099// that only make sense read together.
1100#[allow(clippy::too_many_lines)]
1101fn findings_for(comparison: &Comparison, sanitizer: &Sanitizer) -> Vec<Finding> {
1102    let mut out = Vec::new();
1103    let bucket = Some(comparison.bucket.clone());
1104    let leg = Some(comparison.leg.clone());
1105    let mut push = |code: &'static str, severity: &'static str, detail: serde_json::Value| {
1106        out.push(Finding {
1107            bucket: bucket.clone(),
1108            code,
1109            severity,
1110            leg: leg.clone(),
1111            detail,
1112        });
1113    };
1114
1115    if comparison.unresolved_include {
1116        push(
1117            "leg.unresolved-include",
1118            "declared",
1119            serde_json::json!({ "compared": false }),
1120        );
1121        return out;
1122    }
1123    if comparison.variant {
1124        push(
1125            "sibling.variant",
1126            "declared",
1127            serde_json::json!({ "compared": true }),
1128        );
1129    }
1130    if let Some(failure) = &comparison.failure {
1131        let panicked = failure.contains("panicked");
1132        push(
1133            if panicked { "leg.panic" } else { "leg.failure" },
1134            if panicked { "crash" } else { "silent-drop" },
1135            serde_json::json!({ "message": sanitizer.template(failure) }),
1136        );
1137    }
1138    if let Some(side) = &comparison.ybus_unavailable {
1139        push(
1140            "electrical.unavailable",
1141            "silent-value-change",
1142            serde_json::json!({ "side": side }),
1143        );
1144    }
1145    if comparison.ybus.is_some() {
1146        let honest_difference = comparison.leg.via == Via::Sibling
1147            && (comparison.variant || comparison.status_changed > 0);
1148        // The changed entry's bus pair and magnitudes stay in the work
1149        // directory: a `Y_bus` entry is grid data. The finding states that the
1150        // admittance moved on this leg, which is what makes it actionable.
1151        push(
1152            if honest_difference {
1153                "sibling.ybus"
1154            } else {
1155                "electrical.ybus"
1156            },
1157            if honest_difference {
1158                "declared"
1159            } else {
1160                "silent-value-change"
1161            },
1162            serde_json::json!({ "entries_changed": 1 }),
1163        );
1164    }
1165    if comparison.injection.is_some() {
1166        // On a conversion leg powerio moved the power itself, so any move is a
1167        // defect. On a sibling leg the two files may honestly hold different
1168        // operating points — a base case and its contingency variant sit side
1169        // by side in every planning archive — so a move that is only a status
1170        // disagreement is reported for triage rather than as a defect.
1171        let honest_difference = comparison.leg.via == Via::Sibling
1172            && (comparison.injection_status_only || comparison.variant);
1173        push(
1174            if honest_difference {
1175                "sibling.status"
1176            } else {
1177                "electrical.injection"
1178            },
1179            if honest_difference {
1180                "declared"
1181            } else {
1182                "silent-value-change"
1183            },
1184            serde_json::json!({ "status_only": comparison.injection_status_only }),
1185        );
1186    }
1187    if comparison.dc_terminal.is_some() {
1188        let declared = !comparison.warnings.is_empty();
1189        push(
1190            "electrical.dc-terminal",
1191            if declared {
1192                "declared"
1193            } else {
1194                "silent-value-change"
1195            },
1196            serde_json::json!({ "declared": declared }),
1197        );
1198    }
1199    if let Some(core) = &comparison.core_changed {
1200        let power_moved = power_moved(core);
1201        push(
1202            if power_moved {
1203                "core.power"
1204            } else {
1205                "core.regrouped"
1206            },
1207            if power_moved {
1208                "silent-drop"
1209            } else {
1210                "declared"
1211            },
1212            serde_json::json!({ "delta": core }),
1213        );
1214    }
1215    if comparison.status_changed > 0 {
1216        push(
1217            "sibling.status",
1218            "declared",
1219            serde_json::json!({ "elements": comparison.status_changed }),
1220        );
1221    }
1222    if !comparison.model_diffs.is_empty() {
1223        // A serde path is mostly powerio's own field names, but an `Extras`
1224        // map contributes whatever token the source stated, so the path is
1225        // templated like any other text the corpus touched.
1226        let paths: Vec<String> = comparison
1227            .model_diffs
1228            .iter()
1229            .map(|p| sanitizer.template(p))
1230            .collect();
1231        let declared = !comparison.warnings.is_empty();
1232        push(
1233            if declared {
1234                "loss.declared"
1235            } else {
1236                "loss.undeclared"
1237            },
1238            if declared {
1239                "declared"
1240            } else {
1241                "undeclared-loss"
1242            },
1243            serde_json::json!({ "paths": paths }),
1244        );
1245    }
1246    let mut templates: BTreeMap<String, usize> = BTreeMap::new();
1247    for warning in &comparison.warnings {
1248        *templates.entry(sanitizer.template(warning)).or_default() += 1;
1249    }
1250    if !templates.is_empty() {
1251        push(
1252            "warning.observed",
1253            "declared",
1254            serde_json::json!({ "templates": templates }),
1255        );
1256    }
1257    out
1258}
1259
1260/// Findings for one walk.
1261///
1262/// Only the properties a chain can state are reported here. A hop's own loss
1263/// is the same quantity [`findings_for`] already grades, and re-reporting it
1264/// per hop would bury the three findings that are new under a copy of the
1265/// pairwise report.
1266///
1267/// Every finding carries the path that produced it, as format tokens, and the
1268/// seed that replays it. Both are harness vocabulary rather than case data.
1269fn walk_findings(w: &walk::Walk, sanitizer: &Sanitizer) -> Vec<Finding> {
1270    let mut out = Vec::new();
1271    let path: Vec<String> = w.hops.iter().map(|h| h.to.clone()).collect();
1272    for (index, hop) in w.hops.iter().enumerate() {
1273        let leg = Some(Leg {
1274            from: if index == 0 {
1275                w.origin.clone()
1276            } else {
1277                w.hops[index - 1].to.clone()
1278            },
1279            to: hop.to.clone(),
1280            via: Via::Walk,
1281            from_ordinal: index,
1282            to_ordinal: index + 1,
1283        });
1284        let mut push =
1285            |code: &'static str, severity: &'static str, mut detail: serde_json::Value| {
1286                if let Some(map) = detail.as_object_mut() {
1287                    map.insert("origin".into(), serde_json::json!(w.origin));
1288                    map.insert("path".into(), serde_json::json!(path));
1289                    map.insert("hop".into(), serde_json::json!(index));
1290                    map.insert("seed".into(), serde_json::json!(w.seed.to_string()));
1291                }
1292                out.push(Finding {
1293                    bucket: Some(w.bucket.clone()),
1294                    code,
1295                    severity,
1296                    leg: leg.clone(),
1297                    detail,
1298                });
1299            };
1300
1301        if let Some(failure) = &hop.failure {
1302            let panicked = failure.contains("panicked");
1303            push(
1304                if panicked {
1305                    "walk.panic"
1306                } else {
1307                    "walk.failure"
1308                },
1309                if panicked { "crash" } else { "silent-drop" },
1310                serde_json::json!({ "message": sanitizer.template(failure) }),
1311            );
1312        }
1313        if let Some(drift) = &hop.drift {
1314            // Conversion should settle. A second pass through a format that
1315            // lands somewhere else means the reader and writer are not a
1316            // projection, and every later hop inherits the drift. Drift in
1317            // retained extras alone is churn worth seeing; drift in an
1318            // electrical property is a defect.
1319            push(
1320                "walk.drift",
1321                if hop.drift_electrical {
1322                    "silent-value-change"
1323                } else {
1324                    "declared"
1325                },
1326                serde_json::json!({
1327                    "revisit_of": hop.revisit_of,
1328                    "delta": sanitizer.template(drift),
1329                }),
1330            );
1331        }
1332        if let Some(delta) = &hop.path_dependent {
1333            // The destination format is the same and the answer is not, so the
1334            // route through the graph changed the result. No single leg can
1335            // state this, which is the whole reason walks exist. Retention
1336            // honestly thins along a chain, so extras-only path dependence is
1337            // reported for triage rather than as a defect.
1338            push(
1339                "walk.path-dependent",
1340                if hop.path_dependent_electrical {
1341                    "silent-value-change"
1342                } else {
1343                    "declared"
1344                },
1345                serde_json::json!({ "direct": sanitizer.template(delta) }),
1346            );
1347        }
1348        if !hop.resurrected.is_empty() {
1349            // A table was empty and is not. When the hop's own electrical
1350            // properties held, the rows are a regrouping the target format
1351            // demands (pandapower states transformer charging as bus shunts);
1352            // when they moved too, a writer invented data no reader gave it.
1353            push(
1354                "walk.resurrection",
1355                if hop.leg.electrical() {
1356                    "silent-value-change"
1357                } else {
1358                    "declared"
1359                },
1360                serde_json::json!({ "resurrected": hop.resurrected }),
1361            );
1362        }
1363        if !hop.emptied.is_empty() {
1364            // Not a defect. It says the hops after this one are graded against
1365            // an empty table, so their silence is not evidence.
1366            push(
1367                "walk.absorbed",
1368                "declared",
1369                serde_json::json!({ "emptied": hop.emptied }),
1370            );
1371        }
1372    }
1373    out
1374}
1375
1376fn render_jsonl(findings: &[Finding]) -> Result<String> {
1377    let mut out = String::new();
1378    for finding in findings {
1379        out.push_str(&serde_json::to_string(finding)?);
1380        out.push('\n');
1381    }
1382    Ok(out)
1383}
1384
1385fn render_summary(ingest: &Ingest, findings: &[Finding]) -> String {
1386    use std::fmt::Write as _;
1387    let mut out = String::new();
1388    let _ = writeln!(out, "# Corpus run\n");
1389    let _ = writeln!(
1390        out,
1391        "{} files seen, {} buckets, {} unreadable.\n",
1392        ingest.files_seen,
1393        ingest.buckets.len(),
1394        ingest.unreadable.len()
1395    );
1396    let mut sizes: BTreeMap<usize, usize> = BTreeMap::new();
1397    for bucket in &ingest.buckets {
1398        *sizes.entry(bucket.members.len()).or_default() += 1;
1399    }
1400    let _ = writeln!(out, "## Buckets by sibling count\n");
1401    let _ = writeln!(out, "| siblings | buckets |");
1402    let _ = writeln!(out, "| --- | --- |");
1403    for (size, count) in &sizes {
1404        let _ = writeln!(out, "| {size} | {count} |");
1405    }
1406    let mut by_code: BTreeMap<(&str, &str), usize> = BTreeMap::new();
1407    for finding in findings {
1408        *by_code.entry((finding.severity, finding.code)).or_default() += 1;
1409    }
1410    let _ = writeln!(out, "\n## Findings\n");
1411    let _ = writeln!(out, "| severity | code | count |");
1412    let _ = writeln!(out, "| --- | --- | --- |");
1413    for ((severity, code), count) in &by_code {
1414        let _ = writeln!(out, "| {severity} | {code} | {count} |");
1415    }
1416    out
1417}
1418
1419#[cfg(test)]
1420mod tests {
1421    use std::collections::BTreeSet;
1422    use std::path::Path;
1423
1424    use super::{Case, read_case};
1425
1426    /// Every serde key of `value` outside its `extras` subtrees, where keys
1427    /// are powerio's own field names rather than case data.
1428    fn model_keys(value: &serde_json::Value, in_extras: bool, out: &mut BTreeSet<String>) {
1429        match value {
1430            serde_json::Value::Array(xs) => {
1431                for x in xs {
1432                    model_keys(x, in_extras, out);
1433                }
1434            }
1435            serde_json::Value::Object(xs) => {
1436                for (key, x) in xs {
1437                    if !in_extras {
1438                        out.insert(key.clone());
1439                    }
1440                    model_keys(x, in_extras || key == "extras", out);
1441                }
1442            }
1443            _ => {}
1444        }
1445    }
1446
1447    /// The vocabulary's completeness gate: parse every fixture the readers
1448    /// accept, union their serde keys, and require every one to be
1449    /// vocabulary. A field name outside it masks as a corpus secret the day
1450    /// a real corpus teaches the same spelling.
1451    #[test]
1452    fn every_fixture_field_name_is_vocabulary() {
1453        let data = Path::new(env!("CARGO_MANIFEST_DIR"))
1454            .parent()
1455            .unwrap()
1456            .join("tests/data");
1457        let mut keys = BTreeSet::new();
1458        let mut parsed = 0usize;
1459        for entry in walkdir::WalkDir::new(&data)
1460            .into_iter()
1461            .filter_map(Result::ok)
1462            .filter(|e| e.file_type().is_file())
1463        {
1464            let Ok(case) = read_case(entry.path()) else {
1465                continue;
1466            };
1467            let value = match case {
1468                Case::Balanced(net, _) => serde_json::to_value(&*net),
1469                Case::Multiconductor(net) => serde_json::to_value(&*net),
1470            }
1471            .expect("a parsed fixture serializes");
1472            model_keys(&value, false, &mut keys);
1473            parsed += 1;
1474        }
1475        assert!(parsed >= 18, "only {parsed} fixtures parsed");
1476        let vocabulary = super::anonymize::vocabulary();
1477        let missing: Vec<&String> = keys.iter().filter(|k| !vocabulary.contains(*k)).collect();
1478        assert!(
1479            missing.is_empty(),
1480            "{} fixture field name(s) outside the vocabulary would mask as corpus secrets: {missing:?}",
1481            missing.len()
1482        );
1483    }
1484}