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