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}