Skip to main content

rakata_formats/
gff_compare.rs

1//! Comparing a GFF tree against the one it was derived from.
2//!
3//! A typed view reads a GFF, models some of it, and writes it back. Two
4//! questions decide whether that was faithful, and neither can be answered by
5//! looking at the view alone:
6//!
7//! - Did every field it rewrote keep the value the original had?
8//! - Did it write a label the original never had?
9//!
10//! Both are pure tree work: which labels are present, and whether the values
11//! under them match. Nothing here knows what a module or a creature is, which
12//! is why it sits in this crate rather than above it.
13//!
14//! Both the test suite and `vanilla-inspector` use these. That is the point:
15//! if the tool and the tests grew separate implementations, the tool's verdict
16//! would quietly stop meaning what the tests' verdict means, which is the same
17//! parallel-definition drift that has produced real defects here before.
18//!
19//! ## Why a nested struct's id is not compared
20//!
21//! Such a comparison has no subject. A nested struct is reached by label
22//! through `CResGFF::GetStructFromStruct`, which builds its result from the
23//! field's `data_or_data_offset`, the struct's *index* rather than its id,
24//! and the whole class hands back a wrapper holding only that index. No caller
25//! outside it ever holds a pointer it could read an id from. Of the class's
26//! sixty-four methods exactly one reads a struct's `id`, and it is not on any
27//! path that resolves a struct by label.
28//!
29//! So the value that comparison reported on cannot be read by the code that
30//! reaches these structs, and every difference it found was about nothing.
31//! Three sat in the output permanently, which trains a reader to skim a guard
32//! that is right the rest of the time.
33//!
34//! A *list element's* id is a different question and a live one: several
35//! loaders test it against a per-list constant and silently skip an element
36//! that does not match. Nothing here covers that, deliberately -- pairing
37//! elements between two trees is not a tree question, since the rewrite's
38//! order is the view's. `vanilla-inspector`'s `validate struct-ids` compares
39//! them against the schema and against both corpora instead.
40//!
41//! The closure is within `CResGFF`, which is the only GFF parser found across
42//! the tracing done so far rather than one proven unique against the whole
43//! binary. If a second reader exists, the exposure is writing `0` where
44//! vanilla writes something and nothing reads it, which is the risk already
45//! taken on `CameraList` and the two encounter-nested lists.
46//!
47//! ## What this does not decide
48//!
49//! Whether a difference is a defect. A view is a projection and is entitled to
50//! drop fields, and a label written but absent from every file may be a field
51//! the engine omits at its default rather than a wrong label. The caller
52//! supplies that judgement; this module supplies the observation.
53
54use std::collections::{BTreeMap, BTreeSet};
55
56use crate::gff::{GffStruct, GffValue};
57use crate::schema::{gff_value_type, GffType};
58use rakata_core::StrRef;
59
60/// One field whose value changed between the original and the rewrite.
61#[derive(Debug, Clone, PartialEq)]
62pub struct ValueDifference {
63    /// Label the difference was found under. Nested fields report the label
64    /// alone rather than a path, since GFF labels are the unit a reader looks
65    /// a field up by.
66    pub label: String,
67    /// Value the original carried.
68    pub before: GffValue,
69    /// Value the rewrite carried.
70    pub after: GffValue,
71}
72
73/// Reports every field the rewrite changed the value of.
74///
75/// Walks the rewritten tree and compares each field against the original's
76/// field of the same label, recursing into nested structs. A label the
77/// original does not carry is skipped here; that case is what [`LabelCensus`]
78/// exists for.
79///
80/// Lists are skipped entirely. Element order in a rewrite is the view's, not
81/// the file's, so a positional comparison reports differences that are not
82/// defects. A caller that models a list's contents compares them directly.
83pub fn value_differences(original: &GffStruct, rewritten: &GffStruct) -> Vec<ValueDifference> {
84    let mut out = Vec::new();
85    collect_differences(original, rewritten, &mut out);
86    out
87}
88
89fn collect_differences(
90    original: &GffStruct,
91    rewritten: &GffStruct,
92    out: &mut Vec<ValueDifference>,
93) {
94    for field in &rewritten.fields {
95        let Some(before) = original.field(field.label.as_str()) else {
96            continue;
97        };
98        match (before, &field.value) {
99            (GffValue::List(_), _) | (_, GffValue::List(_)) => {}
100            (GffValue::Struct(before_inner), GffValue::Struct(after_inner)) => {
101                collect_differences(before_inner, after_inner, out);
102            }
103            _ if *before != field.value => out.push(ValueDifference {
104                label: field.label.as_str().to_string(),
105                before: before.clone(),
106                after: field.value.clone(),
107            }),
108            _ => {}
109        }
110    }
111}
112
113/// Joins a parent path and a label into the dotted form the reports use.
114/// Top-level fields are bare labels; list elements carry `[]` on the list.
115fn child_path(parent: &str, label: &str) -> String {
116    if parent.is_empty() {
117        label.to_string()
118    } else {
119        format!("{parent}.{label}")
120    }
121}
122
123/// Collects every field path in a struct tree, nested structs and lists
124/// included.
125///
126/// Paths rather than bare labels, because a label alone cannot distinguish a
127/// field the corpus holds at one level from the same label a view reads at
128/// another. That difference is a defect neither a value comparison nor a
129/// symmetry check can see, since a view reading the wrong parent finds
130/// nothing there and writes nothing back, which is self-consistent.
131fn collect_paths(structure: &GffStruct, parent: &str, out: &mut BTreeSet<String>) {
132    for field in &structure.fields {
133        let path = child_path(parent, field.label.as_str());
134        out.insert(path.clone());
135        match &field.value {
136            GffValue::Struct(inner) => collect_paths(inner, &path, out),
137            GffValue::List(elements) => {
138                let element_path = format!("{path}[]");
139                for element in elements {
140                    collect_paths(element, &element_path, out);
141                }
142            }
143            _ => {}
144        }
145    }
146}
147
148/// Collects every label in a struct tree alongside the types it was seen
149/// carrying. A label may hold different types in different files, so this
150/// accumulates a set rather than overwriting.
151fn collect_typed_paths(
152    structure: &GffStruct,
153    parent: &str,
154    out: &mut BTreeMap<String, (BTreeSet<GffType>, bool)>,
155) {
156    for field in &structure.fields {
157        let path = child_path(parent, field.label.as_str());
158        let entry = out.entry(path.clone()).or_default();
159        entry.0.insert(gff_value_type(&field.value));
160        entry.1 |= is_informative(&field.value);
161        match &field.value {
162            GffValue::Struct(inner) => collect_typed_paths(inner, &path, out),
163            GffValue::List(elements) => {
164                let element_path = format!("{path}[]");
165                for element in elements {
166                    collect_typed_paths(element, &element_path, out);
167                }
168            }
169            _ => {}
170        }
171    }
172}
173
174/// The leaf label of a dotted path.
175fn leaf_of(path: &str) -> &str {
176    path.rsplit('.').next().unwrap_or(path)
177}
178
179/// Folds one file's labels into the running observation, counting each label
180/// once per file rather than once per occurrence.
181fn merge_observation(
182    into: &mut BTreeMap<String, LabelObservation>,
183    from: BTreeMap<String, (BTreeSet<GffType>, bool)>,
184) {
185    for (label, (types, informative)) in from {
186        let entry = into.entry(label).or_default();
187        entry.types.extend(types);
188        entry.files += 1;
189        if informative {
190            entry.files_with_value += 1;
191        }
192    }
193}
194
195/// Which labels a view writes that no file was seen to contain, accumulated
196/// over a corpus.
197///
198/// [`value_differences`] cannot see a label the original lacks: it walks the
199/// rewrite and skips anything the original has no field for. That means a
200/// label renamed on both the read and the write side passes it unremarked,
201/// which is the self-consistent case a faithfulness check exists to catch.
202/// Comparing what gets written against everything the corpus was ever seen to
203/// contain is what closes that.
204///
205/// This has to accumulate across files rather than judge one at a time,
206/// because any single file may legitimately omit a field another carries.
207#[derive(Debug, Clone, Default)]
208pub struct LabelCensus {
209    written: BTreeSet<String>,
210    seen: BTreeMap<String, LabelObservation>,
211    files: usize,
212}
213
214/// One leaf label the view models at some of its corpus paths and not others.
215///
216/// Produced by [`LabelCensus::partially_modelled_leaves`].
217#[derive(Debug, Clone, PartialEq, Eq)]
218pub struct LeafPathSplit<'a> {
219    /// The label shared by every path below.
220    pub leaf: &'a str,
221    /// Paths the view reads and writes.
222    pub modelled: Vec<&'a str>,
223    /// Paths the corpus carries that the view does not, with what was seen at
224    /// each, so the reader can weigh them without a second pass.
225    pub unmodelled: Vec<(&'a str, &'a LabelObservation)>,
226}
227
228/// What a corpus was seen to carry under one label.
229#[derive(Debug, Clone, Default, PartialEq, Eq)]
230pub struct LabelObservation {
231    /// Types the label was seen holding. A label may hold different types in
232    /// different files, so this is a set rather than one value.
233    pub types: BTreeSet<GffType>,
234    /// How many files carried the label.
235    ///
236    /// This is what turns a label list into a priority order. A label in
237    /// every file and a label in one are very different propositions, and
238    /// without the count they read identically.
239    pub files: usize,
240    /// How many files carried the label holding something other than its
241    /// zero value.
242    ///
243    /// Prevalence alone misranks: a label present in every file and empty in
244    /// all of them carries no information, while a label in a handful of
245    /// files holding real values does. Ranking needs both.
246    ///
247    /// # This orders work; it does not decide what to model
248    ///
249    /// Neither this count nor the file count predicts whether a field is worth
250    /// modelling, and both have been wrong in both directions. Only what the
251    /// loader does with a field settles it; these counts are for deciding what
252    /// to look at first. `docs/src/architecture.md` works through the three
253    /// ways the counts mislead, with a real field behind each.
254    pub files_with_value: usize,
255}
256
257/// Whether a value carries anything beyond the type's zero.
258///
259/// Deliberately shallow. This is a triage signal for deciding which labels
260/// are worth investigating, not a claim about what the engine treats as
261/// meaningful: a field's real default may be something other than zero, and
262/// only an audit of the loader can say.
263fn is_informative(value: &GffValue) -> bool {
264    match value {
265        GffValue::UInt8(v) => *v != 0,
266        GffValue::Int8(v) => *v != 0,
267        GffValue::UInt16(v) => *v != 0,
268        GffValue::Int16(v) => *v != 0,
269        GffValue::UInt32(v) => *v != 0,
270        GffValue::Int32(v) => *v != 0,
271        GffValue::UInt64(v) => *v != 0,
272        GffValue::Int64(v) => *v != 0,
273        GffValue::Single(v) => *v != 0.0,
274        GffValue::Double(v) => *v != 0.0,
275        GffValue::String(v) => !v.is_empty(),
276        GffValue::ResRef(v) => !v.as_bytes().is_empty(),
277        GffValue::LocalizedString(v) => {
278            v.string_ref != StrRef::invalid() || !v.substrings.is_empty()
279        }
280        GffValue::Binary(v) => v.iter().any(|byte| *byte != 0),
281        GffValue::Struct(v) => !v.fields.is_empty(),
282        GffValue::List(v) => !v.is_empty(),
283        GffValue::Vector3(v) => v.iter().any(|f| *f != 0.0),
284        GffValue::Vector4(v) => v.iter().any(|f| *f != 0.0),
285    }
286}
287
288/// A family of labels that differ only by letter case.
289///
290/// GFF label lookup is case-sensitive, so a view reading one spelling sees
291/// nothing in a file that uses another. Vanilla does ship such families, and
292/// the crate already carries a hand-written tolerant read for one of them, so
293/// the question is how many more there are rather than whether any exist.
294#[derive(Debug, Clone, PartialEq, Eq)]
295pub struct CaseVariantGroup {
296    /// Each spelling found in the corpus, with what was seen under it.
297    pub spellings: Vec<(String, LabelObservation)>,
298    /// The spellings the view writes. A family where this holds fewer than
299    /// every spelling is one the view reads in only some of its forms.
300    pub written: BTreeSet<String>,
301}
302
303/// Whether a corpus can decide the invented-label question at all.
304#[derive(Debug, Clone, PartialEq, Eq)]
305pub enum InventedLabels {
306    /// The corpus held no files, so everything a view writes is trivially
307    /// "absent from the corpus" and the question is meaningless.
308    ///
309    /// This is derived from the corpus rather than configured, so it stops
310    /// being reported the moment a corpus with files is handed in, and it
311    /// cannot go stale the way a caller-set flag would.
312    Undecidable,
313    /// Labels written that no file in the corpus contained.
314    ///
315    /// Not a defect list. A view may write a field the engine omits when it
316    /// holds its default, and it may write a field belonging to a form the
317    /// corpus does not include. Judging those is the caller's.
318    Labels(Vec<String>),
319}
320
321impl LabelCensus {
322    /// Creates an empty census.
323    pub fn new() -> Self {
324        Self::default()
325    }
326
327    /// Records one file's original tree and the tree a view rewrote from it.
328    pub fn observe(&mut self, original: &GffStruct, rewritten: &GffStruct) {
329        let mut in_this_file = BTreeMap::new();
330        collect_typed_paths(original, "", &mut in_this_file);
331        merge_observation(&mut self.seen, in_this_file);
332        collect_paths(rewritten, "", &mut self.written);
333        self.files += 1;
334    }
335
336    /// Number of files observed.
337    pub fn files(&self) -> usize {
338        self.files
339    }
340
341    /// Whether any file in the corpus carried this path.
342    ///
343    /// Separate from [`Self::invented`] because the two answer questions that
344    /// diverge the moment a view stops writing something. `invented` is a
345    /// property of the view and the corpus together, so a label the view no
346    /// longer writes drops out of it, and reading that as "the corpus does not
347    /// carry it" is backwards. This asks the corpus alone, which is the fact a
348    /// decision to stop writing a label has to rest on and keep resting on.
349    pub fn corpus_carries(&self, path: &str) -> bool {
350        self.seen.contains_key(path)
351    }
352
353    /// Labels written that the corpus never contained, or
354    /// [`InventedLabels::Undecidable`] when there was nothing to compare
355    /// against.
356    pub fn invented(&self) -> InventedLabels {
357        if self.files == 0 {
358            return InventedLabels::Undecidable;
359        }
360        InventedLabels::Labels(
361            self.written
362                .iter()
363                .filter(|label| !self.seen.contains_key(*label))
364                .cloned()
365                .collect(),
366        )
367    }
368
369    /// Families of labels in the corpus differing only by letter case.
370    ///
371    /// Returns only families with more than one spelling, since a lone
372    /// spelling has nothing to disagree with. What makes a family worth
373    /// attention is when [`CaseVariantGroup::written`] does not cover every
374    /// spelling: the view then reads some files' copy of that field and not
375    /// others'.
376    pub fn case_variants(&self) -> Vec<CaseVariantGroup> {
377        let mut families: BTreeMap<String, Vec<&String>> = BTreeMap::new();
378        for label in self.seen.keys() {
379            families
380                .entry(label.to_lowercase())
381                .or_default()
382                .push(label);
383        }
384
385        families
386            .into_values()
387            .filter(|spellings| spellings.len() > 1)
388            .map(|spellings| CaseVariantGroup {
389                written: spellings
390                    .iter()
391                    .filter(|label| self.written.contains(**label))
392                    .map(|label| (*label).clone())
393                    .collect(),
394                spellings: spellings
395                    .into_iter()
396                    .map(|label| {
397                        (
398                            label.clone(),
399                            self.seen.get(label).cloned().unwrap_or_default(),
400                        )
401                    })
402                    .collect(),
403            })
404            .collect()
405    }
406
407    /// Field paths whose leaf label appears in the corpus at one level and in
408    /// the view's output at another.
409    ///
410    /// This is the wiring-mismatch check, and it exists because nothing else
411    /// catches the shape. A view that reads a field off the wrong parent finds
412    /// nothing there, so it writes nothing back: the round-trip is stable, the
413    /// values that do coincide all match, and every field involved is
414    /// modelled. It looks like a projection dropping a field it never wanted.
415    ///
416    /// Reported as `(leaf, path_in_corpus, path_written)`. A leaf appearing at
417    /// several levels legitimately, which nested formats do, yields one entry
418    /// per crossing pair and is for a reader to judge rather than a verdict.
419    pub fn misplaced(&self) -> Vec<(&str, &str, &str)> {
420        let mut out = Vec::new();
421        for seen_path in self.seen.keys() {
422            if self.written.contains(seen_path) {
423                continue;
424            }
425            let leaf = leaf_of(seen_path);
426            for written_path in &self.written {
427                if written_path != seen_path
428                    && leaf_of(written_path) == leaf
429                    && !self.seen.contains_key(written_path)
430                {
431                    out.push((leaf, seen_path.as_str(), written_path.as_str()));
432                }
433            }
434        }
435        out
436    }
437
438    /// Leaf labels the corpus carries at several paths, where the view models
439    /// some of those paths and not others.
440    ///
441    /// The blind spot this closes is a reviewer's, not a tool's. Scanning a
442    /// list of unmodelled labels, a leaf that also appears somewhere modelled
443    /// reads as covered: the eye matches the name, finds it in the view, and
444    /// moves on. `OnHeartbeat` survived exactly that way, modelled on the ARE
445    /// root and dropped inside every nested minigame `Scripts` struct, and the
446    /// inventory that was supposed to surface it counted the root's and called
447    /// the label done.
448    ///
449    /// Distinct from [`Self::misplaced`], which asks whether a path the view
450    /// writes exists in any file. Here every path involved is real; the
451    /// question is only whether the view covers all of them. A leaf covered at
452    /// every path it appears at is not reported, and neither is one covered
453    /// nowhere, since [`Self::unmodelled`] already lists that.
454    ///
455    /// A mixed result is not automatically a defect. Formats reuse labels at
456    /// unrelated levels, and a projection may want one and not the other. It
457    /// is a prompt to decide, with the paths side by side.
458    pub fn partially_modelled_leaves(&self) -> Vec<LeafPathSplit<'_>> {
459        let mut by_leaf: BTreeMap<&str, Vec<&String>> = BTreeMap::new();
460        for path in self.seen.keys() {
461            by_leaf.entry(leaf_of(path)).or_default().push(path);
462        }
463
464        by_leaf
465            .into_iter()
466            .filter_map(|(leaf, paths)| {
467                let (modelled, unmodelled): (Vec<_>, Vec<_>) = paths
468                    .into_iter()
469                    .partition(|path| self.written.contains(*path));
470                if modelled.is_empty() || unmodelled.is_empty() {
471                    return None;
472                }
473                Some(LeafPathSplit {
474                    leaf,
475                    modelled: modelled.into_iter().map(String::as_str).collect(),
476                    unmodelled: unmodelled
477                        .into_iter()
478                        .map(|path| (path.as_str(), &self.seen[path]))
479                        .collect(),
480                })
481            })
482            .collect()
483    }
484
485    /// Field paths the corpus contained that the view never writes, each with
486    /// what was observed under it.
487    ///
488    /// Dropping a field is what a projection does, so this is a starting
489    /// point for deciding what is worth modelling rather than a defect list.
490    /// The types come along because they are what makes the follow-on
491    /// modelling cheap: a label alone still needs looking up.
492    pub fn unmodelled(&self) -> Vec<(&str, &LabelObservation)> {
493        self.seen
494            .iter()
495            .filter(|(label, _)| !self.written.contains(*label))
496            .map(|(label, observation)| (label.as_str(), observation))
497            .collect()
498    }
499}
500
501/// Whether one schema serves one reading of a file or two.
502///
503/// Decides how a corpus's silence about a label should be read, which is not
504/// something the census can work out for itself and has now been gotten wrong
505/// twice by callers deciding it inline.
506#[derive(Debug, Clone, Copy, PartialEq, Eq)]
507pub enum SchemaShape {
508    /// One reader, so a label absent from every file is a fallback that runs
509    /// on every file.
510    ///
511    /// UTE's runtime-tracking block is the case that pins this: twelve fields
512    /// absent from all 133 vanilla `.ute` files, and live on all 133.
513    SingleForm,
514    /// Two readings picked at read time, with the corpora separating them.
515    ///
516    /// A label the corpus never carries belongs to the other reading, so its
517    /// fallback is not one this corpus runs. GIT is the case: a static
518    /// placement carries `TemplateResRef` and a saved snapshot does not, and
519    /// scoring the label against saved objects rates it 100% absent over tens
520    /// of thousands of objects that never ask for it.
521    Union,
522}
523
524/// How often each path was absent, counted per occurrence rather than per
525/// file.
526///
527/// [`LabelCensus`] counts a label once per file, which answers "does the
528/// corpus carry this at all". It cannot answer how often a reader's absent
529/// fallback runs, because a label inside a list is present on some elements
530/// and absent on others and a file-level count collapses that to one bit. A
531/// corpus of two hundred creatures where one omits `ScriptHeartbeat` and a
532/// corpus where all two hundred omit it are the same file either way.
533///
534/// So the denominator here is the number of structs visited at the path's
535/// parent: the number of times the reader ran at all. A field on a list
536/// element gets one occasion per element.
537///
538/// Paths are label sequences with no list marker, so a schema entry looks up
539/// directly by the path a walk builds for it. A list and a struct both become
540/// the parent path of whatever hangs beneath them, which is the same nesting
541/// [`Shape`](crate::schema::Shape) uses for a container's children.
542#[derive(Debug, Clone, Default)]
543pub struct AbsenceCensus {
544    containers: BTreeMap<String, usize>,
545    present: BTreeMap<String, usize>,
546}
547
548/// How often one path's reader fallback would fire across a corpus.
549///
550/// Produced by [`AbsenceCensus::rate`].
551#[derive(Debug, Clone, Copy, PartialEq, Eq)]
552pub struct AbsenceRate {
553    /// Structs visited at the path's parent: how many times a reader at this
554    /// path would have run.
555    pub occasions: usize,
556    /// How many of those carried the label.
557    pub present: usize,
558}
559
560impl AbsenceRate {
561    /// Occasions where the label was missing and the fallback would fire.
562    #[must_use]
563    pub fn absent(&self) -> usize {
564        self.occasions.saturating_sub(self.present)
565    }
566
567    /// Absent occasions as a fraction of all occasions, `0.0` when the path
568    /// was never reached.
569    #[must_use]
570    #[allow(clippy::as_conversions)]
571    // CLIPPY: usize -> f64 for a reporting ratio. A corpus large enough to
572    // lose precision here would be several petabytes of GFF.
573    pub fn fraction(&self) -> f64 {
574        if self.occasions == 0 {
575            return 0.0;
576        }
577        self.absent() as f64 / self.occasions as f64
578    }
579}
580
581impl AbsenceCensus {
582    /// An empty census.
583    #[must_use]
584    pub fn new() -> Self {
585        Self::default()
586    }
587
588    /// Folds one file's tree into the running counts.
589    pub fn observe(&mut self, root: &GffStruct) {
590        self.visit(root, String::new());
591    }
592
593    fn visit(&mut self, structure: &GffStruct, path: String) {
594        *self.containers.entry(path.clone()).or_default() += 1;
595        for field in &structure.fields {
596            let child = child_path(&path, field.label.as_str());
597            *self.present.entry(child.clone()).or_default() += 1;
598            match &field.value {
599                GffValue::Struct(inner) => self.visit(inner, child),
600                GffValue::List(elements) => {
601                    for element in elements {
602                        self.visit(element, child.clone());
603                    }
604                }
605                _ => {}
606            }
607        }
608    }
609
610    /// How often `path` was absent, or `None` when the corpus never reached
611    /// its parent.
612    ///
613    /// `None` is not the same as "always present" and not the same as "always
614    /// absent". It means no file in the corpus held a struct this field could
615    /// have appeared on, so the corpus says nothing about the field either
616    /// way. Ranking that as a 0% firing rate would read as evidence the
617    /// fallback never runs, which is exactly backwards.
618    #[must_use]
619    pub fn rate(&self, path: &[&str]) -> Option<AbsenceRate> {
620        let (leaf, parents) = path.split_last()?;
621        let parent_path = parents.join(".");
622        let occasions = *self.containers.get(&parent_path)?;
623        let full = child_path(&parent_path, leaf);
624        Some(AbsenceRate {
625            occasions,
626            present: self.present.get(&full).copied().unwrap_or(0),
627        })
628    }
629
630    /// How often `path`'s fallback fires in this corpus, or `None` when this
631    /// corpus cannot speak to it.
632    ///
633    /// `rate` answers "how often was the label missing here", which is not the
634    /// same question. A missing label only means a fallback ran if this
635    /// corpus's reader was the one looking for it, and under
636    /// [`SchemaShape::Union`] it may not have been. Callers deciding that
637    /// inline have gotten it wrong in both directions:
638    ///
639    /// - Treating a union schema as single-form counts every static-form label
640    ///   as firing across the entire saved corpus, which put six
641    ///   `TemplateResRef` rows at the head of a ranking on numbers describing
642    ///   a reader that never ran.
643    /// - Treating a single-form schema as a union drops every field the corpus
644    ///   universally omits, which is exactly the set whose fallback runs every
645    ///   time and therefore matters most.
646    ///
647    /// So the shape is a parameter rather than a heuristic, and this is the
648    /// only place the rule is written down.
649    #[must_use]
650    pub fn firing_rate(&self, path: &[&str], shape: SchemaShape) -> Option<AbsenceRate> {
651        let rate = self.rate(path)?;
652        match shape {
653            SchemaShape::SingleForm => Some(rate),
654            SchemaShape::Union if rate.present > 0 => Some(rate),
655            SchemaShape::Union => None,
656        }
657    }
658
659    /// How many structs the corpus held at `path`.
660    ///
661    /// The element count of a list, when `path` names one. Zero for a path the
662    /// corpus never held.
663    #[must_use]
664    pub fn occurrences(&self, path: &[&str]) -> usize {
665        self.containers.get(&path.join(".")).copied().unwrap_or(0)
666    }
667}
668
669#[cfg(test)]
670mod tests {
671    use super::*;
672    use crate::gff::GffLabel;
673    use crate::gff_label;
674
675    fn scalar(label: &'static str, value: u8) -> GffStruct {
676        let mut s = GffStruct::new(0);
677        s.push_field(GffLabel::from_static(label), GffValue::UInt8(value));
678        s
679    }
680
681    #[test]
682    fn a_changed_value_is_reported_under_its_label() {
683        let differences = value_differences(&scalar("Tag", 1), &scalar("Tag", 2));
684
685        assert_eq!(differences.len(), 1);
686        assert_eq!(differences[0].label, "Tag");
687        assert_eq!(differences[0].before, GffValue::UInt8(1));
688        assert_eq!(differences[0].after, GffValue::UInt8(2));
689    }
690
691    #[test]
692    fn an_unchanged_value_is_not_reported() {
693        assert!(value_differences(&scalar("Tag", 1), &scalar("Tag", 1)).is_empty());
694    }
695
696    #[test]
697    fn a_label_the_original_lacks_is_left_to_the_census() {
698        // This is the blind spot that makes LabelCensus necessary: comparing
699        // values alone cannot see a label that was invented.
700        assert!(value_differences(&scalar("Tag", 1), &scalar("Taag", 1)).is_empty());
701    }
702
703    /// No comparison here sees a struct's own id, on a nested struct or a list
704    /// element, and neither absence is an oversight. A nested struct's id is
705    /// unreachable by the engine code that resolves one; a list element's is
706    /// live and belongs to a schema comparison, since pairing elements between
707    /// two trees is not a tree question. The module docs have both.
708    #[test]
709    fn a_struct_id_is_invisible_to_every_comparison_here() {
710        let nested = |id| {
711            let mut root = GffStruct::new(0);
712            let mut inner = scalar("Deep", 1);
713            inner.struct_id = id;
714            root.push_field(gff_label!("Inner"), GffValue::Struct(Box::new(inner)));
715            root
716        };
717        assert!(value_differences(&nested(14), &nested(0)).is_empty());
718
719        let element = |id| {
720            let mut root = GffStruct::new(0);
721            let mut item = scalar("Deep", 1);
722            item.struct_id = id;
723            root.push_field(gff_label!("Items"), GffValue::List(vec![item]));
724            root
725        };
726        assert!(value_differences(&element(6), &element(0)).is_empty());
727    }
728
729    #[test]
730    fn nested_struct_fields_are_compared() {
731        let nest = |value| {
732            let mut root = GffStruct::new(0);
733            root.push_field(
734                gff_label!("Inner"),
735                GffValue::Struct(Box::new(scalar("Deep", value))),
736            );
737            root
738        };
739
740        let differences = value_differences(&nest(1), &nest(2));
741
742        assert_eq!(differences.len(), 1);
743        assert_eq!(differences[0].label, "Deep");
744    }
745
746    #[test]
747    fn lists_are_skipped_because_their_order_is_the_writers() {
748        let list = |value| {
749            let mut root = GffStruct::new(0);
750            root.push_field(
751                gff_label!("Items"),
752                GffValue::List(vec![scalar("Id", value)]),
753            );
754            root
755        };
756
757        assert!(value_differences(&list(1), &list(2)).is_empty());
758    }
759
760    #[test]
761    fn paths_reach_into_lists_and_structs() {
762        let mut root = GffStruct::new(0);
763        root.push_field(gff_label!("Items"), GffValue::List(vec![scalar("Id", 1)]));
764        root.push_field(
765            gff_label!("Inner"),
766            GffValue::Struct(Box::new(scalar("Deep", 1))),
767        );
768
769        let mut census = LabelCensus::new();
770        census.observe(&root, &GffStruct::new(0));
771
772        let found: Vec<&str> = census.unmodelled().into_iter().map(|(p, _)| p).collect();
773        // Paths, not bare labels: the level a field sits at is the whole
774        // point, and `Id` alone would not say which list it came from.
775        for path in ["Items", "Items[].Id", "Inner", "Inner.Deep"] {
776            assert!(
777                found.contains(&path),
778                "{path} should be collected, got {found:?}"
779            );
780        }
781    }
782
783    #[test]
784    fn an_invented_label_is_reported_once_a_corpus_exists() {
785        let mut census = LabelCensus::new();
786        census.observe(&scalar("Tag", 1), &scalar("Taag", 1));
787
788        assert_eq!(census.files(), 1);
789        assert_eq!(
790            census.invented(),
791            InventedLabels::Labels(vec!["Taag".to_string()])
792        );
793        let unmodelled = census.unmodelled();
794        assert_eq!(unmodelled.len(), 1);
795        assert_eq!(unmodelled[0].0, "Tag");
796        assert!(unmodelled[0].1.types.contains(&GffType::UInt8));
797        assert_eq!(unmodelled[0].1.files, 1);
798        assert_eq!(unmodelled[0].1.files_with_value, 1);
799    }
800
801    #[test]
802    fn an_empty_corpus_cannot_decide_the_question() {
803        // Everything a view writes is trivially absent from no files at all,
804        // so the answer is "cannot say" rather than "all of them".
805        assert_eq!(LabelCensus::new().invented(), InventedLabels::Undecidable);
806    }
807
808    #[test]
809    fn a_label_present_in_any_file_is_not_invented() {
810        // One file omitting a field another carries must not condemn it,
811        // which is why the census accumulates rather than judging per file.
812        let mut census = LabelCensus::new();
813        census.observe(&GffStruct::new(0), &scalar("Tag", 1));
814        census.observe(&scalar("Tag", 1), &scalar("Tag", 1));
815
816        assert_eq!(census.invented(), InventedLabels::Labels(Vec::new()));
817    }
818}
819
820#[cfg(test)]
821mod prevalence_tests {
822    use super::*;
823    use crate::gff_label;
824
825    #[test]
826    fn an_unmodelled_label_carries_how_many_files_had_it() {
827        // A label in one file of a thousand and a label in all thousand read
828        // identically without the count, and they are not the same finding.
829        let mut census = LabelCensus::new();
830        let mut common = GffStruct::new(0);
831        common.push_field(gff_label!("Everywhere"), GffValue::UInt8(1));
832        let mut rare = GffStruct::new(0);
833        rare.push_field(gff_label!("Everywhere"), GffValue::UInt8(1));
834        rare.push_field(gff_label!("Once"), GffValue::UInt8(1));
835
836        census.observe(&common, &GffStruct::new(0));
837        census.observe(&common, &GffStruct::new(0));
838        census.observe(&rare, &GffStruct::new(0));
839
840        let counts: BTreeMap<&str, usize> = census
841            .unmodelled()
842            .into_iter()
843            .map(|(label, observation)| (label, observation.files))
844            .collect();
845
846        assert_eq!(counts.get("Everywhere"), Some(&3));
847        assert_eq!(counts.get("Once"), Some(&1));
848    }
849}
850
851#[cfg(test)]
852mod wiring_tests {
853    use super::*;
854    use crate::gff::GffLabel;
855    use crate::gff_label;
856
857    fn nested(parent: &'static str, child: &'static str) -> GffStruct {
858        let mut inner = GffStruct::new(0);
859        inner.push_field(GffLabel::from_static(child), GffValue::UInt8(1));
860        let mut root = GffStruct::new(0);
861        root.push_field(
862            GffLabel::from_static(parent),
863            GffValue::Struct(Box::new(inner)),
864        );
865        root
866    }
867
868    #[test]
869    fn a_field_read_off_the_wrong_parent_is_reported_as_misplaced() {
870        // The shape no other check sees. The corpus holds Thing at
871        // Game.Thing; the view looks under Game.Player and so writes
872        // Game.Player.Thing. Values never coincide, the round-trip is
873        // stable, and both labels are modelled.
874        let mut original = GffStruct::new(0);
875        let mut game = GffStruct::new(0);
876        game.push_field(gff_label!("Thing"), GffValue::UInt8(7));
877        original.push_field(gff_label!("Game"), GffValue::Struct(Box::new(game)));
878
879        let mut rewritten = GffStruct::new(0);
880        let mut game_out = GffStruct::new(0);
881        let mut player = GffStruct::new(0);
882        player.push_field(gff_label!("Thing"), GffValue::UInt8(7));
883        game_out.push_field(gff_label!("Player"), GffValue::Struct(Box::new(player)));
884        rewritten.push_field(gff_label!("Game"), GffValue::Struct(Box::new(game_out)));
885
886        assert!(
887            value_differences(&original, &rewritten).is_empty(),
888            "a value comparison cannot see this, which is why the check exists"
889        );
890
891        let mut census = LabelCensus::new();
892        census.observe(&original, &rewritten);
893
894        assert_eq!(
895            census.misplaced(),
896            vec![("Thing", "Game.Thing", "Game.Player.Thing")]
897        );
898    }
899
900    #[test]
901    fn a_leaf_modelled_at_one_path_and_dropped_at_another_is_reported() {
902        // The `OnHeartbeat` shape. The corpus holds the label twice: once at
903        // the root, which the view models, and once nested, which it drops.
904        // Nothing else flags it. The nested path is in `unmodelled`, but a
905        // reviewer reading that list finds the label in the view, matches on
906        // the name and moves on, which is how the real one survived a scan
907        // built to catch it.
908        let mut original = GffStruct::new(0);
909        original.push_field(gff_label!("OnHeartbeat"), GffValue::UInt8(1));
910        let mut scripts = GffStruct::new(0);
911        scripts.push_field(gff_label!("OnHeartbeat"), GffValue::UInt8(2));
912        original.push_field(gff_label!("Scripts"), GffValue::Struct(Box::new(scripts)));
913
914        // The view writes only the root one.
915        let mut rewritten = GffStruct::new(0);
916        rewritten.push_field(gff_label!("OnHeartbeat"), GffValue::UInt8(1));
917
918        let mut census = LabelCensus::new();
919        census.observe(&original, &rewritten);
920
921        assert!(
922            census.misplaced().is_empty(),
923            "both paths are real, so this is not a wiring mismatch"
924        );
925
926        let split = census.partially_modelled_leaves();
927        assert_eq!(split.len(), 1);
928        assert_eq!(split[0].leaf, "OnHeartbeat");
929        assert_eq!(split[0].modelled, vec!["OnHeartbeat"]);
930        assert_eq!(
931            split[0]
932                .unmodelled
933                .iter()
934                .map(|(path, _)| *path)
935                .collect::<Vec<_>>(),
936            vec!["Scripts.OnHeartbeat"]
937        );
938    }
939
940    #[test]
941    fn a_leaf_covered_at_every_path_it_appears_at_is_not_reported() {
942        let mut structure = GffStruct::new(0);
943        structure.push_field(gff_label!("OnHeartbeat"), GffValue::UInt8(1));
944        let mut scripts = GffStruct::new(0);
945        scripts.push_field(gff_label!("OnHeartbeat"), GffValue::UInt8(2));
946        structure.push_field(gff_label!("Scripts"), GffValue::Struct(Box::new(scripts)));
947
948        let mut census = LabelCensus::new();
949        census.observe(&structure, &structure);
950        assert!(census.partially_modelled_leaves().is_empty());
951    }
952
953    #[test]
954    fn a_leaf_covered_nowhere_is_left_to_the_unmodelled_list() {
955        // Reporting it here too would duplicate `unmodelled` and bury the
956        // mixed cases this query exists for.
957        let structure = nested("Game", "Thing");
958        let mut census = LabelCensus::new();
959        census.observe(&structure, &GffStruct::new(0));
960
961        assert!(census.partially_modelled_leaves().is_empty());
962        assert_eq!(census.unmodelled().len(), 2);
963    }
964
965    #[test]
966    fn a_field_at_the_same_path_is_not_misplaced() {
967        let structure = nested("Game", "Thing");
968        let mut census = LabelCensus::new();
969        census.observe(&structure, &structure);
970
971        assert!(census.misplaced().is_empty());
972        assert!(matches!(
973            census.invented(),
974            InventedLabels::Labels(ref labels) if labels.is_empty()
975        ));
976    }
977
978    #[test]
979    fn paths_rather_than_labels_are_reported() {
980        let structure = nested("Game", "Thing");
981        let mut census = LabelCensus::new();
982        census.observe(&structure, &GffStruct::new(0));
983
984        let paths: Vec<&str> = census.unmodelled().into_iter().map(|(p, _)| p).collect();
985        assert!(paths.contains(&"Game.Thing"), "got {paths:?}");
986    }
987}
988
989#[cfg(test)]
990mod absence_tests {
991    use super::*;
992    use crate::gff_label;
993
994    /// One list of creatures, two of which carry `Tag` and one of which does
995    /// not, inside a root that also carries a top-level `Flag`.
996    fn creature_list() -> GffStruct {
997        let mut tagged = GffStruct::new(0);
998        tagged.push_field(gff_label!("Tag"), GffValue::UInt8(1));
999        let untagged = GffStruct::new(0);
1000
1001        let mut root = GffStruct::new(0);
1002        root.push_field(gff_label!("Flag"), GffValue::UInt8(1));
1003        root.push_field(
1004            gff_label!("Creature List"),
1005            GffValue::List(vec![tagged.clone(), tagged, untagged]),
1006        );
1007        root
1008    }
1009
1010    #[test]
1011    fn a_list_field_gets_one_occasion_per_element() {
1012        let mut census = AbsenceCensus::new();
1013        census.observe(&creature_list());
1014
1015        let rate = census
1016            .rate(&["Creature List", "Tag"])
1017            .expect("the list was observed");
1018        assert_eq!(rate.occasions, 3);
1019        assert_eq!(rate.present, 2);
1020        assert_eq!(rate.absent(), 1);
1021    }
1022
1023    #[test]
1024    fn a_top_level_field_gets_one_occasion_per_file() {
1025        let mut census = AbsenceCensus::new();
1026        census.observe(&creature_list());
1027        census.observe(&GffStruct::new(0));
1028
1029        let rate = census.rate(&["Flag"]).expect("the root was observed");
1030        assert_eq!(rate.occasions, 2);
1031        assert_eq!(rate.absent(), 1);
1032    }
1033
1034    #[test]
1035    fn a_path_the_corpus_never_reached_is_none_rather_than_zero() {
1036        // The distinction the whole type exists for: reporting this as a 0%
1037        // firing rate would read as "the fallback never runs" when the truth
1038        // is that nothing was ever checked.
1039        let mut census = AbsenceCensus::new();
1040        census.observe(&GffStruct::new(0));
1041
1042        assert!(census.rate(&["Creature List", "Tag"]).is_none());
1043        assert_eq!(census.rate(&["Flag"]).expect("root observed").absent(), 1);
1044    }
1045
1046    #[test]
1047    fn occurrences_counts_list_elements() {
1048        let mut census = AbsenceCensus::new();
1049        census.observe(&creature_list());
1050        census.observe(&creature_list());
1051
1052        assert_eq!(census.occurrences(&["Creature List"]), 6);
1053        assert_eq!(census.occurrences(&["Encounter List"]), 0);
1054    }
1055
1056    #[test]
1057    fn a_union_corpus_that_never_carries_a_label_is_not_asked_about_it() {
1058        // The saved side of a GIT: the parent is reached on every element and
1059        // the label belongs to the other reading.
1060        let mut census = AbsenceCensus::new();
1061        census.observe(&creature_list());
1062
1063        let path = &["Creature List", "TemplateResRef"];
1064        assert_eq!(census.rate(path).expect("parent reached").absent(), 3);
1065        assert!(census.firing_rate(path, SchemaShape::Union).is_none());
1066    }
1067
1068    #[test]
1069    fn a_single_form_corpus_that_never_carries_a_label_fires_on_all_of_it() {
1070        // The opposite reading of the same measurement, and the one that
1071        // matters most: a field absent from every file is a fallback running
1072        // every time.
1073        let mut census = AbsenceCensus::new();
1074        census.observe(&creature_list());
1075
1076        let path = &["Creature List", "TemplateResRef"];
1077        let rate = census
1078            .firing_rate(path, SchemaShape::SingleForm)
1079            .expect("a single-form schema reads absence as firing");
1080        assert_eq!(rate.absent(), 3);
1081    }
1082
1083    #[test]
1084    fn a_label_the_union_corpus_does_carry_is_still_ranked() {
1085        let mut census = AbsenceCensus::new();
1086        census.observe(&creature_list());
1087
1088        let rate = census
1089            .firing_rate(&["Creature List", "Tag"], SchemaShape::Union)
1090            .expect("the corpus carries Tag on two of three elements");
1091        assert_eq!(rate.absent(), 1);
1092    }
1093
1094    #[test]
1095    fn a_struct_field_is_the_parent_of_what_hangs_under_it() {
1096        let mut inner = GffStruct::new(0);
1097        inner.push_field(gff_label!("Deep"), GffValue::UInt8(1));
1098        let mut root = GffStruct::new(0);
1099        root.push_field(gff_label!("Inner"), GffValue::Struct(Box::new(inner)));
1100
1101        let mut census = AbsenceCensus::new();
1102        census.observe(&root);
1103
1104        let rate = census
1105            .rate(&["Inner", "Deep"])
1106            .expect("the struct was observed");
1107        assert_eq!(rate.occasions, 1);
1108        assert_eq!(rate.present, 1);
1109    }
1110}