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>, Vec<String>),
378}
379
380impl Case {
381 fn domain(&self) -> Domain {
382 match self {
383 Self::Balanced(..) => Domain::Transmission,
384 Self::Multiconductor(..) => Domain::Distribution,
385 }
386 }
387
388 fn fingerprint(&self) -> Fingerprint {
389 match self {
390 Self::Balanced(net, _) => Fingerprint::of(net),
391 Self::Multiconductor(net, _) => Fingerprint::of_distribution(net),
392 }
393 }
394
395 fn format(&self) -> String {
396 match self {
397 Self::Balanced(net, _) => net.source_format().name().to_string(),
398 Self::Multiconductor(net, _) => net
399 .source_format()
400 .as_ref()
401 .map_or("dss", |f| f.name())
402 .to_string(),
403 }
404 }
405
406 fn warnings(&self) -> Vec<String> {
407 match self {
408 Self::Balanced(_, warnings) | Self::Multiconductor(_, warnings) => warnings.clone(),
409 }
410 }
411}
412
413fn read_case(path: &Path) -> std::result::Result<Case, Unreadable> {
421 let unreadable = |error: String, panicked: bool| Unreadable {
422 path: path.to_path_buf(),
423 error,
424 panicked,
425 };
426 let balanced_error = match catch_panic(|| crate::compat::parse_file(path, None)) {
427 Ok(Ok(parsed)) => {
428 let rendered = parsed.rendered_diagnostics();
429 return Ok(Case::Balanced(Box::new(parsed.network), rendered));
430 }
431 Err(message) => return Err(unreadable(message, true)),
432 Ok(Err(err)) => err.to_string(),
433 };
434 match catch_panic(|| crate::compat::dist_parse_file(path, None)) {
435 Ok(Ok(parsed)) => Ok(Case::Multiconductor(
436 Box::new(parsed.network),
437 parsed.warnings,
438 )),
439 Ok(Err(err)) => {
444 let message = err.to_string();
445 Err(unreadable(
446 if message.contains("unknown distribution format")
447 || message.contains("REQUEST.FORMAT.UNKNOWN")
448 {
449 balanced_error
450 } else {
451 message
452 },
453 false,
454 ))
455 }
456 Err(message) => Err(unreadable(message, true)),
457 }
458}
459
460fn balanced(path: &Path) -> Option<BalancedNetwork> {
462 match read_case(path) {
463 Ok(Case::Balanced(net, _)) => Some(*net),
464 _ => None,
465 }
466}
467
468fn multiconductor(path: &Path) -> Option<powerio_dist::MulticonductorNetwork> {
471 match read_case(path) {
472 Ok(Case::Multiconductor(net, _)) => Some(*net),
473 _ => None,
474 }
475}
476
477fn catch_panic<T>(
481 f: impl FnOnce() -> T + std::panic::UnwindSafe,
482) -> std::result::Result<T, String> {
483 let previous = std::panic::take_hook();
484 std::panic::set_hook(Box::new(|_| {}));
485 let result = std::panic::catch_unwind(f);
486 std::panic::set_hook(previous);
487 result.map_err(|payload| {
488 payload
489 .downcast_ref::<&str>()
490 .map(|s| (*s).to_string())
491 .or_else(|| payload.downcast_ref::<String>().cloned())
492 .unwrap_or_else(|| "panic".to_string())
493 })
494}
495
496#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
498#[serde(rename_all = "kebab-case")]
499pub enum Via {
500 RoundTrip,
502 CrossWrite,
504 Sibling,
508 Walk,
512}
513
514#[derive(Debug, Clone, Serialize, Deserialize)]
515pub struct Leg {
516 pub from: String,
517 pub to: String,
518 pub via: Via,
519 pub from_ordinal: usize,
520 pub to_ordinal: usize,
521}
522
523#[derive(Debug, Clone, Serialize, Deserialize)]
526pub struct Comparison {
527 pub bucket: String,
528 pub leg: Leg,
529 pub warnings: Vec<String>,
530 pub core_changed: Option<String>,
531 pub ybus: Option<String>,
532 pub ybus_unavailable: Option<String>,
533 pub injection: Option<String>,
534 pub injection_status_only: bool,
535 pub dc_terminal: Option<String>,
539 pub model_diffs: Vec<String>,
541 pub failure: Option<String>,
542 pub variant: bool,
546 pub unresolved_include: bool,
549 pub status_changed: usize,
551}
552
553#[derive(Debug, Clone, Serialize, Deserialize)]
554pub struct Comparisons {
555 pub comparisons: Vec<Comparison>,
556}
557
558pub fn compare(work: &Path) -> Result<Comparisons> {
564 let ingest: Ingest = read_json(&work.join(INGEST_FILE))?;
565 let mut comparisons = Vec::new();
566
567 for bucket in &ingest.buckets {
568 match bucket.domain {
569 Domain::Transmission => compare_transmission(bucket, &mut comparisons),
570 Domain::Distribution => compare_distribution(bucket, &mut comparisons),
571 }
572 }
573
574 let out = Comparisons { comparisons };
575 write_json(&work.join(COMPARE_FILE), &out)?;
576 Ok(out)
577}
578
579const MAX_BUCKET_MEMBERS: usize = 24;
587
588fn compared_members(bucket: &Bucket) -> &[Member] {
590 let n = bucket.members.len().min(MAX_BUCKET_MEMBERS);
591 &bucket.members[..n]
592}
593
594fn compare_transmission(bucket: &Bucket, out: &mut Vec<Comparison>) {
595 let members = compared_members(bucket);
598 let networks: Vec<Option<BalancedNetwork>> =
599 members.iter().map(|m| balanced(&m.path)).collect();
600 for (i, member) in members.iter().enumerate() {
601 let Some(source) = networks[i].as_ref() else {
602 continue;
603 };
604 for (j, other) in members.iter().enumerate() {
605 let Some(target) = powerio_matrix::target_format_from_name(&other.format) else {
606 continue;
607 };
608 let via = if i == j {
609 Via::RoundTrip
610 } else {
611 Via::CrossWrite
612 };
613 out.push(convert_leg(bucket, member, other, via, source, target));
614 }
615 for (j, other) in members.iter().enumerate().skip(i + 1) {
616 let Some(sibling) = networks[j].as_ref() else {
617 continue;
618 };
619 let twin = fingerprint::same_data(source, sibling);
625 let mut leg = sibling_leg(bucket, member, other, source, sibling);
626 leg.variant = !twin;
627 out.push(leg);
628 }
629 }
630}
631
632fn compare_distribution(bucket: &Bucket, out: &mut Vec<Comparison>) {
638 let members = compared_members(bucket);
639 let networks: Vec<Option<powerio_dist::MulticonductorNetwork>> =
640 members.iter().map(|m| multiconductor(&m.path)).collect();
641 for (i, member) in members.iter().enumerate() {
642 let Some(source) = networks[i].as_ref() else {
643 continue;
644 };
645 for (j, other) in members.iter().enumerate() {
646 let Some(target) = powerio_dist::dist_target_from_name(&other.format) else {
647 continue;
648 };
649 let via = if i == j {
650 Via::RoundTrip
651 } else {
652 Via::CrossWrite
653 };
654 out.push(dist_convert_leg(bucket, member, other, via, source, target));
655 }
656 }
657}
658
659fn dist_convert_leg(
660 bucket: &Bucket,
661 from: &Member,
662 to: &Member,
663 via: Via,
664 source: &powerio_dist::MulticonductorNetwork,
665 target: powerio_dist::DistTargetFormat,
666) -> Comparison {
667 let mut out = empty_comparison(
668 bucket,
669 Leg {
670 from: from.format.clone(),
671 to: to.format.clone(),
672 via,
673 from_ordinal: from.ordinal,
674 to_ordinal: to.ordinal,
675 },
676 );
677 let written = match catch_panic(|| powerio_dist::write_network(source, target)) {
678 Ok(conversion) => conversion,
679 Err(message) => {
680 out.failure = Some(format!("write panicked: {message}"));
681 return out;
682 }
683 };
684 out.warnings.extend(written.rendered_diagnostics());
685 let token = to.format.clone();
686 let text = written.text;
687 if has_include(&text) {
694 out.unresolved_include = true;
695 return out;
696 }
697 let parsed = match catch_panic(|| crate::compat::dist_parse_str(&text, &token)) {
698 Ok(Ok(parsed)) => parsed,
699 Ok(Err(err)) => {
700 out.failure = Some(format!("readback: {err}"));
701 return out;
702 }
703 Err(message) => {
704 out.failure = Some(format!("readback panicked: {message}"));
705 return out;
706 }
707 };
708 out.warnings.extend(parsed.warnings.iter().cloned());
712 let before = invariants::distribution_core(source);
713 let after = invariants::distribution_core(&parsed.network);
714 if before != after {
715 out.core_changed = Some(dist_core_delta(&before, &after));
716 }
717 let clean = parsed.network;
718 let diffs = invariants::model_diffs(
719 &invariants::distribution_value(source),
720 &invariants::distribution_value(&clean),
721 );
722 out.model_diffs = diffs
723 .iter()
724 .map(|d| anonymize::collapse_path(&d.path))
725 .collect();
726 out.model_diffs.sort();
727 out.model_diffs.dedup();
728 out
729}
730
731fn has_include(text: &str) -> bool {
735 text.lines().any(|line| {
736 let word = line.split_whitespace().next().unwrap_or("");
737 word.eq_ignore_ascii_case("redirect")
738 || word.eq_ignore_ascii_case("compile")
739 || word.eq_ignore_ascii_case("buscoords")
743 })
744}
745
746fn dist_core_delta(
747 before: &invariants::DistributionCore,
748 after: &invariants::DistributionCore,
749) -> String {
750 let mut parts = Vec::new();
751 for (label, a, b) in [
752 ("buses", before.buses, after.buses),
753 ("loads", before.loads, after.loads),
754 ("generators", before.generators, after.generators),
755 ("shunts", before.shunts, after.shunts),
756 ] {
757 if a != b {
758 parts.push(format!("{label} {}", count_delta(a, b)));
759 }
760 }
761 if before.load_p != after.load_p {
762 parts.push("load p".to_string());
763 }
764 if before.load_q != after.load_q {
765 parts.push("load q".to_string());
766 }
767 parts.join(", ")
768}
769
770fn empty_comparison(bucket: &Bucket, leg: Leg) -> Comparison {
771 Comparison {
772 bucket: bucket.id.clone(),
773 leg,
774 warnings: Vec::new(),
775 core_changed: None,
776 ybus: None,
777 ybus_unavailable: None,
778 injection: None,
779 injection_status_only: false,
780 dc_terminal: None,
781 model_diffs: Vec::new(),
782 failure: None,
783 variant: false,
784 unresolved_include: false,
785 status_changed: 0,
786 }
787}
788
789fn convert_leg(
790 bucket: &Bucket,
791 from: &Member,
792 to: &Member,
793 via: Via,
794 source: &BalancedNetwork,
795 target: TargetFormat,
796) -> Comparison {
797 let mut out = empty_comparison(
798 bucket,
799 Leg {
800 from: from.format.clone(),
801 to: to.format.clone(),
802 via,
803 from_ordinal: from.ordinal,
804 to_ordinal: to.ordinal,
805 },
806 );
807 let written = match catch_panic(|| powerio_matrix::write_network(source, target)) {
808 Ok(Ok(conversion)) => conversion,
809 Ok(Err(err)) => {
810 out.failure = Some(format!("write: {err}"));
811 return out;
812 }
813 Err(message) => {
814 out.failure = Some(format!("write panicked: {message}"));
815 return out;
816 }
817 };
818 out.warnings.extend(written.rendered_diagnostics());
819 let token = to.format.clone();
820 let text = written.text;
821 let parsed = match catch_panic(|| crate::compat::parse_str(&text, &token)) {
822 Ok(Ok(parsed)) => parsed,
823 Ok(Err(err)) => {
824 out.failure = Some(format!("readback: {err}"));
825 return out;
826 }
827 Err(message) => {
828 out.failure = Some(format!("readback panicked: {message}"));
829 return out;
830 }
831 };
832 out.warnings.extend(parsed.rendered_diagnostics());
833 fill_invariants(&mut out, source, &parsed.network);
834 out
835}
836
837fn sibling_leg(
838 bucket: &Bucket,
839 from: &Member,
840 to: &Member,
841 source: &BalancedNetwork,
842 sibling: &BalancedNetwork,
843) -> Comparison {
844 let mut out = empty_comparison(
845 bucket,
846 Leg {
847 from: from.format.clone(),
848 to: to.format.clone(),
849 via: Via::Sibling,
850 from_ordinal: from.ordinal,
851 to_ordinal: to.ordinal,
852 },
853 );
854 out.status_changed = invariants::status_disagreements(source, sibling);
863 let before = invariants::transmission_core(source);
864 let after = invariants::transmission_core(sibling);
865 if before != after {
866 out.core_changed = Some(core_delta(&before, &after));
867 }
868 match invariants::ybus_change(source, sibling) {
869 Ok(Some(change)) => out.ybus = Some(change.to_string()),
870 Ok(None) => {}
871 Err(side) => out.ybus_unavailable = Some(unavailable_label(&side)),
872 }
873 if let Some(change) = invariants::injection_change(source, sibling) {
874 out.injection_status_only = change.status_only;
875 out.injection = Some(change.to_string());
876 }
877 if let Some(change) = invariants::dc_terminal_change(source, sibling) {
878 out.dc_terminal = Some(change.to_string());
879 }
880 out
881}
882
883fn fill_invariants(out: &mut Comparison, source: &BalancedNetwork, result: &BalancedNetwork) {
884 let before = invariants::transmission_core(source);
885 let after = invariants::transmission_core(result);
886 if before != after {
887 out.core_changed = Some(core_delta(&before, &after));
888 }
889 match invariants::ybus_change(source, result) {
890 Ok(Some(change)) => out.ybus = Some(change.to_string()),
891 Ok(None) => {}
892 Err(side) => out.ybus_unavailable = Some(unavailable_label(&side)),
893 }
894 if let Some(change) = invariants::injection_change(source, result) {
895 out.injection_status_only = change.status_only;
896 out.injection = Some(change.to_string());
897 }
898 if let Some(change) = invariants::dc_terminal_change(source, result) {
899 out.dc_terminal = Some(change.to_string());
900 }
901 let diffs = invariants::model_diffs(
902 &invariants::transmission_value(source),
903 &invariants::transmission_value(result),
904 );
905 out.model_diffs = diffs
906 .iter()
907 .map(|d| anonymize::collapse_path(&d.path))
908 .collect();
909 out.model_diffs.sort();
910 out.model_diffs.dedup();
911}
912
913fn count_delta(before: usize, after: usize) -> String {
916 if after >= before {
917 format!("+{}", after - before)
918 } else {
919 format!("-{}", before - after)
920 }
921}
922
923fn unavailable_label(side: &YbusUnavailable) -> String {
924 match side {
925 YbusUnavailable::Before => "source".to_string(),
926 YbusUnavailable::After => "result".to_string(),
927 }
928}
929
930pub(super) fn power_moved(core: &str) -> bool {
942 ["load p", "load q", "gen p", "base mva"]
943 .iter()
944 .any(|label| core.contains(label))
945}
946
947fn core_delta(
948 before: &invariants::TransmissionCore,
949 after: &invariants::TransmissionCore,
950) -> String {
951 let mut parts = Vec::new();
952 let mut delta = |label: &str, a: usize, b: usize| {
953 if a != b {
954 parts.push(format!("{label} {}", count_delta(a, b)));
955 }
956 };
957 delta("buses", before.buses, after.buses);
958 delta("branches", before.branches, after.branches);
959 delta("generators", before.generators, after.generators);
960 delta("loads", before.loads, after.loads);
961 delta("shunts", before.shunts, after.shunts);
962 if before.load_p != after.load_p {
963 parts.push("load p".to_string());
964 }
965 if before.load_q != after.load_q {
966 parts.push("load q".to_string());
967 }
968 if before.gen_p != after.gen_p {
969 parts.push("gen p".to_string());
970 }
971 if before.base_mva != after.base_mva {
972 parts.push("base mva".to_string());
973 }
974 parts.join(", ")
975}
976
977fn write_json<T: Serialize>(path: &Path, value: &T) -> Result<()> {
978 let text = serde_json::to_string_pretty(value)?;
979 std::fs::write(path, text).with_context(|| format!("write {}", path.display()))?;
980 Ok(())
981}
982
983fn read_json<T: for<'de> Deserialize<'de>>(path: &Path) -> Result<T> {
984 let text = std::fs::read_to_string(path).with_context(|| {
985 format!(
986 "read {} (run `powerio corpus ingest` first)",
987 path.display()
988 )
989 })?;
990 Ok(serde_json::from_str(&text)?)
991}
992
993pub fn report(work: &Path, findings_path: &Path, summary_path: Option<&Path>) -> Result<usize> {
1000 let ingest: Ingest = read_json(&work.join(INGEST_FILE))?;
1001 let comparisons: Option<Comparisons> = read_json(&work.join(COMPARE_FILE)).ok();
1004 let walks: Option<walk::Walks> = read_json(&work.join(walk::WALK_FILE)).ok();
1005 if comparisons.is_none() && walks.is_none() {
1006 anyhow::bail!(
1007 "nothing to report in {}: run `powerio corpus compare` or `powerio corpus walk` first",
1008 work.display()
1009 );
1010 }
1011 let comparisons = comparisons.unwrap_or(Comparisons {
1012 comparisons: Vec::new(),
1013 });
1014
1015 let mut sanitizer = Sanitizer::new();
1016 for key in SCHEMA_KEYS {
1020 sanitizer.allow(key);
1021 }
1022 for bucket in &ingest.buckets {
1023 sanitizer.allow(&bucket.id);
1024 for member in &bucket.members {
1025 sanitizer.allow(&member.format);
1026 sanitizer.learn_path(&member.path);
1027 match read_case(&member.path) {
1028 Ok(Case::Balanced(net, _)) => {
1029 sanitizer.learn_buses(net.buses().iter().map(|b| b.id.0));
1030 sanitizer.learn_network(&serde_json::to_value(&*net)?);
1031 }
1032 Ok(Case::Multiconductor(net, _)) => {
1033 sanitizer.learn_network(&serde_json::to_value(&net)?);
1034 }
1035 Err(_) => {}
1036 }
1037 }
1038 }
1039 for bad in &ingest.unreadable {
1040 sanitizer.learn_path(&bad.path);
1041 }
1042
1043 let mut findings = Vec::new();
1044 for bad in &ingest.unreadable {
1045 findings.push(Finding {
1046 bucket: None,
1047 code: if bad.panicked {
1048 "parse.panic"
1049 } else {
1050 "parse.failure"
1051 },
1052 severity: if bad.panicked { "crash" } else { "declared" },
1053 leg: None,
1054 detail: serde_json::json!({ "message": sanitizer.template(&bad.error) }),
1055 });
1056 }
1057 for comparison in &comparisons.comparisons {
1058 findings.extend(findings_for(comparison, &sanitizer));
1059 }
1060 if let Some(walks) = &walks {
1061 for w in &walks.walks {
1062 findings.extend(walk_findings(w, &sanitizer));
1063 }
1064 }
1065
1066 let jsonl = render_jsonl(&findings)?;
1067 audit_or_bail(&sanitizer, &jsonl, findings_path)?;
1068 std::fs::write(findings_path, &jsonl)
1069 .with_context(|| format!("write {}", findings_path.display()))?;
1070
1071 if let Some(path) = summary_path {
1072 let summary = render_summary(&ingest, &findings);
1073 audit_or_bail(&sanitizer, &summary, path)?;
1074 std::fs::write(path, summary).with_context(|| format!("write {}", path.display()))?;
1075 }
1076 Ok(findings.len())
1077}
1078
1079fn audit_or_bail(sanitizer: &Sanitizer, text: &str, path: &Path) -> Result<()> {
1080 if let Err(leaks) = sanitizer.audit(text) {
1081 anyhow::bail!(
1084 "refusing to write {}: {} corpus string(s) reached it, first at {}",
1085 path.display(),
1086 leaks.len(),
1087 leaks[0]
1088 );
1089 }
1090 Ok(())
1091}
1092
1093#[derive(Debug, Clone, Serialize)]
1094pub struct Finding {
1095 #[serde(skip_serializing_if = "Option::is_none")]
1096 pub bucket: Option<String>,
1097 pub code: &'static str,
1098 pub severity: &'static str,
1099 #[serde(skip_serializing_if = "Option::is_none")]
1100 pub leg: Option<Leg>,
1101 pub detail: serde_json::Value,
1102}
1103
1104#[allow(clippy::too_many_lines)]
1107fn findings_for(comparison: &Comparison, sanitizer: &Sanitizer) -> Vec<Finding> {
1108 let mut out = Vec::new();
1109 let bucket = Some(comparison.bucket.clone());
1110 let leg = Some(comparison.leg.clone());
1111 let mut push = |code: &'static str, severity: &'static str, detail: serde_json::Value| {
1112 out.push(Finding {
1113 bucket: bucket.clone(),
1114 code,
1115 severity,
1116 leg: leg.clone(),
1117 detail,
1118 });
1119 };
1120
1121 if comparison.unresolved_include {
1122 push(
1123 "leg.unresolved-include",
1124 "declared",
1125 serde_json::json!({ "compared": false }),
1126 );
1127 return out;
1128 }
1129 if comparison.variant {
1130 push(
1131 "sibling.variant",
1132 "declared",
1133 serde_json::json!({ "compared": true }),
1134 );
1135 }
1136 if let Some(failure) = &comparison.failure {
1137 let panicked = failure.contains("panicked");
1138 push(
1139 if panicked { "leg.panic" } else { "leg.failure" },
1140 if panicked { "crash" } else { "silent-drop" },
1141 serde_json::json!({ "message": sanitizer.template(failure) }),
1142 );
1143 }
1144 if let Some(side) = &comparison.ybus_unavailable {
1145 push(
1146 "electrical.unavailable",
1147 "silent-value-change",
1148 serde_json::json!({ "side": side }),
1149 );
1150 }
1151 if comparison.ybus.is_some() {
1152 let honest_difference = comparison.leg.via == Via::Sibling
1153 && (comparison.variant || comparison.status_changed > 0);
1154 push(
1158 if honest_difference {
1159 "sibling.ybus"
1160 } else {
1161 "electrical.ybus"
1162 },
1163 if honest_difference {
1164 "declared"
1165 } else {
1166 "silent-value-change"
1167 },
1168 serde_json::json!({ "entries_changed": 1 }),
1169 );
1170 }
1171 if comparison.injection.is_some() {
1172 let honest_difference = comparison.leg.via == Via::Sibling
1178 && (comparison.injection_status_only || comparison.variant);
1179 push(
1180 if honest_difference {
1181 "sibling.status"
1182 } else {
1183 "electrical.injection"
1184 },
1185 if honest_difference {
1186 "declared"
1187 } else {
1188 "silent-value-change"
1189 },
1190 serde_json::json!({ "status_only": comparison.injection_status_only }),
1191 );
1192 }
1193 if comparison.dc_terminal.is_some() {
1194 let declared = !comparison.warnings.is_empty();
1195 push(
1196 "electrical.dc-terminal",
1197 if declared {
1198 "declared"
1199 } else {
1200 "silent-value-change"
1201 },
1202 serde_json::json!({ "declared": declared }),
1203 );
1204 }
1205 if let Some(core) = &comparison.core_changed {
1206 let power_moved = power_moved(core);
1207 push(
1208 if power_moved {
1209 "core.power"
1210 } else {
1211 "core.regrouped"
1212 },
1213 if power_moved {
1214 "silent-drop"
1215 } else {
1216 "declared"
1217 },
1218 serde_json::json!({ "delta": core }),
1219 );
1220 }
1221 if comparison.status_changed > 0 {
1222 push(
1223 "sibling.status",
1224 "declared",
1225 serde_json::json!({ "elements": comparison.status_changed }),
1226 );
1227 }
1228 if !comparison.model_diffs.is_empty() {
1229 let paths: Vec<String> = comparison
1233 .model_diffs
1234 .iter()
1235 .map(|p| sanitizer.template(p))
1236 .collect();
1237 let declared = !comparison.warnings.is_empty();
1238 push(
1239 if declared {
1240 "loss.declared"
1241 } else {
1242 "loss.undeclared"
1243 },
1244 if declared {
1245 "declared"
1246 } else {
1247 "undeclared-loss"
1248 },
1249 serde_json::json!({ "paths": paths }),
1250 );
1251 }
1252 let mut templates: BTreeMap<String, usize> = BTreeMap::new();
1253 for warning in &comparison.warnings {
1254 *templates.entry(sanitizer.template(warning)).or_default() += 1;
1255 }
1256 if !templates.is_empty() {
1257 push(
1258 "warning.observed",
1259 "declared",
1260 serde_json::json!({ "templates": templates }),
1261 );
1262 }
1263 out
1264}
1265
1266fn walk_findings(w: &walk::Walk, sanitizer: &Sanitizer) -> Vec<Finding> {
1276 let mut out = Vec::new();
1277 let path: Vec<String> = w.hops.iter().map(|h| h.to.clone()).collect();
1278 for (index, hop) in w.hops.iter().enumerate() {
1279 let leg = Some(Leg {
1280 from: if index == 0 {
1281 w.origin.clone()
1282 } else {
1283 w.hops[index - 1].to.clone()
1284 },
1285 to: hop.to.clone(),
1286 via: Via::Walk,
1287 from_ordinal: index,
1288 to_ordinal: index + 1,
1289 });
1290 let mut push =
1291 |code: &'static str, severity: &'static str, mut detail: serde_json::Value| {
1292 if let Some(map) = detail.as_object_mut() {
1293 map.insert("origin".into(), serde_json::json!(w.origin));
1294 map.insert("path".into(), serde_json::json!(path));
1295 map.insert("hop".into(), serde_json::json!(index));
1296 map.insert("seed".into(), serde_json::json!(w.seed.to_string()));
1297 }
1298 out.push(Finding {
1299 bucket: Some(w.bucket.clone()),
1300 code,
1301 severity,
1302 leg: leg.clone(),
1303 detail,
1304 });
1305 };
1306
1307 if let Some(failure) = &hop.failure {
1308 let panicked = failure.contains("panicked");
1309 push(
1310 if panicked {
1311 "walk.panic"
1312 } else {
1313 "walk.failure"
1314 },
1315 if panicked { "crash" } else { "silent-drop" },
1316 serde_json::json!({ "message": sanitizer.template(failure) }),
1317 );
1318 }
1319 if let Some(drift) = &hop.drift {
1320 push(
1326 "walk.drift",
1327 if hop.drift_electrical {
1328 "silent-value-change"
1329 } else {
1330 "declared"
1331 },
1332 serde_json::json!({
1333 "revisit_of": hop.revisit_of,
1334 "delta": sanitizer.template(drift),
1335 }),
1336 );
1337 }
1338 if let Some(delta) = &hop.path_dependent {
1339 push(
1345 "walk.path-dependent",
1346 if hop.path_dependent_electrical {
1347 "silent-value-change"
1348 } else {
1349 "declared"
1350 },
1351 serde_json::json!({ "direct": sanitizer.template(delta) }),
1352 );
1353 }
1354 if !hop.resurrected.is_empty() {
1355 push(
1360 "walk.resurrection",
1361 if hop.leg.electrical() {
1362 "silent-value-change"
1363 } else {
1364 "declared"
1365 },
1366 serde_json::json!({ "resurrected": hop.resurrected }),
1367 );
1368 }
1369 if !hop.emptied.is_empty() {
1370 push(
1373 "walk.absorbed",
1374 "declared",
1375 serde_json::json!({ "emptied": hop.emptied }),
1376 );
1377 }
1378 }
1379 out
1380}
1381
1382fn render_jsonl(findings: &[Finding]) -> Result<String> {
1383 let mut out = String::new();
1384 for finding in findings {
1385 out.push_str(&serde_json::to_string(finding)?);
1386 out.push('\n');
1387 }
1388 Ok(out)
1389}
1390
1391fn render_summary(ingest: &Ingest, findings: &[Finding]) -> String {
1392 use std::fmt::Write as _;
1393 let mut out = String::new();
1394 let _ = writeln!(out, "# Corpus run\n");
1395 let _ = writeln!(
1396 out,
1397 "{} files seen, {} buckets, {} unreadable.\n",
1398 ingest.files_seen,
1399 ingest.buckets.len(),
1400 ingest.unreadable.len()
1401 );
1402 let mut sizes: BTreeMap<usize, usize> = BTreeMap::new();
1403 for bucket in &ingest.buckets {
1404 *sizes.entry(bucket.members.len()).or_default() += 1;
1405 }
1406 let _ = writeln!(out, "## Buckets by sibling count\n");
1407 let _ = writeln!(out, "| siblings | buckets |");
1408 let _ = writeln!(out, "| --- | --- |");
1409 for (size, count) in &sizes {
1410 let _ = writeln!(out, "| {size} | {count} |");
1411 }
1412 let mut by_code: BTreeMap<(&str, &str), usize> = BTreeMap::new();
1413 for finding in findings {
1414 *by_code.entry((finding.severity, finding.code)).or_default() += 1;
1415 }
1416 let _ = writeln!(out, "\n## Findings\n");
1417 let _ = writeln!(out, "| severity | code | count |");
1418 let _ = writeln!(out, "| --- | --- | --- |");
1419 for ((severity, code), count) in &by_code {
1420 let _ = writeln!(out, "| {severity} | {code} | {count} |");
1421 }
1422 out
1423}
1424
1425#[cfg(test)]
1426mod tests {
1427 use std::collections::BTreeSet;
1428 use std::path::Path;
1429
1430 use super::{Case, read_case};
1431
1432 fn model_keys(value: &serde_json::Value, in_extras: bool, out: &mut BTreeSet<String>) {
1435 match value {
1436 serde_json::Value::Array(xs) => {
1437 for x in xs {
1438 model_keys(x, in_extras, out);
1439 }
1440 }
1441 serde_json::Value::Object(xs) => {
1442 for (key, x) in xs {
1443 if !in_extras {
1444 out.insert(key.clone());
1445 }
1446 model_keys(x, in_extras || key == "extras", out);
1447 }
1448 }
1449 _ => {}
1450 }
1451 }
1452
1453 #[test]
1458 fn every_fixture_field_name_is_vocabulary() {
1459 let data = Path::new(env!("CARGO_MANIFEST_DIR"))
1460 .parent()
1461 .unwrap()
1462 .join("tests/data");
1463 let mut keys = BTreeSet::new();
1464 let mut parsed = 0usize;
1465 for entry in walkdir::WalkDir::new(&data)
1466 .into_iter()
1467 .filter_map(Result::ok)
1468 .filter(|e| e.file_type().is_file())
1469 {
1470 let Ok(case) = read_case(entry.path()) else {
1471 continue;
1472 };
1473 let value = match case {
1474 Case::Balanced(net, _) => serde_json::to_value(&*net),
1475 Case::Multiconductor(net, _) => serde_json::to_value(&*net),
1476 }
1477 .expect("a parsed fixture serializes");
1478 model_keys(&value, false, &mut keys);
1479 parsed += 1;
1480 }
1481 assert!(parsed >= 18, "only {parsed} fixtures parsed");
1482 let vocabulary = super::anonymize::vocabulary();
1483 let missing: Vec<&String> = keys.iter().filter(|k| !vocabulary.contains(*k)).collect();
1484 assert!(
1485 missing.is_empty(),
1486 "{} fixture field name(s) outside the vocabulary would mask as corpus secrets: {missing:?}",
1487 missing.len()
1488 );
1489 }
1490}