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::BalancedNetwork;
37use powerio_tx::TargetFormat;
38use serde::{Deserialize, Serialize};
39
40use crate::invariants::{self, YbusUnavailable};
41use anonymize::Sanitizer;
42use fingerprint::Fingerprint;
43
44const INGEST_FILE: &str = "ingest.json";
45const COMPARE_FILE: &str = "comparisons.json";
46
47const SCHEMA_KEYS: [&str; 83] = [
52 "Corpus",
55 "run",
56 "files",
57 "seen",
58 "unreadable",
59 "Buckets",
60 "siblings",
61 "Findings",
62 "count",
63 "true",
66 "false",
67 "null",
68 "parse.panic",
72 "parse.failure",
73 "leg.panic",
74 "leg.failure",
75 "leg.unresolved-include",
76 "sibling.different-data",
77 "sibling.status",
78 "sibling.ybus",
79 "electrical.unavailable",
80 "electrical.ybus",
81 "electrical.injection",
82 "electrical.dc-terminal",
83 "core.power",
84 "core.regrouped",
85 "loss.declared",
86 "loss.undeclared",
87 "warning.observed",
88 "walk.panic",
89 "walk.failure",
90 "walk.drift",
91 "walk.path-dependent",
92 "walk.resurrection",
93 "walk.absorbed",
94 "crash",
96 "silent-drop",
97 "silent-value-change",
98 "undeclared-loss",
99 "buses",
101 "branches",
102 "generators",
103 "loads",
104 "shunts",
105 "load",
106 "gen",
107 "base",
108 "mva",
109 "hop",
110 "hops",
111 "path",
112 "origin",
113 "direct",
114 "emptied",
115 "resurrected",
116 "seed",
117 "revisit_of",
118 "walk",
119 "bucket",
120 "code",
121 "severity",
122 "leg",
123 "from",
124 "to",
125 "via",
126 "from_ordinal",
127 "to_ordinal",
128 "detail",
129 "templates",
130 "paths",
131 "delta",
132 "message",
133 "side",
134 "status_only",
135 "entries_changed",
136 "round-trip",
137 "cross-write",
138 "sibling",
139 "declared",
140 "elements",
141 "compared",
142 "unresolved-include",
143 "different_case_data",
144];
145
146#[derive(Debug, Clone, Serialize, Deserialize)]
148pub struct Member {
149 pub ordinal: usize,
151 pub format: String,
153 pub path: PathBuf,
155 pub warnings: Vec<String>,
156}
157
158#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
162#[serde(rename_all = "kebab-case")]
163pub enum Domain {
164 Transmission,
165 Distribution,
166}
167
168#[derive(Debug, Clone, Serialize, Deserialize)]
170pub struct Bucket {
171 pub id: String,
172 pub domain: Domain,
173 pub fingerprint: Fingerprint,
174 pub members: Vec<Member>,
175}
176
177#[derive(Debug, Clone, Serialize, Deserialize)]
179pub struct Unreadable {
180 pub path: PathBuf,
181 pub error: String,
182 pub panicked: bool,
184}
185
186#[derive(Debug, Clone, Serialize, Deserialize)]
187pub struct Ingest {
188 pub buckets: Vec<Bucket>,
189 pub unreadable: Vec<Unreadable>,
190 pub files_seen: usize,
191 pub skipped: usize,
194 pub escaped: usize,
196}
197
198fn within(root: &Path, path: &Path) -> bool {
205 match (root.canonicalize(), path.canonicalize()) {
206 (Ok(root), Ok(path)) => path.starts_with(&root),
207 _ => false,
208 }
209}
210
211fn names_a_case(path: &Path) -> bool {
214 const CASE_EXTENSIONS: [&str; 9] = [
215 "m", "raw", "rawx", "epc", "aux", "pwb", "uct", "json", "dss",
216 ];
217 path.extension()
218 .and_then(|e| e.to_str())
219 .map(str::to_ascii_lowercase)
220 .is_some_and(|ext| CASE_EXTENSIONS.contains(&ext.as_str()))
221}
222
223pub fn ingest(corpus: &Path, work: &Path, max_bytes: Option<u64>) -> Result<Ingest> {
236 std::fs::create_dir_all(work)
237 .with_context(|| format!("create work directory {}", work.display()))?;
238 let mut files_seen = 0usize;
239 let mut skipped = 0usize;
240 let mut escaped = 0usize;
241 let mut unreadable = Vec::new();
242 let mut parsed: Vec<Parsed> = Vec::new();
243
244 for entry in walkdir::WalkDir::new(corpus)
245 .follow_links(false)
246 .sort_by_file_name()
247 {
248 let entry = match entry {
249 Ok(entry) => entry,
250 Err(err) => {
251 unreadable.push(Unreadable {
252 path: err.path().unwrap_or(corpus).to_path_buf(),
253 error: err.to_string(),
254 panicked: false,
255 });
256 continue;
257 }
258 };
259 let path = entry.path();
260 if !within(corpus, path) {
265 escaped += 1;
266 continue;
267 }
268 let is_pypsa_dir = entry.file_type().is_dir() && path.join("network.csv").is_file();
269 if entry.file_type().is_dir() && !is_pypsa_dir {
270 continue;
271 }
272 files_seen += 1;
273 if let Some(cap) = max_bytes {
274 let over = entry.metadata().is_ok_and(|m| m.is_file() && m.len() > cap);
275 if over {
276 skipped += 1;
277 continue;
278 }
279 }
280 match read_case(path) {
281 Ok(case) if case.fingerprint().buses == 0 => skipped += 1,
286 Ok(case) => {
287 parsed.push(Parsed {
288 domain: case.domain(),
289 fingerprint: case.fingerprint(),
290 format: case.format(),
291 path: path.to_path_buf(),
292 warnings: case.warnings(),
293 });
294 }
295 Err(bad) if names_a_case(path) || bad.panicked => unreadable.push(bad),
300 Err(_) => skipped += 1,
301 }
302 }
303
304 let mut buckets = bucket(parsed);
305 buckets.sort_by(|a, b| {
306 a.fingerprint
307 .primary()
308 .cmp(&b.fingerprint.primary())
309 .then_with(|| a.fingerprint.cmp(&b.fingerprint))
310 });
311 for (i, bucket) in buckets.iter_mut().enumerate() {
312 bucket.id = format!("case-{i:03}");
313 for (ordinal, member) in bucket.members.iter_mut().enumerate() {
314 member.ordinal = ordinal;
315 }
316 }
317
318 let ingest = Ingest {
319 buckets,
320 unreadable,
321 files_seen,
322 skipped,
323 escaped,
324 };
325 write_json(&work.join(INGEST_FILE), &ingest)?;
326 Ok(ingest)
327}
328
329struct Parsed {
330 domain: Domain,
331 fingerprint: Fingerprint,
332 format: String,
333 path: PathBuf,
334 warnings: Vec<String>,
335}
336
337fn bucket(parsed: Vec<Parsed>) -> Vec<Bucket> {
340 let mut groups: BTreeMap<(Domain, fingerprint::PrimaryKey), Vec<Parsed>> = BTreeMap::new();
341 for item in parsed {
342 groups
343 .entry((item.domain, item.fingerprint.primary()))
344 .or_default()
345 .push(item);
346 }
347 let mut out = Vec::new();
348 for ((domain, _), group) in groups {
349 let mut splits: Vec<Bucket> = Vec::new();
352 for item in group {
353 let member = Member {
354 ordinal: 0,
355 format: item.format,
356 path: item.path,
357 warnings: item.warnings,
358 };
359 match splits
360 .iter_mut()
361 .find(|split| split.fingerprint.agrees_with(&item.fingerprint))
362 {
363 Some(split) => split.members.push(member),
364 None => splits.push(Bucket {
365 id: String::new(),
366 domain,
367 fingerprint: item.fingerprint,
368 members: vec![member],
369 }),
370 }
371 }
372 out.extend(splits);
373 }
374 out
375}
376
377pub enum Case {
379 Balanced(Box<BalancedNetwork>, Vec<String>),
380 Multiconductor(Box<powerio_dist::MulticonductorNetwork>, Vec<String>),
381}
382
383impl Case {
384 fn domain(&self) -> Domain {
385 match self {
386 Self::Balanced(..) => Domain::Transmission,
387 Self::Multiconductor(..) => Domain::Distribution,
388 }
389 }
390
391 fn fingerprint(&self) -> Fingerprint {
392 match self {
393 Self::Balanced(net, _) => Fingerprint::of(net),
394 Self::Multiconductor(net, _) => Fingerprint::of_distribution(net),
395 }
396 }
397
398 fn format(&self) -> String {
399 match self {
400 Self::Balanced(net, _) => net.source_format().name().to_string(),
401 Self::Multiconductor(net, _) => net
402 .source_format()
403 .as_ref()
404 .map_or("dss", |f| f.name())
405 .to_string(),
406 }
407 }
408
409 fn warnings(&self) -> Vec<String> {
410 match self {
411 Self::Balanced(_, warnings) | Self::Multiconductor(_, warnings) => warnings.clone(),
412 }
413 }
414}
415
416fn read_case(path: &Path) -> std::result::Result<Case, Unreadable> {
424 let unreadable = |error: String, panicked: bool| Unreadable {
425 path: path.to_path_buf(),
426 error,
427 panicked,
428 };
429 let balanced_error = match catch_panic(|| crate::module_io::load_balanced_module(path, None)) {
430 Ok(Ok(parsed)) => {
431 let rendered = powerio_core::render_diagnostics(parsed.diagnostics());
432 return Ok(Case::Balanced(Box::new(parsed.into_value()), rendered));
433 }
434 Err(message) => return Err(unreadable(message, true)),
435 Ok(Err(err)) => err.to_string(),
436 };
437 match catch_panic(|| crate::module_io::load_multiconductor_module(path, None)) {
438 Ok(Ok(parsed)) => {
439 let warnings = powerio_core::render_diagnostics(parsed.diagnostics());
440 Ok(Case::Multiconductor(
441 Box::new(parsed.into_value()),
442 warnings,
443 ))
444 }
445 Ok(Err(err)) => {
450 let message = err.to_string();
451 Err(unreadable(
452 if message.contains("unknown distribution format")
453 || message.contains("REQUEST.FORMAT.UNKNOWN")
454 {
455 balanced_error
456 } else {
457 message
458 },
459 false,
460 ))
461 }
462 Err(message) => Err(unreadable(message, true)),
463 }
464}
465
466fn balanced(path: &Path) -> Option<BalancedNetwork> {
468 match read_case(path) {
469 Ok(Case::Balanced(net, _)) => Some(*net),
470 _ => None,
471 }
472}
473
474fn multiconductor(path: &Path) -> Option<powerio_dist::MulticonductorNetwork> {
477 match read_case(path) {
478 Ok(Case::Multiconductor(net, _)) => Some(*net),
479 _ => None,
480 }
481}
482
483fn catch_panic<T>(
487 f: impl FnOnce() -> T + std::panic::UnwindSafe,
488) -> std::result::Result<T, String> {
489 let previous = std::panic::take_hook();
490 std::panic::set_hook(Box::new(|_| {}));
491 let result = std::panic::catch_unwind(f);
492 std::panic::set_hook(previous);
493 result.map_err(|payload| {
494 payload
495 .downcast_ref::<&str>()
496 .map(|s| (*s).to_string())
497 .or_else(|| payload.downcast_ref::<String>().cloned())
498 .unwrap_or_else(|| "panic".to_string())
499 })
500}
501
502#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
504#[serde(rename_all = "kebab-case")]
505pub enum Via {
506 RoundTrip,
508 CrossWrite,
510 Sibling,
514 Walk,
518}
519
520#[derive(Debug, Clone, Serialize, Deserialize)]
521pub struct Leg {
522 pub from: String,
523 pub to: String,
524 pub via: Via,
525 pub from_ordinal: usize,
526 pub to_ordinal: usize,
527}
528
529#[derive(Debug, Clone, Serialize, Deserialize)]
532pub struct Comparison {
533 pub bucket: String,
534 pub leg: Leg,
535 pub warnings: Vec<String>,
536 pub core_changed: Option<String>,
537 pub ybus: Option<String>,
538 pub ybus_unavailable: Option<String>,
539 pub injection: Option<String>,
540 pub injection_status_only: bool,
541 pub dc_terminal: Option<String>,
545 pub model_diffs: Vec<String>,
547 pub failure: Option<String>,
548 pub different_case_data: bool,
551 pub unresolved_include: bool,
555 pub status_changed: usize,
557}
558
559#[derive(Debug, Clone, Serialize, Deserialize)]
560pub struct Comparisons {
561 pub comparisons: Vec<Comparison>,
562}
563
564pub fn compare(work: &Path) -> Result<Comparisons> {
570 let ingest: Ingest = read_json(&work.join(INGEST_FILE))?;
571 let mut comparisons = Vec::new();
572
573 for bucket in &ingest.buckets {
574 match bucket.domain {
575 Domain::Transmission => compare_transmission(bucket, &mut comparisons),
576 Domain::Distribution => compare_distribution(bucket, &mut comparisons),
577 }
578 }
579
580 let out = Comparisons { comparisons };
581 write_json(&work.join(COMPARE_FILE), &out)?;
582 Ok(out)
583}
584
585const MAX_BUCKET_MEMBERS: usize = 24;
593
594fn compared_members(bucket: &Bucket) -> &[Member] {
596 let n = bucket.members.len().min(MAX_BUCKET_MEMBERS);
597 &bucket.members[..n]
598}
599
600fn compare_transmission(bucket: &Bucket, out: &mut Vec<Comparison>) {
601 let members = compared_members(bucket);
604 let networks: Vec<Option<BalancedNetwork>> =
605 members.iter().map(|m| balanced(&m.path)).collect();
606 for (i, member) in members.iter().enumerate() {
607 let Some(source) = networks[i].as_ref() else {
608 continue;
609 };
610 for (j, other) in members.iter().enumerate() {
611 let Some(target) = powerio_tx::format::parse_target_format(&other.format) else {
612 continue;
613 };
614 let via = if i == j {
615 Via::RoundTrip
616 } else {
617 Via::CrossWrite
618 };
619 out.push(convert_leg(bucket, member, other, via, source, target));
620 }
621 for (j, other) in members.iter().enumerate().skip(i + 1) {
622 let Some(sibling) = networks[j].as_ref() else {
623 continue;
624 };
625 let twin = fingerprint::same_case_data(source, sibling);
631 let mut leg = sibling_leg(bucket, member, other, source, sibling);
632 leg.different_case_data = !twin;
633 out.push(leg);
634 }
635 }
636}
637
638fn compare_distribution(bucket: &Bucket, out: &mut Vec<Comparison>) {
644 let members = compared_members(bucket);
645 let networks: Vec<Option<powerio_dist::MulticonductorNetwork>> =
646 members.iter().map(|m| multiconductor(&m.path)).collect();
647 for (i, member) in members.iter().enumerate() {
648 let Some(source) = networks[i].as_ref() else {
649 continue;
650 };
651 for (j, other) in members.iter().enumerate() {
652 let Some(target) = powerio_dist::parse_dist_target_format(&other.format) else {
653 continue;
654 };
655 let via = if i == j {
656 Via::RoundTrip
657 } else {
658 Via::CrossWrite
659 };
660 out.push(dist_convert_leg(bucket, member, other, via, source, target));
661 }
662 }
663}
664
665fn dist_convert_leg(
666 bucket: &Bucket,
667 from: &Member,
668 to: &Member,
669 via: Via,
670 source: &powerio_dist::MulticonductorNetwork,
671 target: powerio_dist::DistTargetFormat,
672) -> Comparison {
673 let mut out = empty_comparison(
674 bucket,
675 Leg {
676 from: from.format.clone(),
677 to: to.format.clone(),
678 via,
679 from_ordinal: from.ordinal,
680 to_ordinal: to.ordinal,
681 },
682 );
683 let source_module = powerio_core::PioModule::new(source.clone());
684 let emission = match catch_panic(|| {
685 crate::module_io::emit_multiconductor_module(&source_module, target)
686 }) {
687 Ok(Ok(emission)) => emission,
688 Ok(Err(error)) => {
689 out.failure = Some(format!("emit: {error}"));
690 return out;
691 }
692 Err(message) => {
693 out.failure = Some(format!("emit panicked: {message}"));
694 return out;
695 }
696 };
697 out.warnings.extend(emission.render_diagnostics());
698 let token = to.format.clone();
699 let text = emission.text;
700 if has_include(&text) {
707 out.unresolved_include = true;
708 return out;
709 }
710 let parsed = match catch_panic(|| crate::module_io::load_multiconductor_memory(&text, &token)) {
711 Ok(Ok(parsed)) => parsed,
712 Ok(Err(err)) => {
713 out.failure = Some(format!("readback: {err}"));
714 return out;
715 }
716 Err(message) => {
717 out.failure = Some(format!("readback panicked: {message}"));
718 return out;
719 }
720 };
721 out.warnings
725 .extend(powerio_core::render_diagnostics(parsed.diagnostics()));
726 let before = invariants::distribution_core(source);
727 let after = invariants::distribution_core(parsed.value());
728 if before != after {
729 out.core_changed = Some(dist_core_delta(&before, &after));
730 }
731 let clean = parsed.into_value();
732 let diffs = invariants::model_diffs(
733 &invariants::distribution_value(source),
734 &invariants::distribution_value(&clean),
735 );
736 out.model_diffs = diffs
737 .iter()
738 .map(|d| anonymize::collapse_path(&d.path))
739 .collect();
740 out.model_diffs.sort();
741 out.model_diffs.dedup();
742 out
743}
744
745fn has_include(text: &str) -> bool {
749 text.lines().any(|line| {
750 let word = line.split_whitespace().next().unwrap_or("");
751 word.eq_ignore_ascii_case("redirect")
752 || word.eq_ignore_ascii_case("compile")
753 || word.eq_ignore_ascii_case("buscoords")
757 })
758}
759
760fn dist_core_delta(
761 before: &invariants::DistributionCore,
762 after: &invariants::DistributionCore,
763) -> String {
764 let mut parts = Vec::new();
765 for (label, a, b) in [
766 ("buses", before.buses, after.buses),
767 ("loads", before.loads, after.loads),
768 ("generators", before.generators, after.generators),
769 ("shunts", before.shunts, after.shunts),
770 ] {
771 if a != b {
772 parts.push(format!("{label} {}", count_delta(a, b)));
773 }
774 }
775 if before.load_p != after.load_p {
776 parts.push("load p".to_string());
777 }
778 if before.load_q != after.load_q {
779 parts.push("load q".to_string());
780 }
781 parts.join(", ")
782}
783
784fn empty_comparison(bucket: &Bucket, leg: Leg) -> Comparison {
785 Comparison {
786 bucket: bucket.id.clone(),
787 leg,
788 warnings: Vec::new(),
789 core_changed: None,
790 ybus: None,
791 ybus_unavailable: None,
792 injection: None,
793 injection_status_only: false,
794 dc_terminal: None,
795 model_diffs: Vec::new(),
796 failure: None,
797 different_case_data: false,
798 unresolved_include: false,
799 status_changed: 0,
800 }
801}
802
803fn convert_leg(
804 bucket: &Bucket,
805 from: &Member,
806 to: &Member,
807 via: Via,
808 source: &BalancedNetwork,
809 target: TargetFormat,
810) -> Comparison {
811 let mut out = empty_comparison(
812 bucket,
813 Leg {
814 from: from.format.clone(),
815 to: to.format.clone(),
816 via,
817 from_ordinal: from.ordinal,
818 to_ordinal: to.ordinal,
819 },
820 );
821 let source_module = powerio_core::PioModule::new(source.clone());
822 let emission = match catch_panic(|| {
823 crate::module_io::emit_balanced_module(
824 &source_module,
825 target,
826 &powerio_tx::EmitOptions::default(),
827 )
828 }) {
829 Ok(Ok(emission)) => emission,
830 Ok(Err(err)) => {
831 out.failure = Some(format!("emit: {err}"));
832 return out;
833 }
834 Err(message) => {
835 out.failure = Some(format!("emit panicked: {message}"));
836 return out;
837 }
838 };
839 out.warnings.extend(emission.render_diagnostics());
840 let token = to.format.clone();
841 let text = emission.text;
842 let parsed = match catch_panic(|| crate::module_io::load_balanced_memory(&text, &token)) {
843 Ok(Ok(parsed)) => parsed,
844 Ok(Err(err)) => {
845 out.failure = Some(format!("readback: {err}"));
846 return out;
847 }
848 Err(message) => {
849 out.failure = Some(format!("readback panicked: {message}"));
850 return out;
851 }
852 };
853 out.warnings
854 .extend(powerio_core::render_diagnostics(parsed.diagnostics()));
855 fill_invariants(&mut out, source, parsed.value());
856 out
857}
858
859fn sibling_leg(
860 bucket: &Bucket,
861 from: &Member,
862 to: &Member,
863 source: &BalancedNetwork,
864 sibling: &BalancedNetwork,
865) -> Comparison {
866 let mut out = empty_comparison(
867 bucket,
868 Leg {
869 from: from.format.clone(),
870 to: to.format.clone(),
871 via: Via::Sibling,
872 from_ordinal: from.ordinal,
873 to_ordinal: to.ordinal,
874 },
875 );
876 out.status_changed = invariants::status_disagreements(source, sibling);
885 let before = invariants::transmission_core(source);
886 let after = invariants::transmission_core(sibling);
887 if before != after {
888 out.core_changed = Some(core_delta(&before, &after));
889 }
890 match invariants::ybus_change(source, sibling) {
891 Ok(Some(change)) => out.ybus = Some(change.to_string()),
892 Ok(None) => {}
893 Err(side) => out.ybus_unavailable = Some(unavailable_label(&side)),
894 }
895 if let Some(change) = invariants::injection_change(source, sibling) {
896 out.injection_status_only = change.status_only;
897 out.injection = Some(change.to_string());
898 }
899 if let Some(change) = invariants::dc_terminal_change(source, sibling) {
900 out.dc_terminal = Some(change.to_string());
901 }
902 out
903}
904
905fn fill_invariants(out: &mut Comparison, source: &BalancedNetwork, result: &BalancedNetwork) {
906 let before = invariants::transmission_core(source);
907 let after = invariants::transmission_core(result);
908 if before != after {
909 out.core_changed = Some(core_delta(&before, &after));
910 }
911 match invariants::ybus_change(source, result) {
912 Ok(Some(change)) => out.ybus = Some(change.to_string()),
913 Ok(None) => {}
914 Err(side) => out.ybus_unavailable = Some(unavailable_label(&side)),
915 }
916 if let Some(change) = invariants::injection_change(source, result) {
917 out.injection_status_only = change.status_only;
918 out.injection = Some(change.to_string());
919 }
920 if let Some(change) = invariants::dc_terminal_change(source, result) {
921 out.dc_terminal = Some(change.to_string());
922 }
923 let diffs = invariants::model_diffs(
924 &invariants::transmission_value(source),
925 &invariants::transmission_value(result),
926 );
927 out.model_diffs = diffs
928 .iter()
929 .map(|d| anonymize::collapse_path(&d.path))
930 .collect();
931 out.model_diffs.sort();
932 out.model_diffs.dedup();
933}
934
935fn count_delta(before: usize, after: usize) -> String {
938 if after >= before {
939 format!("+{}", after - before)
940 } else {
941 format!("-{}", before - after)
942 }
943}
944
945fn unavailable_label(side: &YbusUnavailable) -> String {
946 match side {
947 YbusUnavailable::Before => "source".to_string(),
948 YbusUnavailable::After => "result".to_string(),
949 }
950}
951
952pub(super) fn power_moved(core: &str) -> bool {
964 ["load p", "load q", "gen p", "base mva"]
965 .iter()
966 .any(|label| core.contains(label))
967}
968
969fn core_delta(
970 before: &invariants::TransmissionCore,
971 after: &invariants::TransmissionCore,
972) -> String {
973 let mut parts = Vec::new();
974 let mut delta = |label: &str, a: usize, b: usize| {
975 if a != b {
976 parts.push(format!("{label} {}", count_delta(a, b)));
977 }
978 };
979 delta("buses", before.buses, after.buses);
980 delta("branches", before.branches, after.branches);
981 delta("generators", before.generators, after.generators);
982 delta("loads", before.loads, after.loads);
983 delta("shunts", before.shunts, after.shunts);
984 if before.load_p != after.load_p {
985 parts.push("load p".to_string());
986 }
987 if before.load_q != after.load_q {
988 parts.push("load q".to_string());
989 }
990 if before.gen_p != after.gen_p {
991 parts.push("gen p".to_string());
992 }
993 if before.base_mva != after.base_mva {
994 parts.push("base mva".to_string());
995 }
996 parts.join(", ")
997}
998
999fn write_json<T: Serialize>(path: &Path, value: &T) -> Result<()> {
1000 let text = serde_json::to_string_pretty(value)?;
1001 std::fs::write(path, text).with_context(|| format!("write {}", path.display()))?;
1002 Ok(())
1003}
1004
1005fn read_json<T: for<'de> Deserialize<'de>>(path: &Path) -> Result<T> {
1006 let text = std::fs::read_to_string(path).with_context(|| {
1007 format!(
1008 "read {} (run `powerio corpus ingest` first)",
1009 path.display()
1010 )
1011 })?;
1012 Ok(serde_json::from_str(&text)?)
1013}
1014
1015pub fn report(work: &Path, findings_path: &Path, summary_path: Option<&Path>) -> Result<usize> {
1022 let ingest: Ingest = read_json(&work.join(INGEST_FILE))?;
1023 let comparisons: Option<Comparisons> = read_json(&work.join(COMPARE_FILE)).ok();
1026 let walks: Option<walk::Walks> = read_json(&work.join(walk::WALK_FILE)).ok();
1027 if comparisons.is_none() && walks.is_none() {
1028 anyhow::bail!(
1029 "nothing to report in {}: run `powerio corpus compare` or `powerio corpus walk` first",
1030 work.display()
1031 );
1032 }
1033 let comparisons = comparisons.unwrap_or(Comparisons {
1034 comparisons: Vec::new(),
1035 });
1036
1037 let mut sanitizer = Sanitizer::new();
1038 for key in SCHEMA_KEYS {
1042 sanitizer.allow(key);
1043 }
1044 for bucket in &ingest.buckets {
1045 sanitizer.allow(&bucket.id);
1046 for member in &bucket.members {
1047 sanitizer.allow(&member.format);
1048 sanitizer.learn_path(&member.path);
1049 match read_case(&member.path) {
1050 Ok(Case::Balanced(net, _)) => {
1051 sanitizer.learn_buses(net.buses().iter().map(|b| b.id.0));
1052 sanitizer.learn_network(&serde_json::to_value(&*net)?);
1053 }
1054 Ok(Case::Multiconductor(net, _)) => {
1055 sanitizer.learn_network(&serde_json::to_value(&net)?);
1056 }
1057 Err(_) => {}
1058 }
1059 }
1060 }
1061 for bad in &ingest.unreadable {
1062 sanitizer.learn_path(&bad.path);
1063 }
1064
1065 let mut findings = Vec::new();
1066 for bad in &ingest.unreadable {
1067 findings.push(Finding {
1068 bucket: None,
1069 code: if bad.panicked {
1070 "parse.panic"
1071 } else {
1072 "parse.failure"
1073 },
1074 severity: if bad.panicked { "crash" } else { "declared" },
1075 leg: None,
1076 detail: serde_json::json!({ "message": sanitizer.template(&bad.error) }),
1077 });
1078 }
1079 for comparison in &comparisons.comparisons {
1080 findings.extend(findings_for(comparison, &sanitizer));
1081 }
1082 if let Some(walks) = &walks {
1083 for w in &walks.walks {
1084 findings.extend(walk_findings(w, &sanitizer));
1085 }
1086 }
1087
1088 let jsonl = render_jsonl(&findings)?;
1089 audit_or_bail(&sanitizer, &jsonl, findings_path)?;
1090 std::fs::write(findings_path, &jsonl)
1091 .with_context(|| format!("write {}", findings_path.display()))?;
1092
1093 if let Some(path) = summary_path {
1094 let summary = render_summary(&ingest, &findings);
1095 audit_or_bail(&sanitizer, &summary, path)?;
1096 std::fs::write(path, summary).with_context(|| format!("write {}", path.display()))?;
1097 }
1098 Ok(findings.len())
1099}
1100
1101fn audit_or_bail(sanitizer: &Sanitizer, text: &str, path: &Path) -> Result<()> {
1102 if let Err(leaks) = sanitizer.audit(text) {
1103 anyhow::bail!(
1106 "refusing to write {}: {} corpus string(s) reached it, first at {}",
1107 path.display(),
1108 leaks.len(),
1109 leaks[0]
1110 );
1111 }
1112 Ok(())
1113}
1114
1115#[derive(Debug, Clone, Serialize)]
1116pub struct Finding {
1117 #[serde(skip_serializing_if = "Option::is_none")]
1118 pub bucket: Option<String>,
1119 pub code: &'static str,
1120 pub severity: &'static str,
1121 #[serde(skip_serializing_if = "Option::is_none")]
1122 pub leg: Option<Leg>,
1123 pub detail: serde_json::Value,
1124}
1125
1126#[allow(clippy::too_many_lines)]
1129fn findings_for(comparison: &Comparison, sanitizer: &Sanitizer) -> Vec<Finding> {
1130 let mut out = Vec::new();
1131 let bucket = Some(comparison.bucket.clone());
1132 let leg = Some(comparison.leg.clone());
1133 let mut push = |code: &'static str, severity: &'static str, detail: serde_json::Value| {
1134 out.push(Finding {
1135 bucket: bucket.clone(),
1136 code,
1137 severity,
1138 leg: leg.clone(),
1139 detail,
1140 });
1141 };
1142
1143 if comparison.unresolved_include {
1144 push(
1145 "leg.unresolved-include",
1146 "declared",
1147 serde_json::json!({ "compared": false }),
1148 );
1149 return out;
1150 }
1151 if comparison.different_case_data {
1152 push(
1153 "sibling.different-data",
1154 "declared",
1155 serde_json::json!({ "compared": true }),
1156 );
1157 }
1158 if let Some(failure) = &comparison.failure {
1159 let panicked = failure.contains("panicked");
1160 push(
1161 if panicked { "leg.panic" } else { "leg.failure" },
1162 if panicked { "crash" } else { "silent-drop" },
1163 serde_json::json!({ "message": sanitizer.template(failure) }),
1164 );
1165 }
1166 if let Some(side) = &comparison.ybus_unavailable {
1167 push(
1168 "electrical.unavailable",
1169 "silent-value-change",
1170 serde_json::json!({ "side": side }),
1171 );
1172 }
1173 if comparison.ybus.is_some() {
1174 let honest_difference = comparison.leg.via == Via::Sibling
1175 && (comparison.different_case_data || comparison.status_changed > 0);
1176 push(
1180 if honest_difference {
1181 "sibling.ybus"
1182 } else {
1183 "electrical.ybus"
1184 },
1185 if honest_difference {
1186 "declared"
1187 } else {
1188 "silent-value-change"
1189 },
1190 serde_json::json!({ "entries_changed": 1 }),
1191 );
1192 }
1193 if comparison.injection.is_some() {
1194 let honest_difference = comparison.leg.via == Via::Sibling
1200 && (comparison.injection_status_only || comparison.different_case_data);
1201 push(
1202 if honest_difference {
1203 "sibling.status"
1204 } else {
1205 "electrical.injection"
1206 },
1207 if honest_difference {
1208 "declared"
1209 } else {
1210 "silent-value-change"
1211 },
1212 serde_json::json!({ "status_only": comparison.injection_status_only }),
1213 );
1214 }
1215 if comparison.dc_terminal.is_some() {
1216 let declared = !comparison.warnings.is_empty();
1217 push(
1218 "electrical.dc-terminal",
1219 if declared {
1220 "declared"
1221 } else {
1222 "silent-value-change"
1223 },
1224 serde_json::json!({ "declared": declared }),
1225 );
1226 }
1227 if let Some(core) = &comparison.core_changed {
1228 let power_moved = power_moved(core);
1229 push(
1230 if power_moved {
1231 "core.power"
1232 } else {
1233 "core.regrouped"
1234 },
1235 if power_moved {
1236 "silent-drop"
1237 } else {
1238 "declared"
1239 },
1240 serde_json::json!({ "delta": core }),
1241 );
1242 }
1243 if comparison.status_changed > 0 {
1244 push(
1245 "sibling.status",
1246 "declared",
1247 serde_json::json!({ "elements": comparison.status_changed }),
1248 );
1249 }
1250 if !comparison.model_diffs.is_empty() {
1251 let paths: Vec<String> = comparison
1255 .model_diffs
1256 .iter()
1257 .map(|p| sanitizer.template(p))
1258 .collect();
1259 let declared = !comparison.warnings.is_empty();
1260 push(
1261 if declared {
1262 "loss.declared"
1263 } else {
1264 "loss.undeclared"
1265 },
1266 if declared {
1267 "declared"
1268 } else {
1269 "undeclared-loss"
1270 },
1271 serde_json::json!({ "paths": paths }),
1272 );
1273 }
1274 let mut templates: BTreeMap<String, usize> = BTreeMap::new();
1275 for warning in &comparison.warnings {
1276 *templates.entry(sanitizer.template(warning)).or_default() += 1;
1277 }
1278 if !templates.is_empty() {
1279 push(
1280 "warning.observed",
1281 "declared",
1282 serde_json::json!({ "templates": templates }),
1283 );
1284 }
1285 out
1286}
1287
1288fn walk_findings(w: &walk::Walk, sanitizer: &Sanitizer) -> Vec<Finding> {
1298 let mut out = Vec::new();
1299 let path: Vec<String> = w.hops.iter().map(|h| h.to.clone()).collect();
1300 for (index, hop) in w.hops.iter().enumerate() {
1301 let leg = Some(Leg {
1302 from: if index == 0 {
1303 w.origin.clone()
1304 } else {
1305 w.hops[index - 1].to.clone()
1306 },
1307 to: hop.to.clone(),
1308 via: Via::Walk,
1309 from_ordinal: index,
1310 to_ordinal: index + 1,
1311 });
1312 let mut push =
1313 |code: &'static str, severity: &'static str, mut detail: serde_json::Value| {
1314 if let Some(map) = detail.as_object_mut() {
1315 map.insert("origin".into(), serde_json::json!(w.origin));
1316 map.insert("path".into(), serde_json::json!(path));
1317 map.insert("hop".into(), serde_json::json!(index));
1318 map.insert("seed".into(), serde_json::json!(w.seed.to_string()));
1319 }
1320 out.push(Finding {
1321 bucket: Some(w.bucket.clone()),
1322 code,
1323 severity,
1324 leg: leg.clone(),
1325 detail,
1326 });
1327 };
1328
1329 if let Some(failure) = &hop.failure {
1330 let panicked = failure.contains("panicked");
1331 push(
1332 if panicked {
1333 "walk.panic"
1334 } else {
1335 "walk.failure"
1336 },
1337 if panicked { "crash" } else { "silent-drop" },
1338 serde_json::json!({ "message": sanitizer.template(failure) }),
1339 );
1340 }
1341 if let Some(drift) = &hop.drift {
1342 push(
1348 "walk.drift",
1349 if hop.drift_electrical {
1350 "silent-value-change"
1351 } else {
1352 "declared"
1353 },
1354 serde_json::json!({
1355 "revisit_of": hop.revisit_of,
1356 "delta": sanitizer.template(drift),
1357 }),
1358 );
1359 }
1360 if let Some(delta) = &hop.path_dependent {
1361 push(
1367 "walk.path-dependent",
1368 if hop.path_dependent_electrical {
1369 "silent-value-change"
1370 } else {
1371 "declared"
1372 },
1373 serde_json::json!({ "direct": sanitizer.template(delta) }),
1374 );
1375 }
1376 if !hop.resurrected.is_empty() {
1377 push(
1382 "walk.resurrection",
1383 if hop.leg.electrical() {
1384 "silent-value-change"
1385 } else {
1386 "declared"
1387 },
1388 serde_json::json!({ "resurrected": hop.resurrected }),
1389 );
1390 }
1391 if !hop.emptied.is_empty() {
1392 push(
1395 "walk.absorbed",
1396 "declared",
1397 serde_json::json!({ "emptied": hop.emptied }),
1398 );
1399 }
1400 }
1401 out
1402}
1403
1404fn render_jsonl(findings: &[Finding]) -> Result<String> {
1405 let mut out = String::new();
1406 for finding in findings {
1407 out.push_str(&serde_json::to_string(finding)?);
1408 out.push('\n');
1409 }
1410 Ok(out)
1411}
1412
1413fn render_summary(ingest: &Ingest, findings: &[Finding]) -> String {
1414 use std::fmt::Write as _;
1415 let mut out = String::new();
1416 let _ = writeln!(out, "# Corpus run\n");
1417 let _ = writeln!(
1418 out,
1419 "{} files seen, {} buckets, {} unreadable.\n",
1420 ingest.files_seen,
1421 ingest.buckets.len(),
1422 ingest.unreadable.len()
1423 );
1424 let mut sizes: BTreeMap<usize, usize> = BTreeMap::new();
1425 for bucket in &ingest.buckets {
1426 *sizes.entry(bucket.members.len()).or_default() += 1;
1427 }
1428 let _ = writeln!(out, "## Buckets by sibling count\n");
1429 let _ = writeln!(out, "| siblings | buckets |");
1430 let _ = writeln!(out, "| --- | --- |");
1431 for (size, count) in &sizes {
1432 let _ = writeln!(out, "| {size} | {count} |");
1433 }
1434 let mut by_code: BTreeMap<(&str, &str), usize> = BTreeMap::new();
1435 for finding in findings {
1436 *by_code.entry((finding.severity, finding.code)).or_default() += 1;
1437 }
1438 let _ = writeln!(out, "\n## Findings\n");
1439 let _ = writeln!(out, "| severity | code | count |");
1440 let _ = writeln!(out, "| --- | --- | --- |");
1441 for ((severity, code), count) in &by_code {
1442 let _ = writeln!(out, "| {severity} | {code} | {count} |");
1443 }
1444 out
1445}
1446
1447#[cfg(test)]
1448mod tests {
1449 use std::collections::BTreeSet;
1450 use std::path::Path;
1451
1452 use super::{Case, read_case};
1453
1454 fn model_keys(value: &serde_json::Value, in_case_map: bool, out: &mut BTreeSet<String>) {
1456 match value {
1457 serde_json::Value::Array(xs) => {
1458 for x in xs {
1459 model_keys(x, in_case_map, out);
1460 }
1461 }
1462 serde_json::Value::Object(xs) => {
1463 for (key, x) in xs {
1464 if !in_case_map {
1465 out.insert(key.clone());
1466 }
1467 model_keys(
1468 x,
1469 in_case_map || matches!(key.as_str(), "extras" | "properties"),
1470 out,
1471 );
1472 }
1473 }
1474 _ => {}
1475 }
1476 }
1477
1478 #[test]
1483 fn every_fixture_field_name_is_vocabulary() {
1484 let data = Path::new(env!("CARGO_MANIFEST_DIR"))
1485 .parent()
1486 .unwrap()
1487 .join("tests/data");
1488 let mut keys = BTreeSet::new();
1489 let mut parsed = 0usize;
1490 for entry in walkdir::WalkDir::new(&data)
1491 .into_iter()
1492 .filter_map(Result::ok)
1493 .filter(|e| e.file_type().is_file())
1494 {
1495 let Ok(case) = read_case(entry.path()) else {
1496 continue;
1497 };
1498 let value = match case {
1499 Case::Balanced(net, _) => serde_json::to_value(&*net),
1500 Case::Multiconductor(net, _) => serde_json::to_value(&*net),
1501 }
1502 .expect("a parsed fixture serializes");
1503 model_keys(&value, false, &mut keys);
1504 parsed += 1;
1505 }
1506 assert!(parsed >= 18, "only {parsed} fixtures parsed");
1507 let vocabulary = super::anonymize::vocabulary();
1508 let missing: Vec<&String> = keys.iter().filter(|k| !vocabulary.contains(*k)).collect();
1509 assert!(
1510 missing.is_empty(),
1511 "{} fixture field name(s) outside the vocabulary would mask as corpus secrets: {missing:?}",
1512 missing.len()
1513 );
1514 }
1515}