1pub 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
46const SCHEMA_KEYS: [&str; 83] = [
51 "Corpus",
54 "run",
55 "files",
56 "seen",
57 "unreadable",
58 "Buckets",
59 "siblings",
60 "Findings",
61 "count",
62 "true",
65 "false",
66 "null",
67 "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 "crash",
95 "silent-drop",
96 "silent-value-change",
97 "undeclared-loss",
98 "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#[derive(Debug, Clone, Serialize, Deserialize)]
147pub struct Member {
148 pub ordinal: usize,
150 pub format: String,
152 pub path: PathBuf,
154 pub warnings: Vec<String>,
155}
156
157#[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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
178pub struct Unreadable {
179 pub path: PathBuf,
180 pub error: String,
181 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 pub skipped: usize,
193 pub escaped: usize,
195}
196
197fn 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
210fn 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
220pub 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 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 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 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
334fn 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 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
374pub 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
414fn 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 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
453fn balanced(path: &Path) -> Option<BalancedNetwork> {
455 match read_case(path) {
456 Ok(Case::Balanced(net, _)) => Some(*net),
457 _ => None,
458 }
459}
460
461fn multiconductor(path: &Path) -> Option<powerio_dist::MulticonductorNetwork> {
464 match read_case(path) {
465 Ok(Case::Multiconductor(net)) => Some(*net),
466 _ => None,
467 }
468}
469
470fn 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
491#[serde(rename_all = "kebab-case")]
492pub enum Via {
493 RoundTrip,
495 CrossWrite,
497 Sibling,
501 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#[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 pub dc_terminal: Option<String>,
532 pub model_diffs: Vec<String>,
534 pub failure: Option<String>,
535 pub variant: bool,
539 pub unresolved_include: bool,
542 pub status_changed: usize,
544}
545
546#[derive(Debug, Clone, Serialize, Deserialize)]
547pub struct Comparisons {
548 pub comparisons: Vec<Comparison>,
549}
550
551pub 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
572const MAX_BUCKET_MEMBERS: usize = 24;
580
581fn 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 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 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
625fn 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 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 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
725fn 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 || 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 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
907fn 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
924pub(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
987pub fn report(work: &Path, findings_path: &Path, summary_path: Option<&Path>) -> Result<usize> {
994 let ingest: Ingest = read_json(&work.join(INGEST_FILE))?;
995 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 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 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#[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 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 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 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
1260fn 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 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 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 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 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 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 #[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}