Skip to main content

shifty_engine/
compact.rs

1//! Compact, lossless encoding of an [`EvidenceRun`](crate::EvidenceRun).
2//!
3//! Full evidence is large for three measured reasons, and they dominate on
4//! different corpora:
5//!
6//!   * **Repeated terms.** The same IRIs recur at every mention. On
7//!     `brick/models/bldg1.ttl` (2,650 triples) 243,249 term occurrences are
8//!     548 distinct terms — a 444× redundancy, and the single largest lever.
9//!     It grows with the corpus: `bldg11.ttl` reaches 998×.
10//!   * **Repeated subtrees.** The same `(constraint, node)` conclusion is
11//!     reached through many parents, and each occurrence is written out in
12//!     full. On the same model 105,673 evidence-node occurrences are 19,765
13//!     distinct nodes — 5.3×, and roughly constant across the Brick corpus.
14//!   * **The constraint catalog.** Both arenas are dumped on every run
15//!     regardless of how many findings there are. On a small 223P model
16//!     (`guideline36-2021-A-1.ttl`, 146 triples) that fixed cost is 2.02 MB of
17//!     a 3.52 MB run — 57%.
18//!
19//! Together, on `bldg1.ttl`, a 33.1 MB run encodes to 9.8 MB with its catalog
20//! and 7.6 MB without. [`sharing`] reports the two redundancy factors for a run
21//! directly, measured against the very predicates the encoder interns by, so a
22//! quoted ratio cannot drift from what compaction actually collapses.
23//!
24//! This encoding removes both without losing anything: evidence nodes and RDF
25//! terms are hash-consed into tables and referenced by index, and the catalog
26//! is optional for callers that already hold the schema. [`expand`] reconstructs
27//! the original run exactly.
28//!
29//! The encoding is structural rather than typed: it interns any tagged
30//! `{"type", "details"}` object and any RDF term, so it follows the evidence
31//! vocabulary automatically instead of mirroring every variant. That keeps one
32//! definition of the evidence types and gives the Python bindings the same
33//! format for free.
34
35use crate::witness::EvidenceRun;
36use rustc_hash::{FxHashMap, FxHasher};
37use serde_json::{Map, Value, json};
38use std::hash::Hasher;
39
40/// Field name marking an interned evidence-node reference.
41const NODE_REF: &str = "#";
42/// Field name marking an interned term reference.
43const TERM_REF: &str = "~";
44/// Bumped when the encoding changes shape.
45const VERSION: u64 = 1;
46
47/// Encode `run` in the compact form.
48///
49/// With `include_catalog` false the constraint catalog is omitted; decoding
50/// then needs it supplied out of band ([`expand_with_catalog`]). Callers that
51/// already hold the schema the run was produced against should omit it.
52pub fn compact(run: &EvidenceRun, include_catalog: bool) -> serde_json::Result<Value> {
53    Ok(compact_value(serde_json::to_value(run)?, include_catalog))
54}
55
56/// Encode an already-serialized run.
57///
58/// The typed form is not needed to compact — callers holding a run only as JSON
59/// (the language bindings, a stored artifact) can encode it without a typed
60/// round-trip.
61pub fn compact_value(value: Value, include_catalog: bool) -> Value {
62    encode(value, include_catalog).0
63}
64
65/// How often the interned entries of a run repeat.
66///
67/// Counted over the evidence alone — the `statements` — because that is the
68/// part that grows with the corpus. The catalog is a fixed per-run cost and
69/// would flatter the ratio: it is interned into the same tables, so folding it
70/// in adds distinct entries without adding evidence occurrences.
71///
72/// Tagged nodes are split into two families, because they answer different
73/// questions and the mixture is dominated by the wrong one. *Result* nodes are
74/// [`Witness`](crate::witness::Witness) and [`SatTrace`](crate::witness::SatTrace)
75/// — one validation judgment each. *Support* nodes are
76/// [`PathSupport`](crate::witness::PathSupport) certificates, which say how a
77/// value was reached and are not judgments about anything. Both serialize as
78/// `{"type", "details"}` and the encoder interns both, so the combined
79/// [`node_redundancy`](Self::node_redundancy) is a statement about encoding
80/// cost only. Sharing *between validation results* is
81/// [`result_redundancy`](Self::result_redundancy), and on the Brick corpus the
82/// two differ by several fold because support nodes are the large majority of
83/// occurrences.
84#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
85pub struct Sharing {
86    /// Tagged nodes of either family as written out by the full encoding.
87    pub node_occurrences: usize,
88    /// Distinct such nodes, i.e. the size of the node table.
89    pub distinct_nodes: usize,
90    /// Occurrences of tagged nodes that carry a validation judgment.
91    pub result_occurrences: usize,
92    /// Distinct such nodes.
93    pub distinct_results: usize,
94    /// Occurrences of path-support certificates.
95    pub support_occurrences: usize,
96    /// Distinct such certificates.
97    pub distinct_support: usize,
98    /// RDF terms as written out by the full encoding.
99    pub term_occurrences: usize,
100    /// Distinct such terms, i.e. the size of the term table.
101    pub distinct_terms: usize,
102}
103
104impl Sharing {
105    /// Occurrences per distinct node over both families; 1.0 when nothing
106    /// repeats.
107    ///
108    /// What the compact encoding collapses. Not a measure of sharing between
109    /// validation results — see [`result_redundancy`](Self::result_redundancy).
110    pub fn node_redundancy(&self) -> f64 {
111        ratio(self.node_occurrences, self.distinct_nodes)
112    }
113
114    /// Occurrences per distinct validation-judgment node.
115    pub fn result_redundancy(&self) -> f64 {
116        ratio(self.result_occurrences, self.distinct_results)
117    }
118
119    /// Occurrences per distinct path-support certificate.
120    pub fn support_redundancy(&self) -> f64 {
121        ratio(self.support_occurrences, self.distinct_support)
122    }
123
124    /// Occurrences per distinct term; 1.0 when nothing repeats.
125    pub fn term_redundancy(&self) -> f64 {
126        ratio(self.term_occurrences, self.distinct_terms)
127    }
128
129    /// Share of tagged-node occurrences that are path support rather than
130    /// validation judgments.
131    pub fn support_share(&self) -> f64 {
132        if self.node_occurrences == 0 {
133            0.0
134        } else {
135            self.support_occurrences as f64 / self.node_occurrences as f64
136        }
137    }
138}
139
140fn ratio(occurrences: usize, distinct: usize) -> f64 {
141    if distinct == 0 {
142        1.0
143    } else {
144        occurrences as f64 / distinct as f64
145    }
146}
147
148/// Measure how much a run's evidence repeats, without keeping the encoding.
149///
150/// Reported against the same predicates the encoder interns by, so the ratio
151/// cannot drift from what compaction actually collapses.
152pub fn sharing(run: &EvidenceRun) -> serde_json::Result<Sharing> {
153    Ok(encode(serde_json::to_value(run)?, false).1)
154}
155
156fn encode(mut value: Value, include_catalog: bool) -> (Value, Sharing) {
157    let conforms = value.get("conforms").cloned().unwrap_or(json!(false));
158    let catalog = value
159        .get_mut("constraints")
160        .map(Value::take)
161        .unwrap_or(Value::Null);
162
163    let mut terms = Interner::default();
164    let mut nodes = Interner::default();
165    let mut families = Families::default();
166    let statements = intern(
167        value
168            .get_mut("statements")
169            .map(Value::take)
170            .unwrap_or(json!([])),
171        &mut terms,
172        &mut nodes,
173        &mut families,
174    );
175    // Read the counters before the catalog perturbs them: this is the sharing
176    // among the evidence itself.
177    let sharing = Sharing {
178        node_occurrences: nodes.occurrences,
179        distinct_nodes: nodes.table.len(),
180        result_occurrences: families.result_occurrences,
181        distinct_results: families.distinct_results,
182        support_occurrences: families.support_occurrences,
183        distinct_support: families.distinct_support,
184        term_occurrences: terms.occurrences,
185        distinct_terms: terms.table.len(),
186    };
187    // The catalog shares the same tables: its shapes are the very constraints
188    // the evidence nodes refer to, so interning both together collapses the
189    // overlap instead of writing each side out separately.
190    let catalog = include_catalog.then(|| intern(catalog, &mut terms, &mut nodes, &mut families));
191
192    let mut out = Map::new();
193    out.insert("v".into(), json!(VERSION));
194    out.insert("conforms".into(), conforms);
195    out.insert("terms".into(), Value::Array(terms.table));
196    out.insert("nodes".into(), Value::Array(nodes.table));
197    out.insert("statements".into(), statements);
198    if let Some(catalog) = catalog {
199        out.insert("constraints".into(), catalog);
200    }
201    (Value::Object(out), sharing)
202}
203
204/// Serialize the compact encoding.
205pub fn to_compact_json(run: &EvidenceRun, include_catalog: bool) -> serde_json::Result<String> {
206    serde_json::to_string(&compact(run, include_catalog)?)
207}
208
209/// Reconstruct a run from its compact encoding, which must carry its catalog.
210pub fn expand(value: &Value) -> Result<EvidenceRun, CompactError> {
211    let catalog = value
212        .get("constraints")
213        .cloned()
214        .ok_or(CompactError::MissingCatalog)?;
215    expand_with_catalog(value, catalog)
216}
217
218/// Reconstruct a run whose catalog was omitted, using a catalog held elsewhere.
219pub fn expand_with_catalog(value: &Value, catalog: Value) -> Result<EvidenceRun, CompactError> {
220    serde_json::from_value(expand_value(value, catalog)?).map_err(CompactError::Decode)
221}
222
223/// Reconstruct the serialized run without decoding it into the typed form.
224///
225/// The inverse of [`compact_value`], for callers that only move JSON around.
226pub fn expand_value(value: &Value, catalog: Value) -> Result<Value, CompactError> {
227    let version = value.get("v").and_then(Value::as_u64);
228    if version != Some(VERSION) {
229        return Err(CompactError::Version(version));
230    }
231    let terms = array(value, "terms")?;
232    let nodes = array(value, "nodes")?;
233    let statements = value.get("statements").ok_or(CompactError::Malformed)?;
234
235    // Node table entries reference only *earlier* entries, so expanding in
236    // order lets each one reuse the already-expanded form of its children.
237    let mut expanded: Vec<Value> = Vec::with_capacity(nodes.len());
238    for node in nodes {
239        let value = restore(node, terms, &expanded)?;
240        expanded.push(value);
241    }
242
243    let mut out = Map::new();
244    out.insert(
245        "conforms".into(),
246        value.get("conforms").cloned().unwrap_or(json!(false)),
247    );
248    // An interned catalog resolves through the tables; one supplied out of band
249    // holds no references and passes through unchanged.
250    out.insert("constraints".into(), restore(&catalog, terms, &expanded)?);
251    out.insert("statements".into(), restore(statements, terms, &expanded)?);
252    Ok(Value::Object(out))
253}
254
255/// Why a compact encoding could not be read back.
256#[derive(Debug)]
257pub enum CompactError {
258    /// The encoding omitted its catalog; supply one with
259    /// [`expand_with_catalog`].
260    MissingCatalog,
261    /// Encoded by a different version of this format.
262    Version(Option<u64>),
263    /// Required tables are absent or not arrays.
264    Malformed,
265    /// A table reference points outside the entries available at that point.
266    InvalidReference { table: &'static str, id: usize },
267    /// The reconstructed value is not a valid run.
268    Decode(serde_json::Error),
269}
270
271impl std::fmt::Display for CompactError {
272    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
273        match self {
274            Self::MissingCatalog => {
275                write!(f, "compact evidence omits its constraint catalog")
276            }
277            Self::Version(found) => {
278                write!(f, "compact evidence version {found:?}, expected {VERSION}")
279            }
280            Self::Malformed => write!(f, "compact evidence is missing its tables"),
281            Self::InvalidReference { table, id } => {
282                write!(f, "compact evidence has invalid {table} reference {id}")
283            }
284            Self::Decode(error) => write!(f, "compact evidence does not decode: {error}"),
285        }
286    }
287}
288
289impl std::error::Error for CompactError {}
290
291fn array<'a>(value: &'a Value, key: &str) -> Result<&'a [Value], CompactError> {
292    value
293        .get(key)
294        .and_then(Value::as_array)
295        .map(Vec::as_slice)
296        .ok_or(CompactError::Malformed)
297}
298
299#[derive(Default)]
300struct Interner {
301    table: Vec<Value>,
302    /// Structural hash to the ids that hash there. Buckets hold more than one
303    /// id only on a genuine hash collision, which the value comparison below
304    /// then settles.
305    index: FxHashMap<u64, Vec<u32>>,
306    /// Every intern call, whether or not it was a first sight. The excess over
307    /// `table.len()` is exactly what the encoding collapses.
308    occurrences: usize,
309}
310
311impl Interner {
312    /// The id for `value` and whether this call created it, inserting on first
313    /// sight so that structurally identical entries collapse.
314    ///
315    /// Keyed by a structural hash rather than by the value's canonical text.
316    /// Serializing each occurrence to a `String` to use as a key allocated once
317    /// per *occurrence* and discarded it on every hit — and hits are the whole
318    /// point of an interner. On `bldg11.ttl` that was roughly six million
319    /// allocations for 371k distinct entries.
320    fn intern(&mut self, value: Value) -> (usize, bool) {
321        self.occurrences += 1;
322        let hash = structural_hash(&value);
323        if let Some(bucket) = self.index.get(&hash) {
324            for &id in bucket {
325                if self.table[id as usize] == value {
326                    return (id as usize, false);
327                }
328            }
329        }
330        let id = self.table.len();
331        self.table.push(value);
332        self.index.entry(hash).or_default().push(id as u32);
333        (id, true)
334    }
335}
336
337/// Per-family node counts, split out of the single node table the encoding
338/// needs. Every table entry belongs to exactly one family, so counting at
339/// insertion partitions the table exactly.
340#[derive(Default)]
341struct Families {
342    result_occurrences: usize,
343    distinct_results: usize,
344    support_occurrences: usize,
345    distinct_support: usize,
346}
347
348impl Families {
349    fn record(&mut self, result: bool, fresh: bool) {
350        let (occurrences, distinct) = if result {
351            (&mut self.result_occurrences, &mut self.distinct_results)
352        } else {
353            (&mut self.support_occurrences, &mut self.distinct_support)
354        };
355        *occurrences += 1;
356        *distinct += usize::from(fresh);
357    }
358}
359
360/// Hash a value by structure, without materializing it.
361///
362/// Agrees with `Value`'s own equality — including `Number`'s, which
363/// distinguishes the integer and float representations rather than comparing
364/// numerically — so equal values always land in the same bucket. Should that
365/// ever cease to hold (`serde_json`'s `preserve_order` feature would make
366/// object iteration order significant while equality stays order-independent),
367/// the failure is a missed merge, not a wrong encoding: the value is stored
368/// under a second id and expands to exactly the same tree.
369fn structural_hash(value: &Value) -> u64 {
370    let mut hasher = FxHasher::default();
371    hash_into(value, &mut hasher);
372    hasher.finish()
373}
374
375fn hash_into(value: &Value, hasher: &mut FxHasher) {
376    match value {
377        Value::Null => hasher.write_u8(0),
378        Value::Bool(flag) => {
379            hasher.write_u8(1);
380            hasher.write_u8(u8::from(*flag));
381        }
382        Value::Number(number) => {
383            hasher.write_u8(2);
384            if let Some(unsigned) = number.as_u64() {
385                hasher.write_u8(0);
386                hasher.write_u64(unsigned);
387            } else if let Some(signed) = number.as_i64() {
388                hasher.write_u8(1);
389                hasher.write_i64(signed);
390            } else {
391                hasher.write_u8(2);
392                hasher.write_u64(number.as_f64().map_or(0, f64::to_bits));
393            }
394        }
395        Value::String(text) => {
396            hasher.write_u8(3);
397            hasher.write(text.as_bytes());
398        }
399        Value::Array(items) => {
400            hasher.write_u8(4);
401            hasher.write_usize(items.len());
402            items.iter().for_each(|item| hash_into(item, hasher));
403        }
404        Value::Object(map) => {
405            hasher.write_u8(5);
406            hasher.write_usize(map.len());
407            for (key, child) in map {
408                hasher.write(key.as_bytes());
409                hash_into(child, hasher);
410            }
411        }
412    }
413}
414
415/// An RDF term in SPARQL-JSON spelling: `{"type": "uri"|"bnode"|"literal", …}`.
416/// Tagged evidence nodes also carry `type`, so the `details` key distinguishes
417/// them.
418fn is_term(map: &Map<String, Value>) -> bool {
419    !map.contains_key("details")
420        && map
421            .get("type")
422            .and_then(Value::as_str)
423            .is_some_and(|kind| matches!(kind, "uri" | "bnode" | "literal"))
424        && map.contains_key("value")
425}
426
427/// A tagged evidence or path-support node.
428fn is_node(map: &Map<String, Value>) -> bool {
429    map.len() == 2 && map.contains_key("type") && map.contains_key("details")
430}
431
432/// Whether a tagged node carries a validation judgment rather than a path
433/// certificate.
434///
435/// Decided by the presence of `shape` in the payload rather than by a list of
436/// variant tags: every `Witness` and `SatTrace` variant carries a `shape`
437/// field, while `PathSupport`'s payloads are a triple or an array of nested
438/// certificates. A tag list would have to be revised whenever a variant is
439/// added, and would misclassify silently when it was not; this follows the
440/// vocabulary the same way the rest of the encoder does. `PathSupport::Empty`
441/// is a unit variant, so it serializes without `details` and is never a node
442/// at all.
443fn is_result(value: &Value) -> bool {
444    value
445        .get("details")
446        .and_then(Value::as_object)
447        .is_some_and(|details| details.contains_key("shape"))
448}
449
450/// Replace every term and tagged node with a table reference, children first.
451fn intern(
452    mut value: Value,
453    terms: &mut Interner,
454    nodes: &mut Interner,
455    families: &mut Families,
456) -> Value {
457    let (term, tagged) = match &value {
458        Value::Object(map) => (is_term(map), is_node(map)),
459        _ => (false, false),
460    };
461    if term {
462        let (id, _) = terms.intern(value);
463        return json!({ TERM_REF: id });
464    }
465    // Children are rewritten in place. Collecting them into a fresh `Map` or
466    // `Vec` allocated a new container for every object and array in the run,
467    // which is the whole tree — and the tree is what makes runs large enough
468    // to want compacting in the first place.
469    match &mut value {
470        Value::Object(map) => {
471            for child in map.values_mut() {
472                let taken = std::mem::replace(child, Value::Null);
473                *child = intern(taken, terms, nodes, families);
474            }
475        }
476        Value::Array(items) => {
477            for item in items.iter_mut() {
478                let taken = std::mem::replace(item, Value::Null);
479                *item = intern(taken, terms, nodes, families);
480            }
481        }
482        _ => {}
483    }
484    if tagged {
485        // Classified before interning: the payload is still in hand, and
486        // children have already been replaced by references, which leaves the
487        // `shape` key untouched either way.
488        let result = is_result(&value);
489        let (id, fresh) = nodes.intern(value);
490        families.record(result, fresh);
491        json!({ NODE_REF: id })
492    } else {
493        value
494    }
495}
496
497/// Resolve table references back into the original tree.
498fn restore(value: &Value, terms: &[Value], nodes: &[Value]) -> Result<Value, CompactError> {
499    match value {
500        Value::Object(map) => {
501            if let Some(id) = reference(map, TERM_REF) {
502                return terms
503                    .get(id)
504                    .cloned()
505                    .ok_or(CompactError::InvalidReference { table: "term", id });
506            }
507            if let Some(id) = reference(map, NODE_REF) {
508                return nodes
509                    .get(id)
510                    .cloned()
511                    .ok_or(CompactError::InvalidReference { table: "node", id });
512            }
513            Ok(Value::Object(
514                map.iter()
515                    .map(|(key, child)| Ok((key.clone(), restore(child, terms, nodes)?)))
516                    .collect::<Result<Map<_, _>, CompactError>>()?,
517            ))
518        }
519        Value::Array(items) => Ok(Value::Array(
520            items
521                .iter()
522                .map(|item| restore(item, terms, nodes))
523                .collect::<Result<Vec<_>, _>>()?,
524        )),
525        other => Ok(other.clone()),
526    }
527}
528
529fn reference(map: &Map<String, Value>, key: &str) -> Option<usize> {
530    (map.len() == 1)
531        .then(|| map.get(key))
532        .flatten()
533        .and_then(Value::as_u64)
534        .map(|id| id as usize)
535}
536
537#[cfg(test)]
538mod tests {
539    use super::*;
540    use crate::validate_with_evidence;
541    use crate::witness::{PathSupport, SatTrace};
542    use oxrdf::{NamedNode, Triple};
543    use shifty_parse::{load_turtle, parse_turtle};
544
545    // Exercises every term kind the encoding must recognize — IRIs, blank
546    // nodes, plain/typed/tagged literals — alongside both evidence polarities.
547    const TTL: &str = r#"
548        @prefix sh: <http://www.w3.org/ns/shacl#> .
549        @prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
550        @prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
551        @prefix ex: <http://ex/> .
552        ex:S a sh:NodeShape ; sh:targetClass ex:T ;
553          sh:property [ sh:path ex:p ; sh:minCount 1 ; sh:class ex:C ] ;
554          sh:property [ sh:path ex:q ; sh:maxCount 1 ] ;
555          sh:property [ sh:path ex:n ; sh:datatype xsd:integer ] ;
556          sh:property [ sh:path ex:label ; sh:minCount 1 ] .
557        ex:T2 a sh:NodeShape ; sh:targetClass ex:T ;
558          sh:property [ sh:path ex:p ; sh:minCount 1 ; sh:class ex:C ] .
559        ex:good a ex:T ; ex:p ex:c1 ; ex:q ex:z ;
560          ex:n 3 ; ex:label "hello"@en ; ex:child [ ex:p ex:c1 ] .
561        ex:bad a ex:T ; ex:q ex:z ; ex:q ex:y ; ex:n "not a number" .
562        ex:c1 a ex:C .
563    "#;
564
565    fn run() -> EvidenceRun {
566        let parsed = parse_turtle(TTL.as_bytes(), None).unwrap();
567        let loaded = load_turtle(TTL.as_bytes(), None).unwrap();
568        validate_with_evidence(&loaded.graph, &parsed.schema).unwrap()
569    }
570
571    #[test]
572    fn compact_round_trips_exactly() {
573        let original = run();
574        let encoded = compact(&original, true).unwrap();
575        let restored = expand(&encoded).unwrap();
576        assert_eq!(restored, original);
577    }
578
579    #[test]
580    fn catalog_can_be_carried_out_of_band() {
581        let original = run();
582        let encoded = compact(&original, false).unwrap();
583        assert!(encoded.get("constraints").is_none());
584
585        let catalog = serde_json::to_value(&original.constraints).unwrap();
586        let restored = expand_with_catalog(&encoded, catalog).unwrap();
587        assert_eq!(restored, original);
588        assert!(matches!(
589            expand(&encoded),
590            Err(CompactError::MissingCatalog)
591        ));
592    }
593
594    #[test]
595    fn identical_subtrees_are_stored_once() {
596        // ex:S and ex:T2 declare the same property constraint, and both select
597        // the same two focus nodes, so the evidence repeats.
598        let original = run();
599        let encoded = compact(&original, true).unwrap();
600        let nodes = encoded.get("nodes").unwrap().as_array().unwrap().len();
601        let emitted = original.walk().len();
602        assert!(
603            nodes < emitted,
604            "expected sharing: {nodes} distinct vs {emitted} emitted"
605        );
606    }
607
608    #[test]
609    fn compact_is_smaller_than_the_full_run() {
610        let original = run();
611        let full = original.to_json().unwrap().len();
612        let packed = to_compact_json(&original, true).unwrap().len();
613        assert!(
614            packed < full,
615            "compact {packed} not smaller than full {full}"
616        );
617    }
618
619    #[test]
620    fn sharing_counts_the_evidence_the_encoder_interns() {
621        let original = run();
622        let measured = sharing(&original).unwrap();
623
624        // Distinct counts are the tables the encoding actually writes, and the
625        // catalog must not inflate them: it is interned into the same tables,
626        // so measuring off the full encoding would report more distinct nodes
627        // than the evidence has occurrences.
628        let encoded = compact(&original, false).unwrap();
629        let table = |key: &str| encoded.get(key).unwrap().as_array().unwrap().len();
630        assert_eq!(measured.distinct_nodes, table("nodes"));
631        assert_eq!(measured.distinct_terms, table("terms"));
632        assert_eq!(
633            table("nodes"),
634            compact(&original, true)
635                .unwrap()
636                .get("nodes")
637                .unwrap()
638                .as_array()
639                .unwrap()
640                .len()
641                .min(table("nodes")),
642            "the catalog only ever adds entries"
643        );
644
645        // Occurrences exceed distinct entries exactly when something repeats,
646        // which is what the fixture is built to do.
647        assert!(measured.node_occurrences > measured.distinct_nodes);
648        assert!(measured.term_occurrences > measured.distinct_terms);
649        assert!(measured.node_redundancy() > 1.0);
650        assert!(measured.term_redundancy() > 1.0);
651    }
652
653    // The split exists because the combined node counts are dominated by path
654    // support, so anything read off them as "sharing between results" is
655    // reading the wrong family. Two things pin it: the partition is exact, and
656    // the result side agrees with an independent typed traversal that never
657    // touches JSON.
658    #[test]
659    fn the_node_families_partition_and_agree_with_the_typed_walk() {
660        let original = run();
661        let measured = sharing(&original).unwrap();
662
663        assert_eq!(
664            measured.result_occurrences + measured.support_occurrences,
665            measured.node_occurrences,
666            "every tagged node belongs to exactly one family"
667        );
668        assert_eq!(
669            measured.distinct_results + measured.distinct_support,
670            measured.distinct_nodes,
671            "the families partition the node table"
672        );
673
674        // `walk` visits `Witness`/`SatTrace` and nothing else, by a traversal
675        // written against the types rather than the serialized form.
676        assert_eq!(
677            measured.result_occurrences,
678            original.walk().len(),
679            "result occurrences are exactly the evidence nodes"
680        );
681        assert!(
682            measured.support_occurrences > 0,
683            "the fixture reaches values by a path, so it has certificates"
684        );
685    }
686
687    #[test]
688    fn path_support_is_not_mistaken_for_a_judgment() {
689        // `PathSupport::Edge` and a `Witness` are both `{type, details}`; only
690        // the judgment carries a shape.
691        let edge = serde_json::to_value(PathSupport::Edge(Triple::new(
692            NamedNode::new("urn:s").unwrap(),
693            NamedNode::new("urn:p").unwrap(),
694            NamedNode::new("urn:o").unwrap(),
695        )))
696        .unwrap();
697        assert!(is_node(edge.as_object().unwrap()));
698        assert!(!is_result(&edge));
699
700        assert!(!is_result(
701            &serde_json::to_value(PathSupport::Chain(vec![])).unwrap()
702        ));
703
704        let judgment = serde_json::to_value(SatTrace::Irrefutable {
705            shape: shifty_algebra::ShapeId(0),
706        })
707        .unwrap();
708        assert!(is_node(judgment.as_object().unwrap()));
709        assert!(is_result(&judgment));
710    }
711
712    // The structural hash replaced a canonical-text key, so what needs pinning
713    // is that it still agrees with equality: one table entry per distinct
714    // value, and never two distinct values sharing an id.
715    #[test]
716    fn interning_gives_one_entry_per_distinct_value() {
717        let distinct = [
718            json!(null),
719            json!(true),
720            json!(false),
721            json!(0),
722            json!(-1),
723            json!(1.5),
724            json!(""),
725            json!("0"),
726            json!([]),
727            json!([json!(0)]),
728            json!([json!(0), json!(0)]),
729            json!({}),
730            json!({ "a": 0 }),
731            json!({ "a": 0, "b": 1 }),
732            json!({ "b": 0, "a": 1 }),
733            json!({ "a": { "a": 0 } }),
734        ];
735
736        let mut interner = Interner::default();
737        let first: Vec<(usize, bool)> = distinct
738            .iter()
739            .map(|value| interner.intern(value.clone()))
740            .collect();
741        assert_eq!(
742            first,
743            (0..distinct.len()).map(|id| (id, true)).collect::<Vec<_>>(),
744            "every distinct value is a fresh insertion"
745        );
746
747        // Re-interning returns the original ids, reports no insertion, and adds
748        // nothing to the table.
749        let again: Vec<(usize, bool)> = distinct
750            .iter()
751            .map(|value| interner.intern(value.clone()))
752            .collect();
753        assert_eq!(
754            again,
755            first.iter().map(|&(id, _)| (id, false)).collect::<Vec<_>>()
756        );
757        assert_eq!(interner.table.len(), distinct.len());
758        assert_eq!(interner.occurrences, 2 * distinct.len());
759
760        // Every id resolves back to the value that produced it.
761        for (value, &(id, _)) in distinct.iter().zip(first.iter()) {
762            assert_eq!(&interner.table[id], value);
763        }
764    }
765
766    #[test]
767    fn colliding_values_stay_distinct() {
768        // Force the collision path: two unequal values pushed into one bucket
769        // must still receive different ids.
770        let mut interner = Interner::default();
771        let a = interner.intern(json!({ "type": "uri", "value": "urn:a" }));
772        let b = interner.intern(json!({ "type": "uri", "value": "urn:b" }));
773        assert_ne!(a, b);
774
775        let bucket: Vec<u32> = interner.index.values().flatten().copied().collect();
776        assert_eq!(bucket.len(), 2, "both ids are reachable from the index");
777    }
778
779    #[test]
780    fn sharing_of_nothing_is_one() {
781        assert_eq!(Sharing::default().node_redundancy(), 1.0);
782        assert_eq!(Sharing::default().term_redundancy(), 1.0);
783    }
784
785    #[test]
786    fn a_foreign_version_is_rejected() {
787        let mut encoded = compact(&run(), true).unwrap();
788        encoded["v"] = json!(VERSION + 1);
789        assert!(matches!(expand(&encoded), Err(CompactError::Version(_))));
790    }
791
792    #[test]
793    fn dangling_and_forward_references_are_rejected() {
794        let dangling = json!({
795            "v": VERSION,
796            "conforms": false,
797            "terms": [],
798            "nodes": [],
799            "statements": { (NODE_REF): 0 },
800            "constraints": [],
801        });
802        assert!(matches!(
803            expand_value(&dangling, json!([])),
804            Err(CompactError::InvalidReference {
805                table: "node",
806                id: 0
807            })
808        ));
809
810        let forward = json!({
811            "v": VERSION,
812            "conforms": false,
813            "terms": [],
814            "nodes": [{ (NODE_REF): 0 }],
815            "statements": [],
816            "constraints": [],
817        });
818        assert!(matches!(
819            expand_value(&forward, json!([])),
820            Err(CompactError::InvalidReference {
821                table: "node",
822                id: 0
823            })
824        ));
825    }
826}