Skip to main content

powerio_cli/corpus/
walk.rs

1//! Random conversion walks: chain a case through a cycle of formats and hold
2//! the chain to properties a single leg cannot express.
3//!
4//! [`super::compare`] runs every leg from the pristine case. That is the right
5//! shape for a gate, and it is blind to three things:
6//!
7//! - **Path dependence.** Reaching `egret` through `psse` and `surge` may give
8//!   a different network than converting to `egret` directly. Every leg on the
9//!   way can be individually clean and the composition still wrong.
10//! - **Drift.** Conversion should settle: once a case has been through a
11//!   format, going through it again should be a no-op. A pair that never
12//!   settles loses or invents something on every pass, and one leg cannot see
13//!   it.
14//! - **Resurrection.** A count that reached zero and came back is a writer
15//!   stating data no reader gave it. Only a chain reaches the empty state that
16//!   makes this visible.
17//!
18//! A walk also reaches inputs the matrix never builds: a leg fed a network
19//! that has already crossed three formats is a leg the gate has never run.
20//!
21//! # Learning from the walks
22//!
23//! Random search over a ten format graph wastes most of its budget re-walking
24//! what it already knows. The run keeps a [`Ledger`] in the work directory,
25//! keyed by directed edge, holding every signature that edge has ever produced
26//! — warning templates, changed model paths, finding codes. Two things read
27//! it:
28//!
29//! - **Sampling.** An edge's weight is its novelty rate, `(1 + novel) / (1 +
30//!   visits)`. An edge nobody has walked scores 1; one that has stopped
31//!   teaching anything decays toward 0 without ever reaching it, so nothing is
32//!   permanently excluded.
33//! - **Stopping.** A walk that adds no signature is dry. `settle` consecutive
34//!   dry walks end the run: the sampler has stopped finding, rather than the
35//!   caller having guessed a walk count.
36//!
37//! The ledger persists, so a second `powerio corpus walk` against the same work
38//! directory resumes with everything the first learned. Delete it to start over.
39
40use std::collections::{BTreeMap, BTreeSet};
41use std::path::Path;
42
43use anyhow::Result;
44use powerio_dist::MulticonductorNetwork;
45use powerio_matrix::BalancedNetwork;
46use serde::{Deserialize, Serialize};
47
48use super::anonymize;
49use super::{Bucket, Domain, Ingest, balanced, catch_panic, core_delta, multiconductor};
50use crate::invariants;
51
52pub(super) const WALK_FILE: &str = "walks.json";
53pub(super) const LEDGER_FILE: &str = "ledger.json";
54
55/// The formats a walk may step through: every transmission format that both
56/// writes text and reads it back.
57///
58/// `pypsa-csv` and `gridfm` are directories and `pwb` is read only, so none of
59/// them can be a step. `goc3` and `opfdata` write only by echoing a retained
60/// source, which a walk clears at the first hop, so they would write an empty
61/// document.
62pub const ALPHABET: [&str; 10] = [
63    "matpower",
64    "psse",
65    "psse34",
66    "psse35",
67    "powerworld",
68    "pandapower-json",
69    "powermodels-json",
70    "egret-json",
71    "pslf",
72    "surge-json",
73];
74
75/// The distribution alphabet: every multiconductor format, each of which both
76/// writes text and reads it back. Three formats gives two choices per step,
77/// which is still enough to reach every composition the pairwise compare
78/// cannot: `dss → pmd → dss` asks whether OpenDSS settles, and `dss → pmd →
79/// bmopf` asks whether the route matters.
80pub const DIST_ALPHABET: [&str; 3] = ["dss", "pmd-json", "bmopf-json"];
81
82/// What a hop changed, in the vocabulary [`super::Comparison`] already uses so
83/// the reporter sanitizes both the same way.
84#[derive(Debug, Clone, Default, Serialize, Deserialize)]
85pub struct HopDelta {
86    pub core_changed: Option<String>,
87    pub ybus: Option<String>,
88    pub injection: Option<String>,
89    pub dc_terminal: Option<String>,
90    pub model_diffs: Vec<String>,
91}
92
93impl HopDelta {
94    fn of(before: &BalancedNetwork, after: &BalancedNetwork) -> Self {
95        let mut out = Self::default();
96        let (core_before, core_after) = (
97            invariants::transmission_core(before),
98            invariants::transmission_core(after),
99        );
100        if core_before != core_after {
101            out.core_changed = Some(core_delta(&core_before, &core_after));
102        }
103        if let Ok(Some(change)) = invariants::ybus_change(before, after) {
104            out.ybus = Some(change.to_string());
105        }
106        if let Some(change) = invariants::injection_change(before, after) {
107            out.injection = Some(change.to_string());
108        }
109        if let Some(change) = invariants::dc_terminal_change(before, after) {
110            out.dc_terminal = Some(change.to_string());
111        }
112        out.finish_diffs(&invariants::model_diffs(
113            &invariants::transmission_value(before),
114            &invariants::transmission_value(after),
115        ));
116        out
117    }
118
119    /// The distribution delta: element counts and the typed-model diff, the
120    /// same two properties [`super::compare`] grades for this domain.
121    fn of_dist(before: &MulticonductorNetwork, after: &MulticonductorNetwork) -> Self {
122        let mut out = Self::default();
123        let (core_before, core_after) = (
124            invariants::distribution_core(before),
125            invariants::distribution_core(after),
126        );
127        if core_before != core_after {
128            out.core_changed = Some(super::dist_core_delta(&core_before, &core_after));
129        }
130        out.finish_diffs(&invariants::model_diffs(
131            &invariants::distribution_value(before),
132            &invariants::distribution_value(after),
133        ));
134        out
135    }
136
137    fn finish_diffs(&mut self, diffs: &[invariants::ModelDiff]) {
138        self.model_diffs = diffs
139            .iter()
140            .map(|d| anonymize::collapse_path(&d.path))
141            .collect();
142        self.model_diffs.sort();
143        self.model_diffs.dedup();
144    }
145
146    fn is_empty(&self) -> bool {
147        !self.electrical() && self.model_diffs.is_empty()
148    }
149
150    /// Whether an electrical property moved, as opposed to retained extras or
151    /// other model fields alone.
152    ///
153    /// A counts-only core change stays out: splitting one unbalanced load into
154    /// its phases or restating transformer charging as bus shunts regroups
155    /// elements the way the target format demands, and the pairwise compare
156    /// grades the same move `core.regrouped`. Power moving is the line, there
157    /// as here.
158    pub(super) fn electrical(&self) -> bool {
159        self.core_changed.as_deref().is_some_and(super::power_moved)
160            || self.ybus.is_some()
161            || self.injection.is_some()
162            || self.dc_terminal.is_some()
163    }
164
165    /// The signatures this delta teaches its edge. Values are left out: the
166    /// ledger decides what to explore next and two runs of one defect on
167    /// different cases must count as one thing learned.
168    fn signatures(&self, into: &mut BTreeSet<String>) {
169        for (key, present) in [
170            ("core", self.core_changed.is_some()),
171            ("ybus", self.ybus.is_some()),
172            ("injection", self.injection.is_some()),
173            ("dc", self.dc_terminal.is_some()),
174        ] {
175            if present {
176                into.insert(key.to_string());
177            }
178        }
179        for path in &self.model_diffs {
180            into.insert(format!("path:{path}"));
181        }
182    }
183}
184
185/// One step of a walk: write the running network in a format, read it back,
186/// and say what moved.
187#[derive(Debug, Clone, Serialize, Deserialize)]
188pub struct Hop {
189    /// The format written at this step.
190    pub to: String,
191    pub warnings: Vec<String>,
192    pub failure: Option<String>,
193    /// Against the network this hop started from: the leg's own loss, the same
194    /// quantity [`super::compare`] measures, but from a walked input rather
195    /// than the pristine case.
196    pub leg: HopDelta,
197    /// Set when converting the origin straight to this format gives a
198    /// different network than arriving here along the walk. The chain's route
199    /// changed the destination, which no single leg can state.
200    pub path_dependent: Option<String>,
201    /// Whether that difference reaches an electrical property (counts, power,
202    /// `Y_bus`, DC terminals) rather than retained extras alone. Retention
203    /// honestly thins along a chain, so only the electrical kind is graded a
204    /// defect.
205    #[serde(default)]
206    pub path_dependent_electrical: bool,
207    /// Counts the origin had that are zero here. Not a defect on its own: it
208    /// says a later clean hop may be clean only because the table is empty.
209    pub emptied: Vec<String>,
210    /// Counts that were zero at the previous hop and are nonzero here.
211    pub resurrected: Vec<String>,
212    /// The earlier hop this format was last written at, when the walk revisits
213    /// one.
214    pub revisit_of: Option<usize>,
215    /// Set when that revisit produced a different network than the first
216    /// visit: conversion has not settled.
217    pub drift: Option<String>,
218    /// As [`path_dependent_electrical`](Self::path_dependent_electrical), for
219    /// the drift.
220    #[serde(default)]
221    pub drift_electrical: bool,
222}
223
224#[derive(Debug, Clone, Serialize, Deserialize)]
225pub struct Walk {
226    pub bucket: String,
227    /// The seed this walk was drawn with. Replays it exactly.
228    pub seed: u64,
229    pub origin: String,
230    pub hops: Vec<Hop>,
231}
232
233#[derive(Debug, Clone, Default, Serialize, Deserialize)]
234pub struct Walks {
235    pub walks: Vec<Walk>,
236    /// Buckets whose walks were cut short by the settle rule: the sampler
237    /// stopped finding there before the walk budget ran out.
238    pub settled_buckets: usize,
239}
240
241/// What one directed edge has taught the harness.
242#[derive(Debug, Clone, Default, Serialize, Deserialize)]
243pub struct EdgeMemory {
244    pub visits: usize,
245    /// Visits that added at least one signature.
246    pub novel: usize,
247    pub signatures: BTreeSet<String>,
248}
249
250/// The harness's memory of its own walks, persisted in the work directory.
251#[derive(Debug, Clone, Default, Serialize, Deserialize)]
252pub struct Ledger {
253    /// Keyed `"<from>><to>"`.
254    pub edges: BTreeMap<String, EdgeMemory>,
255    pub walks: usize,
256}
257
258impl Ledger {
259    fn key(from: &str, to: &str) -> String {
260        format!("{from}>{to}")
261    }
262
263    /// The sampler's preference for stepping `from → to`.
264    ///
265    /// Laplace smoothed so an unvisited edge scores 1 and a well worn one
266    /// decays toward, but never reaches, 0: an edge that stopped teaching
267    /// under one case may still have something to say under the next.
268    fn weight(&self, from: &str, to: &str) -> f64 {
269        self.edges
270            .get(&Self::key(from, to))
271            .map_or(1.0, |e| (1.0 + e.novel as f64) / (1.0 + e.visits as f64))
272    }
273
274    /// Record a visit. Returns whether it taught the edge anything.
275    fn learn(&mut self, from: &str, to: &str, signatures: &BTreeSet<String>) -> bool {
276        let entry = self.edges.entry(Self::key(from, to)).or_default();
277        entry.visits += 1;
278        let before = entry.signatures.len();
279        entry.signatures.extend(signatures.iter().cloned());
280        let novel = entry.signatures.len() > before;
281        if novel {
282            entry.novel += 1;
283        }
284        novel
285    }
286}
287
288/// xorshift64*, so a walk is reproducible from its seed with no dependency.
289struct Rng(u64);
290
291impl Rng {
292    fn new(seed: u64) -> Self {
293        // A zero state is a fixed point of the shift.
294        Self(seed | 1)
295    }
296
297    fn next_u64(&mut self) -> u64 {
298        self.0 ^= self.0 >> 12;
299        self.0 ^= self.0 << 25;
300        self.0 ^= self.0 >> 27;
301        self.0.wrapping_mul(0x2545_F491_4F6C_DD1D)
302    }
303
304    /// An index into `weights`, drawn proportionally. Falls back to the last
305    /// entry when every weight is zero, which the smoothing prevents anyway.
306    fn weighted(&mut self, weights: &[f64]) -> usize {
307        let total: f64 = weights.iter().sum();
308        if total <= 0.0 {
309            return weights.len().saturating_sub(1);
310        }
311        // 53 bits is the whole f64 mantissa, so the draw is uniform on [0, 1).
312        let unit = (self.next_u64() >> 11) as f64 / (1u64 << 53) as f64;
313        let mut cursor = unit * total;
314        for (i, w) in weights.iter().enumerate() {
315            cursor -= w;
316            if cursor < 0.0 {
317                return i;
318            }
319        }
320        weights.len() - 1
321    }
322}
323
324/// The running network of a walk, in whichever model the bucket holds.
325#[derive(Clone)]
326enum Net {
327    Balanced(BalancedNetwork),
328    Dist(MulticonductorNetwork),
329}
330
331impl Net {
332    fn delta(&self, after: &Net) -> HopDelta {
333        match (self, after) {
334            (Self::Balanced(a), Self::Balanced(b)) => HopDelta::of(a, b),
335            (Self::Dist(a), Self::Dist(b)) => HopDelta::of_dist(a, b),
336            // A walk never crosses domains; the alphabets are disjoint.
337            _ => HopDelta::default(),
338        }
339    }
340
341    /// Element tables `self` has that `after` does not.
342    fn emptied(&self, after: &Net) -> Vec<String> {
343        match (self, after) {
344            (Self::Balanced(a), Self::Balanced(b)) => {
345                let (a, b) = (
346                    invariants::transmission_core(a),
347                    invariants::transmission_core(b),
348                );
349                zeroed(&[
350                    ("branches", a.branches, b.branches),
351                    ("generators", a.generators, b.generators),
352                    ("loads", a.loads, b.loads),
353                    ("shunts", a.shunts, b.shunts),
354                ])
355            }
356            (Self::Dist(a), Self::Dist(b)) => {
357                let (a, b) = (
358                    invariants::distribution_core(a),
359                    invariants::distribution_core(b),
360                );
361                zeroed(&[
362                    ("buses", a.buses, b.buses),
363                    ("generators", a.generators, b.generators),
364                    ("loads", a.loads, b.loads),
365                    ("shunts", a.shunts, b.shunts),
366                ])
367            }
368            _ => Vec::new(),
369        }
370    }
371
372    /// Emit as `to` and read back, collecting both sides' warnings. A panic
373    /// on either side comes back as a message rather than unwinding.
374    fn convert(&self, to: &str, warnings: &mut Vec<String>) -> std::result::Result<Net, String> {
375        match self {
376            Self::Balanced(net) => {
377                let Some(target) = powerio_tx::format::parse_target_format(to) else {
378                    return Err(format!("no writer for {to}"));
379                };
380                let module = powerio_core::PioModule::new(net.clone());
381                let emission = match catch_panic(|| {
382                    crate::module_io::emit_balanced_module(
383                        &module,
384                        target,
385                        &powerio_tx::EmitOptions::default(),
386                    )
387                }) {
388                    Ok(Ok(emission)) => emission,
389                    Ok(Err(err)) => return Err(format!("emit: {err}")),
390                    Err(message) => return Err(format!("emit panicked: {message}")),
391                };
392                warnings.extend(emission.render_diagnostics());
393                match catch_panic(|| crate::module_io::load_balanced_memory(&emission.text, to)) {
394                    Ok(Ok(parsed)) => {
395                        warnings.extend(powerio_core::render_diagnostics(parsed.diagnostics()));
396                        Ok(Self::Balanced(parsed.into_value()))
397                    }
398                    Ok(Err(err)) => Err(format!("readback: {err}")),
399                    Err(message) => Err(format!("readback panicked: {message}")),
400                }
401            }
402            Self::Dist(net) => {
403                let Some(target) = powerio_dist::parse_dist_target_format(to) else {
404                    return Err(format!("no writer for {to}"));
405                };
406                let module = powerio_core::PioModule::new(net.clone());
407                let emission = match catch_panic(|| {
408                    crate::module_io::emit_multiconductor_module(&module, target)
409                }) {
410                    Ok(Ok(emission)) => emission,
411                    Ok(Err(err)) => return Err(format!("emit: {err}")),
412                    Err(message) => return Err(format!("emit panicked: {message}")),
413                };
414                warnings.extend(emission.render_diagnostics());
415                // A deck that pulls in other files cannot be read back from a
416                // string; see the same test in the pairwise compare.
417                if super::has_include(&emission.text) {
418                    return Err("emitted deck redirects to include files".to_string());
419                }
420                match catch_panic(|| {
421                    crate::module_io::load_multiconductor_memory(&emission.text, to)
422                }) {
423                    Ok(Ok(parsed)) => {
424                        warnings.extend(powerio_core::render_diagnostics(parsed.diagnostics()));
425                        Ok(Self::Dist(parsed.into_value()))
426                    }
427                    Ok(Err(err)) => Err(format!("readback: {err}")),
428                    Err(message) => Err(format!("readback panicked: {message}")),
429                }
430            }
431        }
432    }
433}
434
435fn zeroed(counts: &[(&str, usize, usize)]) -> Vec<String> {
436    counts
437        .iter()
438        .filter(|&&(_, had, has)| had > 0 && has == 0)
439        .map(|&(label, ..)| label.to_string())
440        .collect()
441}
442
443/// Walk every bucket, learning as it goes.
444///
445/// Each walk starts from a bucket member, draws `hops` formats by the ledger's
446/// novelty weights, and converts through them in turn. The run stops early
447/// when `settle` consecutive walks add nothing to the ledger.
448///
449/// # Errors
450///
451/// Fails when the work directory has no ingest output or cannot be written.
452pub fn walk(work: &Path, walks: usize, hops: usize, seed: u64, settle: usize) -> Result<Walks> {
453    let ingest: Ingest = super::read_json(&work.join(super::INGEST_FILE))?;
454    let ledger_path = work.join(LEDGER_FILE);
455    let mut ledger: Ledger = super::read_json(&ledger_path).unwrap_or_default();
456
457    let mut out = Walks::default();
458    let mut rng = Rng::new(seed);
459
460    for bucket in &ingest.buckets {
461        let Some((origin_format, origin)) = first_readable(bucket) else {
462            continue;
463        };
464        let alphabet: &[&str] = match bucket.domain {
465            Domain::Transmission => &ALPHABET,
466            Domain::Distribution => &DIST_ALPHABET,
467        };
468        // Direct conversions of this bucket's origin, computed at most once
469        // per format across all its walks.
470        let mut direct: BTreeMap<String, Option<Net>> = BTreeMap::new();
471        // The settle rule is per bucket: each bucket is a different case, and
472        // one going quiet says nothing about what the next can teach. A global
473        // streak would let a few quiet buckets starve every one after them.
474        let mut dry = 0usize;
475        for _ in 0..walks {
476            if dry >= settle {
477                break;
478            }
479            let walk_seed = rng.next_u64();
480            let path = draw_path(
481                &ledger,
482                alphabet,
483                &origin_format,
484                hops,
485                &mut Rng::new(walk_seed),
486            );
487            let (walk, learned) = run_walk(
488                bucket.id.clone(),
489                walk_seed,
490                &origin_format,
491                &origin,
492                &path,
493                &mut ledger,
494                &mut direct,
495            );
496            dry = if learned { 0 } else { dry + 1 };
497            ledger.walks += 1;
498            out.walks.push(walk);
499        }
500        if dry >= settle {
501            out.settled_buckets += 1;
502        }
503    }
504
505    super::write_json(&ledger_path, &ledger)?;
506    super::write_json(&work.join(WALK_FILE), &out)?;
507    Ok(out)
508}
509
510/// The first bucket member that reads in the bucket's own model, with its
511/// format.
512///
513/// The retained source text is cleared. A walk measures conversions, and a
514/// network that kept its source byte-echoes any write back to its own format
515/// — which would make the direct baseline for that format the identity and
516/// flag every route to it as path dependent over nothing but retention.
517fn first_readable(bucket: &Bucket) -> Option<(String, Net)> {
518    bucket.members.iter().find_map(|m| {
519        let net = match bucket.domain {
520            Domain::Transmission => {
521                let net = balanced(&m.path)?;
522                Net::Balanced(net)
523            }
524            Domain::Distribution => {
525                let mut net = multiconductor(&m.path)?;
526                *net.source_format_mut() = None;
527                Net::Dist(net)
528            }
529        };
530        Some((m.format.clone(), net))
531    })
532}
533
534/// Draw a path of `hops` formats, never stepping to the format just written
535/// (a same format step is the round trip [`super::compare`] already runs).
536fn draw_path(
537    ledger: &Ledger,
538    alphabet: &[&str],
539    origin: &str,
540    hops: usize,
541    rng: &mut Rng,
542) -> Vec<String> {
543    let mut path = Vec::with_capacity(hops);
544    let mut here = origin.to_string();
545    for _ in 0..hops {
546        let choices: Vec<&str> = alphabet
547            .iter()
548            .copied()
549            .filter(|f| *f != here.as_str())
550            .collect();
551        let weights: Vec<f64> = choices.iter().map(|to| ledger.weight(&here, to)).collect();
552        let pick = choices[rng.weighted(&weights)].to_string();
553        here.clone_from(&pick);
554        path.push(pick);
555    }
556    path
557}
558
559/// Convert `origin` along `path`, recording every hop. Returns the walk and
560/// whether it taught the ledger anything.
561fn run_walk(
562    bucket: String,
563    seed: u64,
564    origin_format: &str,
565    origin: &Net,
566    path: &[String],
567    ledger: &mut Ledger,
568    direct: &mut BTreeMap<String, Option<Net>>,
569) -> (Walk, bool) {
570    let mut walk = Walk {
571        bucket,
572        seed,
573        origin: origin_format.to_string(),
574        hops: Vec::with_capacity(path.len()),
575    };
576    let mut learned = false;
577    let mut current = origin.clone();
578    let mut here = origin_format.to_string();
579    // Every network the walk has produced, by the format that produced it, so
580    // a revisit can be compared against the first pass.
581    let mut seen: Vec<(String, Net)> = Vec::new();
582
583    for to in path {
584        let mut hop = Hop {
585            to: to.clone(),
586            warnings: Vec::new(),
587            failure: None,
588            leg: HopDelta::default(),
589            path_dependent: None,
590            path_dependent_electrical: false,
591            emptied: Vec::new(),
592            resurrected: Vec::new(),
593            revisit_of: None,
594            drift: None,
595            drift_electrical: false,
596        };
597        let next = match current.convert(to, &mut hop.warnings) {
598            Ok(net) => net,
599            Err(message) => {
600                // A failed hop still teaches its edge, or a crash found once
601                // would be re-found on every later run.
602                let mut signatures = BTreeSet::new();
603                signatures.insert(format!("fail:{}", warning_shape(&message)));
604                learned |= ledger.learn(&here, to, &signatures);
605                hop.failure = Some(message);
606                walk.hops.push(hop);
607                // Nothing to carry forward.
608                break;
609            }
610        };
611
612        hop.leg = current.delta(&next);
613        hop.emptied = origin.emptied(&next);
614        hop.resurrected = next.emptied(&current);
615
616        if let Some(index) = seen.iter().position(|(f, _)| f == to) {
617            hop.revisit_of = Some(index);
618            let delta = seen[index].1.delta(&next);
619            if !delta.is_empty() {
620                hop.drift_electrical = delta.electrical();
621                hop.drift = Some(describe(&delta));
622            }
623        }
624
625        // Would converting the origin straight here have landed in the same
626        // place? Only asked past the first hop, where the walk and the direct
627        // conversion are the same thing. The direct result depends only on the
628        // origin and the format, so it is computed once per bucket however
629        // many walks and hops ask.
630        if !seen.is_empty() {
631            let baseline = direct
632                .entry(to.clone())
633                .or_insert_with(|| origin.convert(to, &mut Vec::new()).ok());
634            if let Some(direct_net) = baseline {
635                let delta = direct_net.delta(&next);
636                if !delta.is_empty() {
637                    hop.path_dependent_electrical = delta.electrical();
638                    hop.path_dependent = Some(describe(&delta));
639                }
640            }
641        }
642
643        let mut signatures = BTreeSet::new();
644        hop.leg.signatures(&mut signatures);
645        for warning in &hop.warnings {
646            signatures.insert(format!("warn:{}", warning_shape(warning)));
647        }
648        for (code, present) in [
649            ("drift", hop.drift.is_some()),
650            ("path-dependent", hop.path_dependent.is_some()),
651            ("resurrection", !hop.resurrected.is_empty()),
652        ] {
653            if present {
654                signatures.insert(code.to_string());
655            }
656        }
657        learned |= ledger.learn(&here, to, &signatures);
658
659        seen.push((to.clone(), next.clone()));
660        here.clone_from(to);
661        current = next;
662        walk.hops.push(hop);
663    }
664
665    (walk, learned)
666}
667
668/// A delta as one line, for the fields a walk finding names.
669fn describe(delta: &HopDelta) -> String {
670    let mut parts = Vec::new();
671    for value in [
672        delta.core_changed.as_ref(),
673        delta.ybus.as_ref(),
674        delta.injection.as_ref(),
675        delta.dc_terminal.as_ref(),
676    ]
677    .into_iter()
678    .flatten()
679    {
680        parts.push(value.clone());
681    }
682    if !delta.model_diffs.is_empty() {
683        parts.push(delta.model_diffs.join(", "));
684    }
685    parts.join("; ")
686}
687
688/// A warning reduced to its shape: the first eight tokens, each masked to `_`
689/// unless it is plain lowercase prose. The ledger counts kinds of warning, and
690/// an element name or a count in the text would make every case look like a
691/// new lesson — the first corpus run minted 2551 "lessons" on one edge from
692/// nineteen spellings of one warning about line names.
693///
694/// A token survives when every character is a lowercase letter or the
695/// punctuation warning prose uses around it. Anything else — digits, an
696/// element name's capitals, a quoted value — masks. Uppercase powerio
697/// vocabulary (MATPOWER, PSS/E) masks with them, which costs nothing: the
698/// masked token is in a fixed position, so the shape stays distinct.
699fn warning_shape(warning: &str) -> String {
700    warning
701        .split_whitespace()
702        .take(8)
703        .map(|w| {
704            let prose = w
705                .chars()
706                .all(|c| c.is_ascii_lowercase() || "()[]{}.,:;`-_/".contains(c));
707            if prose { w } else { "_" }
708        })
709        .collect::<Vec<_>>()
710        .join(" ")
711}
712
713#[cfg(test)]
714mod tests {
715    use super::*;
716
717    #[test]
718    fn a_seed_replays_its_path() {
719        let ledger = Ledger::default();
720        let a = draw_path(&ledger, &ALPHABET, "matpower", 6, &mut Rng::new(7));
721        let b = draw_path(&ledger, &ALPHABET, "matpower", 6, &mut Rng::new(7));
722        assert_eq!(a, b);
723        assert_eq!(a.len(), 6);
724        assert_ne!(
725            a,
726            draw_path(&ledger, &ALPHABET, "matpower", 6, &mut Rng::new(8))
727        );
728    }
729
730    #[test]
731    fn a_path_never_steps_to_the_format_it_is_already_in() {
732        let ledger = Ledger::default();
733        for alphabet in [&ALPHABET[..], &DIST_ALPHABET[..]] {
734            let path = draw_path(&ledger, alphabet, alphabet[0], 40, &mut Rng::new(3));
735            assert_ne!(path[0], alphabet[0]);
736            assert!(
737                path.windows(2).all(|w| w[0] != w[1]),
738                "a same-format step is the round trip compare already runs: {path:?}"
739            );
740        }
741    }
742
743    #[test]
744    fn the_ledger_prefers_an_edge_it_has_not_walked() {
745        let mut ledger = Ledger::default();
746        let nothing = BTreeSet::new();
747        // Ten visits that taught nothing drive the weight down; an untouched
748        // edge stays at 1.
749        for _ in 0..10 {
750            assert!(!ledger.learn("matpower", "psse", &nothing));
751        }
752        assert!(ledger.weight("matpower", "psse") < 0.1);
753        assert!((ledger.weight("matpower", "egret-json") - 1.0).abs() < f64::EPSILON);
754        // Never zero: an edge that taught nothing under one case may still
755        // have something to say under the next.
756        assert!(ledger.weight("matpower", "psse") > 0.0);
757    }
758
759    #[test]
760    fn a_visit_that_teaches_is_novel_once() {
761        let mut ledger = Ledger::default();
762        let lesson: BTreeSet<String> = ["path:.loads[#].p".to_string()].into_iter().collect();
763        assert!(ledger.learn("psse", "matpower", &lesson));
764        assert!(
765            !ledger.learn("psse", "matpower", &lesson),
766            "the same lesson twice is not a second lesson"
767        );
768        let edge = &ledger.edges["psse>matpower"];
769        assert_eq!((edge.visits, edge.novel), (2, 1));
770    }
771
772    #[test]
773    fn a_warning_shape_drops_what_makes_one_warning_look_like_many() {
774        // Counts and element names mask positionally; the prose around them
775        // stays, so the shape is stable across cases and still distinct
776        // between warnings.
777        assert_eq!(
778            warning_shape("3 switch(es) dropped: MATPOWER has no switch table"),
779            "_ switch(es) dropped: _ has no switch table"
780        );
781        for name in ["L100", "L101", "SourceBus"] {
782            assert_eq!(
783                warning_shape(&format!("line {name}: `units` has no place")),
784                "line _ `units` has no place"
785            );
786        }
787        assert_ne!(
788            warning_shape("bus RG60: location has no place"),
789            warning_shape("line L100: `units` has no place")
790        );
791    }
792}