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