Skip to main content

rete_core/
meta.rs

1//! Pyramid-meta section encoding (SPEC.md §7.3): the summary (quotient) graph,
2//! the per-community tile directory, and — as of **pyramid-meta v2** — the
3//! **schema pyramid** (the non-exclusive `subClassOf` DAG, per-level type rollups,
4//! per-level lateral class relations, and optional per-community descriptors).
5//! Stored in the `.rete` file at the header's `pyramid_meta_offset`.
6//!
7//! Layout:
8//! ```text
9//! varint round                      # dendrogram round used for this pyramid
10//! varint num_superedges
11//!   per edge: varint s_comm, predicate, o_comm, count
12//! varint num_tiles
13//!   per tile: varint community, varint block_len, block_bytes (SPO triple block)
14//! --- v2 (optional, appended; absent in v1 files; ignored by v1 readers) ---
15//! u8 schema_version (= 2)
16//! varint num_strings;        per: len-prefixed UTF-8 IRI/sentinel  # a local table (classes + predicates)
17//! varint num_hierarchy;      per: varint class_idx, num_parents, parent_idx…, depth   # non-exclusive DAG
18//! varint num_rollups;        per: varint round, depth, num_entries, (class_idx,count)…
19//! varint num_level_links;    per: varint round, depth, num_links, (s_idx, pred_idx, o_idx, count)…
20//! varint num_descriptors;    per: varint community, dominant_idx+1 (0=none),
21//!                                  num_class_counts, (class_idx,count)…,
22//!                                  u8 has_bbox [+ 4×f64 le],
23//!                                  u8 has_time [+ from(str) + to(str)]
24//! ```
25//! The v2 block is written **only** when there is schema content, so a typeless
26//! graph stays byte-identical to a v1 file. A v1 reader stops after the tiles
27//! loop and never sees the appended bytes.
28
29use std::collections::BTreeMap;
30
31use crate::tiling::{SuperEdge, Tile};
32use crate::varint::{read_uvarint, write_uvarint};
33
34/// The schema-pyramid section version tag (first byte of the v2 block).
35const SCHEMA_V2: u8 = 2;
36/// The query-stats block tag. Distinct from [`SCHEMA_V2`]; the block is
37/// length-prefixed and written *before* the schema block (which stays trailing,
38/// so `schema_meta_len` is unaffected). Index-free per-predicate cardinality the
39/// cost-based planner uses; old readers skip it via the length prefix.
40const QSTATS_V1: u8 = 3;
41/// The characteristic-sets block tag. Length-prefixed, written after the
42/// query-stats block and before the (trailing) schema block; old readers skip it.
43const CHARSET_V1: u8 = 4;
44/// The label-index block tag. Length-prefixed, written after the characteristic-
45/// sets block and before the (trailing) schema block; old readers skip it. A
46/// bounded, label-sorted `(label, subject)` table for prefix search without a
47/// literal scan; see [`LabelEntry`].
48const LABELIDX_V1: u8 = 5;
49
50/// A **characteristic set** — one entity "shape": the sorted set of predicates a
51/// subject carries, plus how many subjects share exactly that shape. Computed at
52/// build (top-N shapes by subject count), range-fetched with the pyramid. Useful
53/// for data profiling (the distinct entity shapes) and for estimating the
54/// selectivity of a subject star (subjects matching ALL of several predicates).
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub struct CharSet {
57    /// Predicate ids, ascending.
58    pub predicates: Vec<u32>,
59    /// Subjects whose predicate set is exactly this.
60    pub subjects: u64,
61}
62
63/// One entry of the **label index**: the display label of a subject (its
64/// `rdfs:label` / `skos:prefLabel` / … literal) paired with that subject's id.
65/// Computed at build (the most-connected labeled subjects, bounded), the table
66/// is sorted by the label's lowercased form so a prefix query is a binary search
67/// — autocomplete and CONTAINS/prefix lookup **without scanning the literals**.
68/// Range-fetched with the pyramid; the label string is stored inline so the
69/// search is self-contained on the remote-lazy path (no dictionary fault).
70#[derive(Debug, Clone, PartialEq, Eq)]
71pub struct LabelEntry {
72    /// The label literal's lexical value, original case (sorted case-insensitively).
73    pub label: String,
74    /// The subject id (subject id space) this label belongs to.
75    pub subject: u32,
76}
77
78/// Per-predicate cardinality statistics for the cost-based query planner —
79/// computed at build, range-fetched with the pyramid (index-free). `count` is the
80/// triple total; the distinct-subject/object counts and max multiplicities let
81/// the planner estimate a bound subject's/object's selectivity (`functional`
82/// iff `max_objects_per_subject == 1`, inverse-functional iff
83/// `max_subjects_per_object == 1`).
84#[derive(Debug, Clone, PartialEq, Eq)]
85pub struct PredStat {
86    pub predicate: u32,
87    pub count: u64,
88    pub distinct_subjects: u64,
89    pub distinct_objects: u64,
90    pub max_objects_per_subject: u32,
91    pub max_subjects_per_object: u32,
92}
93
94#[derive(Debug, thiserror::Error)]
95pub enum MetaError {
96    #[error("malformed pyramid meta: {0}")]
97    Malformed(&'static str),
98}
99
100/// One node of the shipped `subClassOf` DAG: a class, **all** its direct parents,
101/// and its `depth` (0 = root). The hierarchy is **non-exclusive** — a class may
102/// have several parents (multiple inheritance), so this is a directed acyclic
103/// *graph*, not a tree. The first parent (parents are sorted) is the *canonical*
104/// one used for the depth/rollup spanning tree; the rest preserve the cross-links.
105#[derive(Debug, Clone, PartialEq, Eq)]
106pub struct ClassNode {
107    pub class: String,
108    pub parents: Vec<String>,
109    pub depth: u16,
110}
111
112impl ClassNode {
113    /// The canonical parent (smallest) used for the rollup spanning tree, if any.
114    pub fn canonical_parent(&self) -> Option<&String> {
115        self.parents.first()
116    }
117}
118
119/// A type histogram **rolled up to `depth`** — the global class distribution at
120/// one semantic-zoom level. Coarse levels (small depth) hold abstract ancestor
121/// classes; fine levels (large depth) resolve to leaves. `round` is the
122/// dendrogram round this level is aligned with (informational).
123#[derive(Debug, Clone, PartialEq, Eq)]
124pub struct LevelRollup {
125    pub round: u32,
126    pub depth: u16,
127    pub classes: Vec<(String, u64)>,
128}
129
130/// A class-to-class relation (the **lateral**, non-`is-a` connection): subjects of
131/// `s_class` related by `predicate` to objects of `o_class`, with the instance
132/// `count`. `(literal)` / `(untyped)` are the object-class sentinels.
133#[derive(Debug, Clone, PartialEq, Eq)]
134pub struct ClassRelation {
135    pub s_class: String,
136    pub predicate: String,
137    pub o_class: String,
138    pub count: u64,
139}
140
141/// The class-relation graph **rolled up to `depth`** — the lateral connections at
142/// one semantic-zoom level. Coarse levels show relations between abstract classes
143/// (`Agent → Agent`); finer levels resolve them (`Person → Organisation`). This is
144/// what makes the pyramid a leveled *graph*, not just a leveled histogram.
145#[derive(Debug, Clone, PartialEq, Eq)]
146pub struct LevelLinks {
147    pub round: u32,
148    pub depth: u16,
149    pub links: Vec<ClassRelation>,
150}
151
152/// A per-community refinement descriptor (Phase 4): what a client sees when it
153/// zooms into one community, without fetching that community's triples. Carries
154/// the dominant class, the local type histogram, and optional spatial/temporal
155/// extents. (The physical per-community triple tiles are still future work; this
156/// descriptor index ships in the index-free pyramid-meta and is ready to attach
157/// to those tiles when they exist.)
158#[derive(Debug, Clone, PartialEq)]
159pub struct CommunityDescriptor {
160    pub community: u32,
161    pub dominant_class: Option<String>,
162    pub class_counts: Vec<(String, u64)>,
163    /// `[minLon, minLat, maxLon, maxLat]` over wgs84 lat/long (CRS84).
164    pub bbox: Option<[f64; 4]>,
165    /// `(min, max)` lexical extent over a date/year-typed predicate.
166    pub time_range: Option<(String, String)>,
167}
168
169/// Decoded pyramid metadata.
170#[derive(Debug, Clone, PartialEq)]
171pub struct PyramidMeta {
172    pub round: u32,
173    pub summary: Vec<SuperEdge>,
174    /// `(community, encoded SPO triple block)` per tile.
175    pub tiles: Vec<(u32, Vec<u8>)>,
176    // --- v2 schema pyramid (empty on v1 files) ---
177    pub class_hierarchy: Vec<ClassNode>,
178    pub level_rollups: Vec<LevelRollup>,
179    /// Per-level lateral class-relation graph (the non-`is-a` connections).
180    pub level_links: Vec<LevelLinks>,
181    pub descriptors: Vec<CommunityDescriptor>,
182    // --- v2.1 coherence axioms (empty on v1 and on v2.0 files) ---
183    /// `subClassOf` cycles — each a sorted class set (SCC > 1, or a self-loop).
184    pub subclass_cycles: Vec<Vec<String>>,
185    /// `owl:disjointWith` class pairs (canonical `(min, max)`).
186    pub disjoint_pairs: Vec<(String, String)>,
187    /// `owl:equivalentClass` class pairs (canonical `(min, max)`).
188    pub equivalent_pairs: Vec<(String, String)>,
189    // --- query-stats block (empty when not computed; independent of the schema) ---
190    /// Per-predicate cardinality for the planner; see [`PredStat`].
191    pub predicate_stats: Vec<PredStat>,
192    // --- characteristic-sets block (entity shapes; empty when not computed) ---
193    /// The distinct entity shapes (top-N by subject count); see [`CharSet`].
194    pub char_sets: Vec<CharSet>,
195    // --- label-index block (prefix search; empty when not computed) ---
196    /// Label-sorted `(label, subject)` table for prefix search; see [`LabelEntry`].
197    pub label_index: Vec<LabelEntry>,
198}
199
200impl PyramidMeta {
201    /// Build from a chosen round, the summary superedges, and the tiles — with an
202    /// empty schema pyramid (v1 shape). Use [`with_schema`](Self::with_schema) to
203    /// attach the v2 schema pyramid.
204    pub fn new(round: u32, summary: Vec<SuperEdge>, tiles: &[Tile]) -> Self {
205        let tiles = tiles
206            .iter()
207            .map(|t| (t.community as u32, t.encoded.clone()))
208            .collect();
209        PyramidMeta {
210            round,
211            summary,
212            tiles,
213            class_hierarchy: Vec::new(),
214            level_rollups: Vec::new(),
215            level_links: Vec::new(),
216            descriptors: Vec::new(),
217            subclass_cycles: Vec::new(),
218            disjoint_pairs: Vec::new(),
219            equivalent_pairs: Vec::new(),
220            predicate_stats: Vec::new(),
221            char_sets: Vec::new(),
222            label_index: Vec::new(),
223        }
224    }
225
226    /// Attach the v2 schema pyramid (consuming builder).
227    #[allow(clippy::too_many_arguments)]
228    pub fn with_schema(
229        mut self,
230        class_hierarchy: Vec<ClassNode>,
231        level_rollups: Vec<LevelRollup>,
232        level_links: Vec<LevelLinks>,
233        descriptors: Vec<CommunityDescriptor>,
234        subclass_cycles: Vec<Vec<String>>,
235        disjoint_pairs: Vec<(String, String)>,
236        equivalent_pairs: Vec<(String, String)>,
237    ) -> Self {
238        self.class_hierarchy = class_hierarchy;
239        self.level_rollups = level_rollups;
240        self.level_links = level_links;
241        self.descriptors = descriptors;
242        self.subclass_cycles = subclass_cycles;
243        self.disjoint_pairs = disjoint_pairs;
244        self.equivalent_pairs = equivalent_pairs;
245        self
246    }
247
248    /// Attach per-predicate query stats (consuming builder). Independent of the
249    /// schema pyramid — a typeless graph can still carry stats.
250    pub fn with_predicate_stats(mut self, predicate_stats: Vec<PredStat>) -> Self {
251        self.predicate_stats = predicate_stats;
252        self
253    }
254
255    /// Attach the characteristic sets / entity shapes (consuming builder).
256    pub fn with_char_sets(mut self, char_sets: Vec<CharSet>) -> Self {
257        self.char_sets = char_sets;
258        self
259    }
260
261    /// Attach the label index (consuming builder). Independent of the schema —
262    /// any graph with label literals can carry it. The entries should already be
263    /// sorted by the label's lowercased form (see [`Self::prefix_search`]).
264    pub fn with_label_index(mut self, label_index: Vec<LabelEntry>) -> Self {
265        self.label_index = label_index;
266        self
267    }
268
269    /// All entries whose label, compared case-insensitively, starts with
270    /// `prefix`, capped at `limit`. The index is stored sorted by the lowercased
271    /// label, so this is a binary search for the lower bound followed by a linear
272    /// walk while the prefix still matches — no literal scan. An empty `prefix`
273    /// returns the first `limit` entries.
274    pub fn prefix_search(&self, prefix: &str, limit: usize) -> Vec<&LabelEntry> {
275        let needle = prefix.to_lowercase();
276        // Lower bound: first entry whose lowercased label is >= the needle.
277        let start = self
278            .label_index
279            .partition_point(|e| e.label.to_lowercase().as_str() < needle.as_str());
280        let mut out = Vec::new();
281        for e in &self.label_index[start..] {
282            if out.len() >= limit {
283                break;
284            }
285            let lower = e.label.to_lowercase();
286            if needle.is_empty() || lower.starts_with(&needle) {
287                out.push(e);
288            } else if lower.as_str() > needle.as_str() {
289                // Past the prefix run (entries are sorted) — nothing more matches.
290                break;
291            }
292        }
293        out
294    }
295
296    /// True when no schema pyramid is present (v1 shape).
297    fn schema_is_empty(&self) -> bool {
298        self.class_hierarchy.is_empty()
299            && self.level_rollups.is_empty()
300            && self.level_links.is_empty()
301            && self.descriptors.is_empty()
302            && self.subclass_cycles.is_empty()
303            && self.disjoint_pairs.is_empty()
304            && self.equivalent_pairs.is_empty()
305    }
306
307    pub fn encode(&self) -> Vec<u8> {
308        let mut out = Vec::new();
309        write_uvarint(&mut out, self.round as u64);
310        write_uvarint(&mut out, self.summary.len() as u64);
311        for e in &self.summary {
312            write_uvarint(&mut out, e.s_comm as u64);
313            write_uvarint(&mut out, e.predicate as u64);
314            write_uvarint(&mut out, e.o_comm as u64);
315            write_uvarint(&mut out, e.count as u64);
316        }
317        write_uvarint(&mut out, self.tiles.len() as u64);
318        for (community, block) in &self.tiles {
319            write_uvarint(&mut out, *community as u64);
320            write_uvarint(&mut out, block.len() as u64);
321            out.extend_from_slice(block);
322        }
323        // query-stats block (length-prefixed, tag QSTATS_V1) — written before the
324        // schema block so the schema stays the trailing section (schema_meta_len).
325        // Present only when computed; a v1/v2 reader sees the tag, reads the length,
326        // and skips it.
327        if !self.predicate_stats.is_empty() {
328            let mut payload = Vec::new();
329            write_uvarint(&mut payload, self.predicate_stats.len() as u64);
330            for p in &self.predicate_stats {
331                write_uvarint(&mut payload, p.predicate as u64);
332                write_uvarint(&mut payload, p.count);
333                write_uvarint(&mut payload, p.distinct_subjects);
334                write_uvarint(&mut payload, p.distinct_objects);
335                write_uvarint(&mut payload, p.max_objects_per_subject as u64);
336                write_uvarint(&mut payload, p.max_subjects_per_object as u64);
337            }
338            out.push(QSTATS_V1);
339            write_uvarint(&mut out, payload.len() as u64);
340            out.extend_from_slice(&payload);
341        }
342        // characteristic-sets block (length-prefixed, tag CHARSET_V1) — after the
343        // query-stats block, still before the trailing schema block.
344        if !self.char_sets.is_empty() {
345            let mut payload = Vec::new();
346            write_uvarint(&mut payload, self.char_sets.len() as u64);
347            for c in &self.char_sets {
348                write_uvarint(&mut payload, c.predicates.len() as u64);
349                for &p in &c.predicates {
350                    write_uvarint(&mut payload, p as u64);
351                }
352                write_uvarint(&mut payload, c.subjects);
353            }
354            out.push(CHARSET_V1);
355            write_uvarint(&mut out, payload.len() as u64);
356            out.extend_from_slice(&payload);
357        }
358        // label-index block (length-prefixed, tag LABELIDX_V1) — after the
359        // characteristic-sets block, still before the trailing schema block.
360        if !self.label_index.is_empty() {
361            let mut payload = Vec::new();
362            write_uvarint(&mut payload, self.label_index.len() as u64);
363            for e in &self.label_index {
364                write_str(&mut payload, &e.label);
365                write_uvarint(&mut payload, e.subject as u64);
366            }
367            out.push(LABELIDX_V1);
368            write_uvarint(&mut out, payload.len() as u64);
369            out.extend_from_slice(&payload);
370        }
371        // v2 schema pyramid — appended only when present (a typeless graph stays
372        // byte-identical to v1, and v1 readers stop after the tiles above).
373        if !self.schema_is_empty() {
374            self.encode_schema(&mut out);
375        }
376        out
377    }
378
379    /// Encode the v2 block: a deduped class-string table, then the hierarchy,
380    /// rollups, and descriptors as indices into it.
381    fn encode_schema(&self, out: &mut Vec<u8>) {
382        // Collect every class string into a deterministic, deduped table.
383        // Interning order (first appearance) fixes the table order → reproducible.
384        let mut table: Vec<String> = Vec::new();
385        let mut index: BTreeMap<String, u32> = BTreeMap::new();
386        fn intern(table: &mut Vec<String>, index: &mut BTreeMap<String, u32>, s: &str) {
387            if !index.contains_key(s) {
388                index.insert(s.to_string(), table.len() as u32);
389                table.push(s.to_string());
390            }
391        }
392        for n in &self.class_hierarchy {
393            intern(&mut table, &mut index, &n.class);
394            for p in &n.parents {
395                intern(&mut table, &mut index, p);
396            }
397        }
398        for r in &self.level_rollups {
399            for (c, _) in &r.classes {
400                intern(&mut table, &mut index, c);
401            }
402        }
403        for l in &self.level_links {
404            for r in &l.links {
405                intern(&mut table, &mut index, &r.s_class);
406                intern(&mut table, &mut index, &r.predicate);
407                intern(&mut table, &mut index, &r.o_class);
408            }
409        }
410        for d in &self.descriptors {
411            if let Some(c) = &d.dominant_class {
412                intern(&mut table, &mut index, c);
413            }
414            for (c, _) in &d.class_counts {
415                intern(&mut table, &mut index, c);
416            }
417        }
418        // v2.1 coherence axioms must join the SAME first interning pass, or `idx`
419        // below panics on a class string that was never interned.
420        for cyc in &self.subclass_cycles {
421            for c in cyc {
422                intern(&mut table, &mut index, c);
423            }
424        }
425        for (a, b) in self.disjoint_pairs.iter().chain(&self.equivalent_pairs) {
426            intern(&mut table, &mut index, a);
427            intern(&mut table, &mut index, b);
428        }
429        let idx = |s: &str| *index.get(s).expect("interned");
430
431        out.push(SCHEMA_V2);
432        write_uvarint(out, table.len() as u64);
433        for s in &table {
434            write_str(out, s);
435        }
436        write_uvarint(out, self.class_hierarchy.len() as u64);
437        for n in &self.class_hierarchy {
438            write_uvarint(out, idx(&n.class) as u64);
439            write_uvarint(out, n.parents.len() as u64);
440            for p in &n.parents {
441                write_uvarint(out, idx(p) as u64);
442            }
443            write_uvarint(out, n.depth as u64);
444        }
445        write_uvarint(out, self.level_rollups.len() as u64);
446        for r in &self.level_rollups {
447            write_uvarint(out, r.round as u64);
448            write_uvarint(out, r.depth as u64);
449            write_uvarint(out, r.classes.len() as u64);
450            for (c, count) in &r.classes {
451                write_uvarint(out, idx(c) as u64);
452                write_uvarint(out, *count);
453            }
454        }
455        write_uvarint(out, self.level_links.len() as u64);
456        for l in &self.level_links {
457            write_uvarint(out, l.round as u64);
458            write_uvarint(out, l.depth as u64);
459            write_uvarint(out, l.links.len() as u64);
460            for r in &l.links {
461                write_uvarint(out, idx(&r.s_class) as u64);
462                write_uvarint(out, idx(&r.predicate) as u64);
463                write_uvarint(out, idx(&r.o_class) as u64);
464                write_uvarint(out, r.count);
465            }
466        }
467        write_uvarint(out, self.descriptors.len() as u64);
468        for d in &self.descriptors {
469            write_uvarint(out, d.community as u64);
470            match &d.dominant_class {
471                Some(c) => write_uvarint(out, idx(c) as u64 + 1),
472                None => write_uvarint(out, 0),
473            }
474            write_uvarint(out, d.class_counts.len() as u64);
475            for (c, count) in &d.class_counts {
476                write_uvarint(out, idx(c) as u64);
477                write_uvarint(out, *count);
478            }
479            match d.bbox {
480                Some(b) => {
481                    out.push(1);
482                    for v in b {
483                        out.extend_from_slice(&v.to_le_bytes());
484                    }
485                }
486                None => out.push(0),
487            }
488            match &d.time_range {
489                Some((from, to)) => {
490                    out.push(1);
491                    write_str(out, from);
492                    write_str(out, to);
493                }
494                None => out.push(0),
495            }
496        }
497        // --- v2.1 additive extension: subClassOf cycles + disjoint/equivalent ---
498        // Written as ONE block, only when any of the three is present, so existing
499        // v2.0 files (schema but no coherence axioms) stay byte-identical and a
500        // v2.0 reader stops cleanly after the descriptors above.
501        if !self.subclass_cycles.is_empty()
502            || !self.disjoint_pairs.is_empty()
503            || !self.equivalent_pairs.is_empty()
504        {
505            write_uvarint(out, self.subclass_cycles.len() as u64);
506            for cyc in &self.subclass_cycles {
507                write_uvarint(out, cyc.len() as u64);
508                for c in cyc {
509                    write_uvarint(out, idx(c) as u64);
510                }
511            }
512            write_uvarint(out, self.disjoint_pairs.len() as u64);
513            for (a, b) in &self.disjoint_pairs {
514                write_uvarint(out, idx(a) as u64);
515                write_uvarint(out, idx(b) as u64);
516            }
517            write_uvarint(out, self.equivalent_pairs.len() as u64);
518            for (a, b) in &self.equivalent_pairs {
519                write_uvarint(out, idx(a) as u64);
520                write_uvarint(out, idx(b) as u64);
521            }
522        }
523    }
524
525    pub fn decode(bytes: &[u8]) -> Result<Self, MetaError> {
526        let mut pos = 0;
527        let g = |pos: &mut usize| -> Result<u64, MetaError> {
528            let (v, n) = read_uvarint(&bytes[*pos..]).ok_or(MetaError::Malformed("truncated"))?;
529            *pos += n;
530            Ok(v)
531        };
532        let round = g(&mut pos)? as u32;
533        // Counts are untrusted; each entry consumes several bytes, so cap the
534        // pre-allocation at the buffer length to avoid an OOM on a bogus count.
535        let n_edges = g(&mut pos)? as usize;
536        let mut summary = Vec::with_capacity(n_edges.min(bytes.len()));
537        for _ in 0..n_edges {
538            summary.push(SuperEdge {
539                s_comm: g(&mut pos)? as usize,
540                predicate: g(&mut pos)? as u32,
541                o_comm: g(&mut pos)? as usize,
542                count: g(&mut pos)? as u32,
543            });
544        }
545        let n_tiles = g(&mut pos)? as usize;
546        let mut tiles = Vec::with_capacity(n_tiles.min(bytes.len()));
547        for _ in 0..n_tiles {
548            let community = g(&mut pos)? as u32;
549            let len = g(&mut pos)? as usize;
550            // checked_add: a corrupt `len` must not overflow-panic before the bound check.
551            let end = pos
552                .checked_add(len)
553                .filter(|&e| e <= bytes.len())
554                .ok_or(MetaError::Malformed("tile block overruns buffer"))?;
555            tiles.push((community, bytes[pos..end].to_vec()));
556            pos = end;
557        }
558
559        let mut meta = PyramidMeta {
560            round,
561            summary,
562            tiles,
563            class_hierarchy: Vec::new(),
564            level_rollups: Vec::new(),
565            level_links: Vec::new(),
566            descriptors: Vec::new(),
567            subclass_cycles: Vec::new(),
568            disjoint_pairs: Vec::new(),
569            equivalent_pairs: Vec::new(),
570            predicate_stats: Vec::new(),
571            char_sets: Vec::new(),
572            label_index: Vec::new(),
573        };
574        // query-stats block (before the schema block) — tag, uvarint length, then
575        // the payload. The length lets a reader that doesn't know it skip past;
576        // we read it and jump to the block end regardless.
577        if pos < bytes.len() && bytes[pos] == QSTATS_V1 {
578            pos += 1;
579            let len = g(&mut pos)? as usize;
580            let end = pos
581                .checked_add(len)
582                .filter(|&e| e <= bytes.len())
583                .ok_or(MetaError::Malformed("query-stats block overruns buffer"))?;
584            let mut p = pos;
585            let np = g(&mut p)? as usize;
586            let mut stats = Vec::with_capacity(np.min(bytes.len()));
587            for _ in 0..np {
588                stats.push(PredStat {
589                    predicate: g(&mut p)? as u32,
590                    count: g(&mut p)?,
591                    distinct_subjects: g(&mut p)?,
592                    distinct_objects: g(&mut p)?,
593                    max_objects_per_subject: g(&mut p)? as u32,
594                    max_subjects_per_object: g(&mut p)? as u32,
595                });
596            }
597            meta.predicate_stats = stats;
598            pos = end;
599        }
600        // characteristic-sets block (after query-stats, before schema).
601        if pos < bytes.len() && bytes[pos] == CHARSET_V1 {
602            pos += 1;
603            let len = g(&mut pos)? as usize;
604            let end = pos
605                .checked_add(len)
606                .filter(|&e| e <= bytes.len())
607                .ok_or(MetaError::Malformed("char-sets block overruns buffer"))?;
608            let mut p = pos;
609            let nc = g(&mut p)? as usize;
610            let mut sets = Vec::with_capacity(nc.min(bytes.len()));
611            for _ in 0..nc {
612                let np = g(&mut p)? as usize;
613                let mut predicates = Vec::with_capacity(np.min(bytes.len()));
614                for _ in 0..np {
615                    predicates.push(g(&mut p)? as u32);
616                }
617                let subjects = g(&mut p)?;
618                sets.push(CharSet {
619                    predicates,
620                    subjects,
621                });
622            }
623            meta.char_sets = sets;
624            pos = end;
625        }
626        // label-index block (after characteristic-sets, before schema).
627        if pos < bytes.len() && bytes[pos] == LABELIDX_V1 {
628            pos += 1;
629            let len = g(&mut pos)? as usize;
630            let end = pos
631                .checked_add(len)
632                .filter(|&e| e <= bytes.len())
633                .ok_or(MetaError::Malformed("label-index block overruns buffer"))?;
634            let mut p = pos;
635            let nl = g(&mut p)? as usize;
636            let mut entries = Vec::with_capacity(nl.min(bytes.len()));
637            for _ in 0..nl {
638                let label = read_str(bytes, &mut p)?;
639                let subject = g(&mut p)? as u32;
640                entries.push(LabelEntry { label, subject });
641            }
642            meta.label_index = entries;
643            pos = end;
644        }
645        // v2 schema pyramid — present iff there are trailing bytes tagged
646        // SCHEMA_V2. Best-effort: the v1 fields (round/summary/tiles) are already
647        // populated, and `decode_schema` only assigns the schema fields on full
648        // success, so a malformed/ambiguous trailing region leaves the schema
649        // empty rather than failing the whole decode. (A genuine v1 file has no
650        // trailing bytes at all, so this branch never fires for it.)
651        if pos < bytes.len() && bytes[pos] == SCHEMA_V2 {
652            let mut p = pos + 1;
653            let _ = decode_schema(bytes, &mut p, &mut meta);
654        }
655        Ok(meta)
656    }
657}
658
659/// Decode the v2 block into `meta` (called only when the tag byte matched).
660fn decode_schema(bytes: &[u8], pos: &mut usize, meta: &mut PyramidMeta) -> Result<(), MetaError> {
661    let g = |pos: &mut usize| -> Result<u64, MetaError> {
662        let (v, n) = read_uvarint(&bytes[*pos..]).ok_or(MetaError::Malformed("truncated v2"))?;
663        *pos += n;
664        Ok(v)
665    };
666    let n_table = g(pos)? as usize;
667    let mut table = Vec::with_capacity(n_table.min(bytes.len()));
668    for _ in 0..n_table {
669        table.push(read_str(bytes, pos)?);
670    }
671    let lookup = |idx: u64| -> Result<String, MetaError> {
672        table
673            .get(idx as usize)
674            .cloned()
675            .ok_or(MetaError::Malformed("class index out of range"))
676    };
677
678    let n_hier = g(pos)? as usize;
679    let mut class_hierarchy = Vec::with_capacity(n_hier.min(bytes.len()));
680    for _ in 0..n_hier {
681        let class = lookup(g(pos)?)?;
682        let n_parents = g(pos)? as usize;
683        let mut parents = Vec::with_capacity(n_parents.min(bytes.len()));
684        for _ in 0..n_parents {
685            parents.push(lookup(g(pos)?)?);
686        }
687        let depth = g(pos)? as u16;
688        class_hierarchy.push(ClassNode {
689            class,
690            parents,
691            depth,
692        });
693    }
694
695    let n_roll = g(pos)? as usize;
696    let mut level_rollups = Vec::with_capacity(n_roll.min(bytes.len()));
697    for _ in 0..n_roll {
698        let round = g(pos)? as u32;
699        let depth = g(pos)? as u16;
700        let n = g(pos)? as usize;
701        let mut classes = Vec::with_capacity(n.min(bytes.len()));
702        for _ in 0..n {
703            let c = lookup(g(pos)?)?;
704            let count = g(pos)?;
705            classes.push((c, count));
706        }
707        level_rollups.push(LevelRollup {
708            round,
709            depth,
710            classes,
711        });
712    }
713
714    let n_links = g(pos)? as usize;
715    let mut level_links = Vec::with_capacity(n_links.min(bytes.len()));
716    for _ in 0..n_links {
717        let round = g(pos)? as u32;
718        let depth = g(pos)? as u16;
719        let n = g(pos)? as usize;
720        let mut links = Vec::with_capacity(n.min(bytes.len()));
721        for _ in 0..n {
722            let s_class = lookup(g(pos)?)?;
723            let predicate = lookup(g(pos)?)?;
724            let o_class = lookup(g(pos)?)?;
725            let count = g(pos)?;
726            links.push(ClassRelation {
727                s_class,
728                predicate,
729                o_class,
730                count,
731            });
732        }
733        level_links.push(LevelLinks {
734            round,
735            depth,
736            links,
737        });
738    }
739
740    let n_desc = g(pos)? as usize;
741    let mut descriptors = Vec::with_capacity(n_desc.min(bytes.len()));
742    for _ in 0..n_desc {
743        let community = g(pos)? as u32;
744        let dom_plus1 = g(pos)?;
745        let dominant_class = if dom_plus1 == 0 {
746            None
747        } else {
748            Some(lookup(dom_plus1 - 1)?)
749        };
750        let n = g(pos)? as usize;
751        let mut class_counts = Vec::with_capacity(n.min(bytes.len()));
752        for _ in 0..n {
753            let c = lookup(g(pos)?)?;
754            let count = g(pos)?;
755            class_counts.push((c, count));
756        }
757        let bbox = if read_u8(bytes, pos)? == 1 {
758            let mut b = [0f64; 4];
759            for v in b.iter_mut() {
760                *v = read_f64(bytes, pos)?;
761            }
762            Some(b)
763        } else {
764            None
765        };
766        let time_range = if read_u8(bytes, pos)? == 1 {
767            let from = read_str(bytes, pos)?;
768            let to = read_str(bytes, pos)?;
769            Some((from, to))
770        } else {
771            None
772        };
773        descriptors.push(CommunityDescriptor {
774            community,
775            dominant_class,
776            class_counts,
777            bbox,
778            time_range,
779        });
780    }
781
782    // Assign the v2.0 fields BEFORE the best-effort v2.1 extension, so a truncated
783    // or v2.0-only file keeps its fully-decoded hierarchy/rollups/links/descriptors
784    // (the extension reads below must never discard what already decoded cleanly).
785    meta.class_hierarchy = class_hierarchy;
786    meta.level_rollups = level_rollups;
787    meta.level_links = level_links;
788    meta.descriptors = descriptors;
789
790    // --- v2.1 additive extension: cycles + disjoint/equivalent pairs ---
791    // A v2.0 file ends exactly here, so stop cleanly at EOF before any new read.
792    if *pos >= bytes.len() {
793        return Ok(());
794    }
795    let n_cycles = g(pos)? as usize;
796    let mut subclass_cycles = Vec::with_capacity(n_cycles.min(bytes.len()));
797    for _ in 0..n_cycles {
798        let k = g(pos)? as usize;
799        let mut members = Vec::with_capacity(k.min(bytes.len()));
800        for _ in 0..k {
801            members.push(lookup(g(pos)?)?);
802        }
803        subclass_cycles.push(members);
804    }
805    let n_disjoint = g(pos)? as usize;
806    let mut disjoint_pairs = Vec::with_capacity(n_disjoint.min(bytes.len()));
807    for _ in 0..n_disjoint {
808        let a = lookup(g(pos)?)?;
809        let b = lookup(g(pos)?)?;
810        disjoint_pairs.push((a, b));
811    }
812    let n_equiv = g(pos)? as usize;
813    let mut equivalent_pairs = Vec::with_capacity(n_equiv.min(bytes.len()));
814    for _ in 0..n_equiv {
815        let a = lookup(g(pos)?)?;
816        let b = lookup(g(pos)?)?;
817        equivalent_pairs.push((a, b));
818    }
819    meta.subclass_cycles = subclass_cycles;
820    meta.disjoint_pairs = disjoint_pairs;
821    meta.equivalent_pairs = equivalent_pairs;
822    Ok(())
823}
824
825/// Byte length of the trailing schema-pyramid block within an encoded pyramid-meta
826/// (0 when there is none). The writer records this in the header so a reader can
827/// fetch *only* the schema block (`schema_meta_len` bytes at the end of pyramid-meta)
828/// for a summary-free, dictionary-free Tier-0 read. Re-walks round + summary + tiles
829/// to find where the schema block begins; any malformed prefix yields 0 (the reader
830/// then safely falls back to decoding the whole section).
831pub fn schema_block_len(bytes: &[u8]) -> u32 {
832    fn walk(bytes: &[u8]) -> Option<usize> {
833        let mut pos = 0usize;
834        macro_rules! uv {
835            () => {{
836                let (v, n) = read_uvarint(bytes.get(pos..)?)?;
837                pos += n;
838                v
839            }};
840        }
841        let _round = uv!();
842        let n_edges = uv!() as usize;
843        for _ in 0..n_edges {
844            uv!();
845            uv!();
846            uv!();
847            uv!();
848        }
849        let n_tiles = uv!() as usize;
850        for _ in 0..n_tiles {
851            let _comm = uv!();
852            let len = uv!() as usize;
853            pos = pos.checked_add(len)?;
854            if pos > bytes.len() {
855                return None;
856            }
857        }
858        // Skip the optional query-stats, characteristic-sets and label-index
859        // blocks (each tag + uvarint length + payload) so the schema stays trailing.
860        for tag in [QSTATS_V1, CHARSET_V1, LABELIDX_V1] {
861            if pos < bytes.len() && bytes[pos] == tag {
862                pos += 1;
863                let len = uv!() as usize;
864                pos = pos.checked_add(len)?;
865                if pos > bytes.len() {
866                    return None;
867                }
868            }
869        }
870        Some(pos)
871    }
872    match walk(bytes) {
873        Some(start) if start < bytes.len() && bytes[start] == SCHEMA_V2 => {
874            (bytes.len() - start) as u32
875        }
876        _ => 0,
877    }
878}
879
880/// Decode a STANDALONE schema-pyramid block — the `schema_meta_len` bytes the header
881/// points at, beginning with the `SCHEMA_V2` tag — into just the schema fields,
882/// without the surrounding round/summary/tiles. Powers the index/dictionary/summary
883/// -free Tier-0 coherence read.
884#[allow(clippy::type_complexity)]
885pub fn decode_schema_block(
886    block: &[u8],
887) -> Result<
888    (
889        Vec<ClassNode>,
890        Vec<Vec<String>>,
891        Vec<(String, String)>,
892        Vec<(String, String)>,
893    ),
894    MetaError,
895> {
896    if block.first() != Some(&SCHEMA_V2) {
897        return Err(MetaError::Malformed("not a schema block"));
898    }
899    let mut meta = PyramidMeta {
900        round: 0,
901        summary: Vec::new(),
902        tiles: Vec::new(),
903        class_hierarchy: Vec::new(),
904        level_rollups: Vec::new(),
905        level_links: Vec::new(),
906        descriptors: Vec::new(),
907        subclass_cycles: Vec::new(),
908        disjoint_pairs: Vec::new(),
909        equivalent_pairs: Vec::new(),
910        predicate_stats: Vec::new(),
911        char_sets: Vec::new(),
912        label_index: Vec::new(),
913    };
914    let mut pos = 1; // skip the SCHEMA_V2 tag
915    decode_schema(block, &mut pos, &mut meta)?;
916    Ok((
917        meta.class_hierarchy,
918        meta.subclass_cycles,
919        meta.disjoint_pairs,
920        meta.equivalent_pairs,
921    ))
922}
923
924/// Decode the same standalone schema block as [`decode_schema_block`] but return
925/// the **schema summary** — the per-class instance histogram and the class-to-class
926/// relations at the finest (leaf) semantic-zoom level. This is the index-free,
927/// range-readable source for a Schema view of a remote graph: `(classes, relations)`
928/// where `classes = [(class_iri, count)]` and `relations = [(s_class, predicate,
929/// o_class, count)]`.
930#[allow(clippy::type_complexity)]
931pub fn decode_schema_block_summary(
932    block: &[u8],
933) -> Result<(Vec<(String, u64)>, Vec<(String, String, String, u64)>), MetaError> {
934    if block.first() != Some(&SCHEMA_V2) {
935        return Err(MetaError::Malformed("not a schema block"));
936    }
937    let mut meta = PyramidMeta {
938        round: 0,
939        summary: Vec::new(),
940        tiles: Vec::new(),
941        class_hierarchy: Vec::new(),
942        level_rollups: Vec::new(),
943        level_links: Vec::new(),
944        descriptors: Vec::new(),
945        subclass_cycles: Vec::new(),
946        disjoint_pairs: Vec::new(),
947        equivalent_pairs: Vec::new(),
948        predicate_stats: Vec::new(),
949        char_sets: Vec::new(),
950        label_index: Vec::new(),
951    };
952    let mut pos = 1;
953    decode_schema(block, &mut pos, &mut meta)?;
954    // Finest level = largest depth (leaves); resolves abstract rollups to real classes.
955    let classes = meta
956        .level_rollups
957        .iter()
958        .max_by_key(|r| r.depth)
959        .map(|r| r.classes.clone())
960        .unwrap_or_default();
961    let relations = meta
962        .level_links
963        .iter()
964        .max_by_key(|l| l.depth)
965        .map(|l| {
966            l.links
967                .iter()
968                .map(|c| {
969                    (
970                        c.s_class.clone(),
971                        c.predicate.clone(),
972                        c.o_class.clone(),
973                        c.count,
974                    )
975                })
976                .collect()
977        })
978        .unwrap_or_default();
979    Ok((classes, relations))
980}
981
982fn write_str(out: &mut Vec<u8>, s: &str) {
983    write_uvarint(out, s.len() as u64);
984    out.extend_from_slice(s.as_bytes());
985}
986
987fn read_str(bytes: &[u8], pos: &mut usize) -> Result<String, MetaError> {
988    let (len, n) = read_uvarint(&bytes[*pos..]).ok_or(MetaError::Malformed("truncated str len"))?;
989    *pos += n;
990    let len = len as usize;
991    let end = pos
992        .checked_add(len)
993        .filter(|&e| e <= bytes.len())
994        .ok_or(MetaError::Malformed("string overruns buffer"))?;
995    let s = std::str::from_utf8(&bytes[*pos..end])
996        .map_err(|_| MetaError::Malformed("invalid utf8"))?
997        .to_string();
998    *pos = end;
999    Ok(s)
1000}
1001
1002fn read_u8(bytes: &[u8], pos: &mut usize) -> Result<u8, MetaError> {
1003    let b = *bytes
1004        .get(*pos)
1005        .ok_or(MetaError::Malformed("truncated u8"))?;
1006    *pos += 1;
1007    Ok(b)
1008}
1009
1010fn read_f64(bytes: &[u8], pos: &mut usize) -> Result<f64, MetaError> {
1011    let end = pos
1012        .checked_add(8)
1013        .filter(|&e| e <= bytes.len())
1014        .ok_or(MetaError::Malformed("truncated f64"))?;
1015    let arr: [u8; 8] = bytes[*pos..end].try_into().unwrap();
1016    *pos = end;
1017    Ok(f64::from_le_bytes(arr))
1018}
1019
1020#[cfg(test)]
1021mod tests {
1022    use super::*;
1023
1024    fn sample_v1() -> PyramidMeta {
1025        let summary = vec![
1026            SuperEdge {
1027                s_comm: 0,
1028                predicate: 5,
1029                o_comm: 0,
1030                count: 4,
1031            },
1032            SuperEdge {
1033                s_comm: 0,
1034                predicate: 5,
1035                o_comm: 1,
1036                count: 1,
1037            },
1038        ];
1039        let tiles = vec![
1040            Tile {
1041                community: 0,
1042                triples: vec![],
1043                encoded: vec![1, 2, 3],
1044            },
1045            Tile {
1046                community: 1,
1047                triples: vec![],
1048                encoded: vec![9, 8],
1049            },
1050        ];
1051        PyramidMeta::new(2, summary, &tiles)
1052    }
1053
1054    #[test]
1055    fn meta_round_trips() {
1056        let meta = sample_v1();
1057        let bytes = meta.encode();
1058        let back = PyramidMeta::decode(&bytes).unwrap();
1059        assert_eq!(meta, back);
1060        assert_eq!(back.round, 2);
1061        assert_eq!(back.tiles[0], (0, vec![1, 2, 3]));
1062        assert!(back.class_hierarchy.is_empty(), "v1 shape has no schema");
1063    }
1064
1065    #[test]
1066    fn empty_meta() {
1067        let meta = PyramidMeta::new(0, vec![], &[]);
1068        assert_eq!(PyramidMeta::decode(&meta.encode()).unwrap(), meta);
1069    }
1070
1071    #[test]
1072    fn v1_encoding_is_unchanged_without_schema() {
1073        // A schema-less meta must encode exactly as before — no trailing v2 byte —
1074        // so typeless files stay byte-identical across the v1→v2 upgrade.
1075        let meta = sample_v1();
1076        let bytes = meta.encode();
1077        // The last byte is the final tile block byte (8), not a schema tag.
1078        assert_eq!(*bytes.last().unwrap(), 8);
1079        assert_ne!(*bytes.last().unwrap(), SCHEMA_V2);
1080    }
1081
1082    #[test]
1083    fn schema_pyramid_round_trips() {
1084        let mut meta = sample_v1();
1085        meta = meta.with_schema(
1086            vec![
1087                ClassNode {
1088                    class: "<http://ex/Agent>".into(),
1089                    parents: vec![],
1090                    depth: 0,
1091                },
1092                ClassNode {
1093                    // A non-exclusive node: two parents (multiple inheritance).
1094                    class: "<http://ex/Astronaut>".into(),
1095                    parents: vec!["<http://ex/Explorer>".into(), "<http://ex/Person>".into()],
1096                    depth: 2,
1097                },
1098            ],
1099            vec![
1100                LevelRollup {
1101                    round: 1,
1102                    depth: 0,
1103                    classes: vec![("<http://ex/Agent>".into(), 12)],
1104                },
1105                LevelRollup {
1106                    round: 0,
1107                    depth: 1,
1108                    classes: vec![
1109                        ("<http://ex/Person>".into(), 9),
1110                        ("<http://ex/Agent>".into(), 3),
1111                    ],
1112                },
1113            ],
1114            vec![LevelLinks {
1115                round: 1,
1116                depth: 0,
1117                links: vec![ClassRelation {
1118                    s_class: "<http://ex/Agent>".into(),
1119                    predicate: "<http://ex/memberOf>".into(),
1120                    o_class: "<http://ex/Agent>".into(),
1121                    count: 6,
1122                }],
1123            }],
1124            vec![CommunityDescriptor {
1125                community: 7,
1126                dominant_class: Some("<http://ex/Person>".into()),
1127                class_counts: vec![("<http://ex/Person>".into(), 5)],
1128                bbox: Some([-10.0, 40.0, 12.5, 51.2]),
1129                time_range: Some(("1700".into(), "1900".into())),
1130            }],
1131            vec![],
1132            vec![],
1133            vec![],
1134        );
1135        let bytes = meta.encode();
1136        // The v2 block is appended → last byte is NOT the v1 tile byte.
1137        let back = PyramidMeta::decode(&bytes).unwrap();
1138        assert_eq!(meta, back);
1139        assert_eq!(back.level_rollups.len(), 2);
1140        // Non-exclusive hierarchy: both parents survive the round-trip.
1141        assert_eq!(back.class_hierarchy[1].parents.len(), 2);
1142        // Lateral connections round-trip.
1143        assert_eq!(
1144            back.level_links[0].links[0].predicate,
1145            "<http://ex/memberOf>"
1146        );
1147        assert_eq!(back.descriptors[0].bbox, Some([-10.0, 40.0, 12.5, 51.2]));
1148        assert_eq!(
1149            back.descriptors[0].time_range,
1150            Some(("1700".into(), "1900".into()))
1151        );
1152    }
1153
1154    #[test]
1155    fn coherence_axioms_round_trip_and_stay_additive() {
1156        // Hierarchy names all the classes the axioms reference, so the interned
1157        // string table (written at the start of the schema block) is identical with
1158        // or without the axioms — the extension is then a pure byte append.
1159        let hier = || {
1160            vec![
1161                ClassNode {
1162                    class: "<http://ex/C>".into(),
1163                    parents: vec![],
1164                    depth: 0,
1165                },
1166                ClassNode {
1167                    class: "<http://ex/D>".into(),
1168                    parents: vec![],
1169                    depth: 0,
1170                },
1171                ClassNode {
1172                    class: "<http://ex/E>".into(),
1173                    parents: vec![],
1174                    depth: 0,
1175                },
1176            ]
1177        };
1178        // v2.0: hierarchy only, no coherence axioms.
1179        let v20 = sample_v1()
1180            .with_schema(hier(), vec![], vec![], vec![], vec![], vec![], vec![])
1181            .encode();
1182        // v2.1: same hierarchy + axioms over hierarchy classes → table unchanged.
1183        let full = sample_v1().with_schema(
1184            hier(),
1185            vec![],
1186            vec![],
1187            vec![],
1188            vec![],
1189            vec![("<http://ex/D>".into(), "<http://ex/E>".into())],
1190            vec![],
1191        );
1192        let v21 = full.encode();
1193        assert!(
1194            v21.starts_with(&v20),
1195            "the extension is appended; the v2.0 prefix is byte-identical"
1196        );
1197        assert!(v21.len() > v20.len());
1198
1199        let back = PyramidMeta::decode(&v21).unwrap();
1200        assert_eq!(back, full, "the coherence axioms round-trip");
1201        assert_eq!(
1202            back.disjoint_pairs,
1203            vec![("<http://ex/D>".to_string(), "<http://ex/E>".to_string())]
1204        );
1205
1206        // The v2.0 bytes (no extension) decode with empty axioms AND an intact
1207        // hierarchy — the hoist fix: a missing extension must not discard v2.0.
1208        let back20 = PyramidMeta::decode(&v20).unwrap();
1209        assert!(back20.disjoint_pairs.is_empty());
1210        assert!(back20.subclass_cycles.is_empty());
1211        assert_eq!(back20.class_hierarchy.len(), 3, "v2.0 hierarchy intact");
1212    }
1213
1214    #[test]
1215    fn v2_is_readable_as_v1_prefix() {
1216        // A v2 file's leading bytes are exactly the v1 encoding, so an old reader
1217        // that stops after the tiles loop still recovers round/summary/tiles.
1218        let mut meta = sample_v1();
1219        let v1_bytes = meta.encode();
1220        meta = meta.with_schema(
1221            vec![ClassNode {
1222                class: "<http://ex/C>".into(),
1223                parents: vec![],
1224                depth: 0,
1225            }],
1226            vec![LevelRollup {
1227                round: 0,
1228                depth: 0,
1229                classes: vec![("<http://ex/C>".into(), 1)],
1230            }],
1231            vec![],
1232            vec![],
1233            vec![],
1234            vec![],
1235            vec![],
1236        );
1237        let v2_bytes = meta.encode();
1238        assert!(
1239            v2_bytes.starts_with(&v1_bytes),
1240            "v2 extends v1 byte-for-byte"
1241        );
1242        assert_eq!(v2_bytes[v1_bytes.len()], SCHEMA_V2, "v2 tag follows");
1243    }
1244
1245    #[test]
1246    fn query_stats_round_trip_and_stay_additive() {
1247        let v1 = sample_v1().encode();
1248        let stats = vec![
1249            PredStat {
1250                predicate: 5,
1251                count: 100,
1252                distinct_subjects: 40,
1253                distinct_objects: 25,
1254                max_objects_per_subject: 6,
1255                max_subjects_per_object: 9,
1256            },
1257            PredStat {
1258                predicate: 9,
1259                count: 3,
1260                distinct_subjects: 3,
1261                distinct_objects: 1,
1262                max_objects_per_subject: 1,
1263                max_subjects_per_object: 3,
1264            },
1265        ];
1266        let bytes = sample_v1().with_predicate_stats(stats.clone()).encode();
1267        // The qstats block is appended after the v1 section → v1 prefix unchanged.
1268        assert!(bytes.starts_with(&v1), "query-stats is appended after v1");
1269        assert_eq!(bytes[v1.len()], QSTATS_V1, "the qstats tag follows v1");
1270        let back = PyramidMeta::decode(&bytes).unwrap();
1271        assert_eq!(back.predicate_stats, stats);
1272        assert_eq!(back.round, 2, "v1 fields intact");
1273        assert_eq!(back.tiles[0], (0, vec![1, 2, 3]));
1274    }
1275
1276    #[test]
1277    fn query_stats_before_schema_keeps_schema_trailing() {
1278        // With BOTH stats and a schema block, schema_block_len must skip the
1279        // qstats block and still find the trailing schema block.
1280        let stats = vec![PredStat {
1281            predicate: 1,
1282            count: 9,
1283            distinct_subjects: 3,
1284            distinct_objects: 3,
1285            max_objects_per_subject: 3,
1286            max_subjects_per_object: 3,
1287        }];
1288        let bytes = sample_v1()
1289            .with_schema(
1290                vec![ClassNode {
1291                    class: "<http://ex/C>".into(),
1292                    parents: vec![],
1293                    depth: 0,
1294                }],
1295                vec![LevelRollup {
1296                    round: 0,
1297                    depth: 0,
1298                    classes: vec![("<http://ex/C>".into(), 1)],
1299                }],
1300                vec![],
1301                vec![],
1302                vec![],
1303                vec![],
1304                vec![],
1305            )
1306            .with_predicate_stats(stats.clone())
1307            .encode();
1308        let back = PyramidMeta::decode(&bytes).unwrap();
1309        assert_eq!(back.predicate_stats, stats);
1310        assert_eq!(back.class_hierarchy.len(), 1, "schema decoded past qstats");
1311        let len = schema_block_len(&bytes) as usize;
1312        assert!(len > 0);
1313        assert_eq!(
1314            bytes[bytes.len() - len],
1315            SCHEMA_V2,
1316            "schema is still the trailing block"
1317        );
1318    }
1319
1320    #[test]
1321    fn char_sets_coexist_with_query_stats_and_schema() {
1322        let sets = vec![
1323            CharSet {
1324                predicates: vec![1, 5, 9],
1325                subjects: 100,
1326            },
1327            CharSet {
1328                predicates: vec![1, 5],
1329                subjects: 40,
1330            },
1331        ];
1332        let stats = vec![PredStat {
1333            predicate: 1,
1334            count: 9,
1335            distinct_subjects: 3,
1336            distinct_objects: 3,
1337            max_objects_per_subject: 3,
1338            max_subjects_per_object: 3,
1339        }];
1340        let bytes = sample_v1()
1341            .with_predicate_stats(stats.clone())
1342            .with_char_sets(sets.clone())
1343            .with_schema(
1344                vec![ClassNode {
1345                    class: "<http://ex/C>".into(),
1346                    parents: vec![],
1347                    depth: 0,
1348                }],
1349                vec![LevelRollup {
1350                    round: 0,
1351                    depth: 0,
1352                    classes: vec![("<http://ex/C>".into(), 1)],
1353                }],
1354                vec![],
1355                vec![],
1356                vec![],
1357                vec![],
1358                vec![],
1359            )
1360            .encode();
1361        let back = PyramidMeta::decode(&bytes).unwrap();
1362        assert_eq!(back.predicate_stats, stats);
1363        assert_eq!(back.char_sets, sets);
1364        assert_eq!(
1365            back.class_hierarchy.len(),
1366            1,
1367            "schema decoded past both blocks"
1368        );
1369        let len = schema_block_len(&bytes) as usize;
1370        assert_eq!(bytes[bytes.len() - len], SCHEMA_V2, "schema still trailing");
1371    }
1372
1373    #[test]
1374    fn label_index_round_trips_and_prefix_searches() {
1375        // Sorted by lowercased label (case-insensitive), as the builder emits.
1376        let labels = vec![
1377            LabelEntry {
1378                label: "Alanine".into(),
1379                subject: 10,
1380            },
1381            LabelEntry {
1382                label: "alpha-D-glucose".into(),
1383                subject: 11,
1384            },
1385            LabelEntry {
1386                label: "Benzene".into(),
1387                subject: 12,
1388            },
1389        ];
1390        let meta = sample_v1().with_label_index(labels.clone());
1391        let bytes = meta.encode();
1392        let back = PyramidMeta::decode(&bytes).unwrap();
1393        assert_eq!(back.label_index, labels, "label index survives round trip");
1394
1395        // Case-insensitive prefix: "al" matches Alanine + alpha-D-glucose, in order.
1396        let hits = back.prefix_search("al", 10);
1397        assert_eq!(
1398            hits.iter().map(|e| e.subject).collect::<Vec<_>>(),
1399            vec![10, 11]
1400        );
1401        // A more specific prefix narrows to one; the walk stops at the run's end.
1402        let hits = back.prefix_search("BEN", 10);
1403        assert_eq!(hits.len(), 1);
1404        assert_eq!(hits[0].subject, 12);
1405        // No match → empty.
1406        assert!(back.prefix_search("zzz", 10).is_empty());
1407        // The limit caps the result.
1408        assert_eq!(back.prefix_search("", 2).len(), 2);
1409    }
1410
1411    #[test]
1412    fn label_index_stays_additive_before_schema() {
1413        let labels = vec![LabelEntry {
1414            label: "x".into(),
1415            subject: 1,
1416        }];
1417        let stats = vec![PredStat {
1418            predicate: 1,
1419            count: 1,
1420            distinct_subjects: 1,
1421            distinct_objects: 1,
1422            max_objects_per_subject: 1,
1423            max_subjects_per_object: 1,
1424        }];
1425        let sets = vec![CharSet {
1426            predicates: vec![1],
1427            subjects: 1,
1428        }];
1429        let bytes = sample_v1()
1430            .with_predicate_stats(stats.clone())
1431            .with_char_sets(sets.clone())
1432            .with_label_index(labels.clone())
1433            .with_schema(
1434                vec![ClassNode {
1435                    class: "<http://ex/C>".into(),
1436                    parents: vec![],
1437                    depth: 0,
1438                }],
1439                vec![],
1440                vec![],
1441                vec![],
1442                vec![],
1443                vec![],
1444                vec![],
1445            )
1446            .encode();
1447        let back = PyramidMeta::decode(&bytes).unwrap();
1448        assert_eq!(back.predicate_stats, stats);
1449        assert_eq!(back.char_sets, sets);
1450        assert_eq!(back.label_index, labels);
1451        assert_eq!(
1452            back.class_hierarchy.len(),
1453            1,
1454            "schema decoded past 3 blocks"
1455        );
1456        let len = schema_block_len(&bytes) as usize;
1457        assert_eq!(
1458            bytes[bytes.len() - len],
1459            SCHEMA_V2,
1460            "schema still trailing past the label index"
1461        );
1462    }
1463}