Skip to main content

shifty_engine/
frozen.rs

1//! Dictionary-encoded RDF dataset shared by inference's native and fallback
2//! evaluators. A committed inference batch updates its indexes before the next
3//! rule group reads them. Validation selects its own default-graph view over
4//! the final inference snapshot without rebuilding the dataset.
5//!
6//! This file is the read-mostly dataset boundary. Source terms and predicate
7//! partitions are shared across sessions. Session-local terms extend the source
8//! dictionary; a complete PSO primary index retains every local triple, while
9//! optional secondary indexes accelerate selected access patterns. Evaluation
10//! stays in IDs until a result crosses back into RDF terms.
11//!
12//! Query-only terms and reachability results may be cached behind interior
13//! mutability, but neither changes the RDF snapshot. The only real mutation,
14//! `extend_triples`, updates affected indexes/statistics and discards derived closures.
15//! Thus every scan sees one coherent snapshot and every cached closure belongs
16//! to that snapshot.
17
18use std::cell::RefCell;
19use std::collections::{BTreeMap, HashMap, HashSet};
20use std::convert::Infallible;
21use std::rc::Rc;
22use std::sync::Arc;
23use std::sync::OnceLock;
24use std::sync::atomic::{AtomicU32, AtomicUsize, Ordering};
25
26use crate::path_plan::ReachStep;
27use crate::profile::IndexRecord;
28use oxrdf::{BlankNode, Graph, NamedNode, Term};
29use shifty_opt::{AccessCatalog, ClosureKind};
30use spareval::{InternalQuad, QueryableDataset};
31use web_time::Instant;
32
33/// IRI under which the shapes graph is loaded into the named-graph slot.
34pub(crate) const SHAPES_GRAPH_IRI: &str = "urn:x-shacl:shapes-graph";
35
36/// Dictionary index for a term. Chosen as `u32` to keep triple arrays at 12
37/// bytes each. Allocation checks the capacity rather than truncating ids.
38pub type TermId = u32;
39
40/// Which graph a scan reads from.
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
42pub(crate) enum GraphSel {
43    /// The default (data) graph.
44    Default,
45    /// A specific named graph, identified by its graph-IRI's `TermId`.
46    Named(TermId),
47}
48
49// ── Term dictionary ──────────────────────────────────────────────────────────
50
51struct TermDictInner {
52    id_to_term: Vec<Term>,
53    term_to_id: HashMap<Term, TermId>,
54}
55
56/// Bidirectional Term ↔ TermId map. Interior-mutable so that
57/// `QueryableDataset::internalize_term` (which takes `&self`) can lazily
58/// assign IDs to query constants that did not appear in the loaded triples.
59struct TermDictionary {
60    source: Option<Arc<SourceStorage>>,
61    local: RefCell<TermDictInner>,
62    /// Alpha-renames data blank nodes that collide with an independently
63    /// parsed source document. Repeated occurrences keep one session-local ID.
64    local_blank_aliases: RefCell<HashMap<BlankNode, BlankNode>>,
65}
66
67/// Immutable source IDs and indexes shared by sessions of one compilation.
68/// Query constants and data terms are never inserted here.
69pub(crate) struct SourceStorage {
70    id_to_term: Vec<Term>,
71    term_to_id: HashMap<Term, TermId>,
72    index: Arc<TripleIndex>,
73    reverse_demand: Vec<NamedNode>,
74    subject_demand: bool,
75    object_demand: bool,
76}
77
78impl SourceStorage {
79    #[cfg(test)]
80    pub(crate) fn encode(graph: &Graph) -> Arc<Self> {
81        Self::encode_with_demand(graph, &AccessCatalog::default())
82    }
83
84    pub(crate) fn encode_with_demand(graph: &Graph, catalog: &AccessCatalog) -> Arc<Self> {
85        let started = crate::profile::is_enabled().then(Instant::now);
86        let terms = TermDictionary::new();
87        let triples = intern_graph(graph, &terms);
88        let reverse_demand: HashSet<_> = catalog
89            .consumers
90            .iter()
91            .filter(|consumer| consumer.default.probes.reverse || consumer.shapes.probes.reverse)
92            .flat_map(|consumer| {
93                consumer
94                    .default
95                    .predicates
96                    .iter()
97                    .chain(&consumer.shapes.predicates)
98            })
99            .cloned()
100            .collect();
101        let source_demand = reverse_demand
102            .iter()
103            .filter_map(|p| terms.get(&Term::NamedNode(p.clone())))
104            .collect();
105        let predicate_labels = predicate_labels(&triples, &terms);
106        let subject_demand = catalog.consumers.iter().any(|consumer| {
107            (consumer.default.any_predicate && consumer.default.probes.forward)
108                || (consumer.shapes.any_predicate && consumer.shapes.probes.forward)
109        });
110        let object_demand = catalog.consumers.iter().any(|consumer| {
111            (consumer.default.any_predicate && consumer.default.probes.reverse)
112                || (consumer.shapes.any_predicate && consumer.shapes.probes.reverse)
113        });
114        let index = Arc::new(TripleIndex::build_with_scope(
115            triples,
116            IndexPolicy::DemandDriven,
117            SOURCE_INDEX_BUDGET,
118            source_demand,
119            subject_demand,
120            object_demand,
121            IndexScope::Source,
122            predicate_labels,
123        ));
124        let local = terms.local.into_inner();
125        crate::profile::record_source_build(index.len());
126        if let Some(started) = started {
127            crate::profile::record_source_encode_time(started.elapsed().as_micros() as u64);
128        }
129        Arc::new(Self {
130            id_to_term: local.id_to_term,
131            term_to_id: local.term_to_id,
132            index,
133            reverse_demand: reverse_demand.into_iter().collect(),
134            subject_demand,
135            object_demand,
136        })
137    }
138}
139
140impl TermDictionary {
141    fn new() -> Self {
142        Self {
143            source: None,
144            local: RefCell::new(TermDictInner {
145                id_to_term: Vec::new(),
146                term_to_id: HashMap::new(),
147            }),
148            local_blank_aliases: RefCell::new(HashMap::new()),
149        }
150    }
151
152    fn from_source(source: Arc<SourceStorage>) -> Self {
153        Self {
154            source: Some(source),
155            local: RefCell::new(TermDictInner {
156                id_to_term: Vec::new(),
157                term_to_id: HashMap::new(),
158            }),
159            local_blank_aliases: RefCell::new(HashMap::new()),
160        }
161    }
162
163    /// Return existing ID or assign a new one.
164    fn intern(&self, term: Term) -> TermId {
165        if let Some(id) = self.source.as_ref().and_then(|s| s.term_to_id.get(&term)) {
166            return *id;
167        }
168        let mut inner = self.local.borrow_mut();
169        if let Some(&id) = inner.term_to_id.get(&term) {
170            return id;
171        }
172        let source_len = self.source.as_ref().map_or(0, |s| s.id_to_term.len());
173        let id = TermId::try_from(source_len + inner.id_to_term.len())
174            .expect("RDF term dictionary exceeded u32 ID capacity");
175        inner.term_to_id.insert(term.clone(), id);
176        inner.id_to_term.push(term);
177        id
178    }
179
180    /// Intern a term originating in the separately parsed data document.
181    fn intern_data(&self, term: Term) -> TermId {
182        let Term::BlankNode(blank) = term else {
183            return self.intern(term);
184        };
185        let source_term = Term::BlankNode(blank.clone());
186        if !self
187            .source
188            .as_ref()
189            .is_some_and(|source| source.term_to_id.contains_key(&source_term))
190        {
191            return self.intern(source_term);
192        }
193        if let Some(alias) = self.local_blank_aliases.borrow().get(&blank).cloned() {
194            return self.intern(Term::BlankNode(alias));
195        }
196        let alias = loop {
197            let candidate = BlankNode::default();
198            if self.get(&Term::BlankNode(candidate.clone())).is_none() {
199                break candidate;
200            }
201        };
202        self.local_blank_aliases
203            .borrow_mut()
204            .insert(blank, alias.clone());
205        self.intern(Term::BlankNode(alias))
206    }
207
208    fn get(&self, term: &Term) -> Option<TermId> {
209        self.source
210            .as_ref()
211            .and_then(|s| s.term_to_id.get(term).copied())
212            .or_else(|| self.local.borrow().term_to_id.get(term).copied())
213    }
214
215    fn externalize(&self, id: TermId) -> Option<Term> {
216        let source_len = self.source.as_ref().map_or(0, |s| s.id_to_term.len());
217        if (id as usize) < source_len {
218            return self.source.as_ref()?.id_to_term.get(id as usize).cloned();
219        }
220        self.local
221            .borrow()
222            .id_to_term
223            .get(id as usize - source_len)
224            .cloned()
225    }
226
227    fn blank_node(&self, id: TermId) -> Option<BlankNode> {
228        let source_len = self.source.as_ref().map_or(0, |s| s.id_to_term.len());
229        if (id as usize) < source_len {
230            return match self.source.as_ref()?.id_to_term.get(id as usize) {
231                Some(Term::BlankNode(node)) => Some(node.clone()),
232                _ => None,
233            };
234        }
235        match self.local.borrow().id_to_term.get(id as usize - source_len) {
236            Some(Term::BlankNode(node)) => Some(node.clone()),
237            _ => None,
238        }
239    }
240}
241
242// ── Triple index ─────────────────────────────────────────────────────────────
243
244/// Complete predicate-partitioned primary representation. Each partition is a
245/// sorted set of `(subject, object)` pairs, so known-predicate scans, forward
246/// probes, and exact membership need no secondary index. Every other pattern
247/// has a correct scan fallback while selective indexes are introduced.
248struct TripleIndex {
249    partitions: BTreeMap<TermId, PredicatePartition>,
250    len: usize,
251    policy: IndexPolicy,
252    reverse_demand: HashSet<TermId>,
253    secondary_budget: usize,
254    secondary_bytes: AtomicUsize,
255    subject_dir: OnceLock<Option<Vec<[TermId; 3]>>>,
256    object_dir: OnceLock<Option<Vec<[TermId; 3]>>>,
257    subject_probes: AtomicU32,
258    object_probes: AtomicU32,
259    subject_demand: bool,
260    object_demand: bool,
261    scope: IndexScope,
262    predicate_labels: HashMap<TermId, String>,
263}
264
265struct PredicatePartition {
266    pairs: Vec<[TermId; 2]>,
267    reverse: OnceLock<Option<Vec<[TermId; 2]>>>,
268    reverse_probes: AtomicU32,
269}
270
271impl Default for PredicatePartition {
272    fn default() -> Self {
273        Self {
274            pairs: Vec::new(),
275            reverse: OnceLock::new(),
276            reverse_probes: AtomicU32::new(0),
277        }
278    }
279}
280
281#[derive(Clone, Copy, Debug, PartialEq, Eq)]
282enum IndexPolicy {
283    BaseOnly,
284    AllIndexes,
285    DemandDriven,
286}
287
288#[derive(Clone, Copy)]
289enum IndexScope {
290    Source,
291    Session,
292}
293
294impl IndexScope {
295    fn label(self) -> &'static str {
296        match self {
297            Self::Source => "source",
298            Self::Session => "session",
299        }
300    }
301}
302
303const SOURCE_INDEX_BUDGET: usize = 64 * 1024 * 1024;
304const SESSION_INDEX_BUDGET: usize = 32 * 1024 * 1024;
305
306fn merge_sorted<T: Copy + Ord>(existing: &mut Vec<T>, mut incoming: Vec<T>) {
307    incoming.sort_unstable();
308    incoming.dedup();
309    let mut merged = Vec::with_capacity(existing.len() + incoming.len());
310    let (mut a, mut b) = (0, 0);
311    while a < existing.len() && b < incoming.len() {
312        match existing[a].cmp(&incoming[b]) {
313            std::cmp::Ordering::Less => {
314                merged.push(existing[a]);
315                a += 1;
316            }
317            std::cmp::Ordering::Greater => {
318                merged.push(incoming[b]);
319                b += 1;
320            }
321            std::cmp::Ordering::Equal => {
322                merged.push(existing[a]);
323                a += 1;
324                b += 1;
325            }
326        }
327    }
328    merged.extend_from_slice(&existing[a..]);
329    merged.extend_from_slice(&incoming[b..]);
330    *existing = merged;
331}
332
333impl TripleIndex {
334    fn build(triples: Vec<[TermId; 3]>) -> Self {
335        Self::build_with_policy(
336            triples,
337            IndexPolicy::DemandDriven,
338            SESSION_INDEX_BUDGET,
339            HashSet::new(),
340            false,
341            false,
342        )
343    }
344
345    fn build_with_policy(
346        triples: Vec<[TermId; 3]>,
347        policy: IndexPolicy,
348        secondary_budget: usize,
349        reverse_demand: HashSet<TermId>,
350        subject_demand: bool,
351        object_demand: bool,
352    ) -> Self {
353        Self::build_with_scope(
354            triples,
355            policy,
356            secondary_budget,
357            reverse_demand,
358            subject_demand,
359            object_demand,
360            IndexScope::Session,
361            HashMap::new(),
362        )
363    }
364
365    #[allow(clippy::too_many_arguments)]
366    fn build_with_scope(
367        triples: Vec<[TermId; 3]>,
368        policy: IndexPolicy,
369        secondary_budget: usize,
370        reverse_demand: HashSet<TermId>,
371        subject_demand: bool,
372        object_demand: bool,
373        scope: IndexScope,
374        predicate_labels: HashMap<TermId, String>,
375    ) -> Self {
376        let mut partitions: BTreeMap<TermId, PredicatePartition> = BTreeMap::new();
377        for [s, p, o] in triples {
378            partitions.entry(p).or_default().pairs.push([s, o]);
379        }
380        let mut len = 0;
381        for partition in partitions.values_mut() {
382            partition.pairs.sort_unstable();
383            partition.pairs.dedup();
384            len += partition.pairs.len();
385        }
386        let index = Self {
387            partitions,
388            len,
389            policy,
390            reverse_demand,
391            secondary_budget,
392            secondary_bytes: AtomicUsize::new(0),
393            subject_dir: OnceLock::new(),
394            object_dir: OnceLock::new(),
395            subject_probes: AtomicU32::new(0),
396            object_probes: AtomicU32::new(0),
397            subject_demand,
398            object_demand,
399            scope,
400            predicate_labels,
401        };
402        if crate::profile::is_enabled() {
403            crate::profile::record_primary_index_bytes(
404                index.scope.label(),
405                0,
406                index.primary_bytes(),
407            );
408        }
409        if policy == IndexPolicy::AllIndexes {
410            for (&predicate, partition) in &index.partitions {
411                index.reverse(predicate, partition);
412            }
413            index.subject_directory();
414            index.object_directory();
415        }
416        index
417    }
418
419    fn contains(&self, s: TermId, p: TermId, o: TermId) -> bool {
420        self.partitions
421            .get(&p)
422            .is_some_and(|partition| partition.pairs.binary_search(&[s, o]).is_ok())
423    }
424
425    fn extend(&mut self, triples: Vec<[TermId; 3]>) {
426        let old_primary_bytes = crate::profile::is_enabled().then(|| self.primary_bytes());
427        let mut batches: BTreeMap<TermId, Vec<[TermId; 2]>> = BTreeMap::new();
428        let mut added_rows = Vec::new();
429        for [s, p, o] in triples {
430            batches.entry(p).or_default().push([s, o]);
431        }
432        for (predicate, mut incoming) in batches {
433            incoming.sort_unstable();
434            incoming.dedup();
435            let partition = self.partitions.entry(predicate).or_default();
436            let existing = &mut partition.pairs;
437            if existing.is_empty() {
438                self.len += incoming.len();
439                added_rows.extend(incoming.iter().map(|&[s, o]| [s, predicate, o]));
440                *existing = incoming;
441                continue;
442            }
443            let additions: Vec<_> = incoming
444                .iter()
445                .copied()
446                .filter(|row| existing.binary_search(row).is_err())
447                .collect();
448            self.len += additions.len();
449            added_rows.extend(additions.iter().map(|&[s, o]| [s, predicate, o]));
450            merge_sorted(existing, incoming);
451            if let Some(Some(reverse)) = partition.reverse.get_mut() {
452                let bytes = additions.len() * std::mem::size_of::<[TermId; 2]>();
453                if self
454                    .secondary_bytes
455                    .load(Ordering::Relaxed)
456                    .saturating_add(bytes)
457                    <= self.secondary_budget
458                {
459                    self.secondary_bytes.fetch_add(bytes, Ordering::Relaxed);
460                    merge_sorted(
461                        reverse,
462                        additions.into_iter().map(|[s, o]| [o, s]).collect(),
463                    );
464                } else {
465                    let old_bytes = reverse.len() * std::mem::size_of::<[TermId; 2]>();
466                    partition.reverse.take();
467                    self.secondary_bytes.fetch_sub(old_bytes, Ordering::Relaxed);
468                }
469            }
470        }
471        if let Some(Some(directory)) = self.subject_dir.get_mut() {
472            let bytes = added_rows.len() * std::mem::size_of::<[TermId; 3]>();
473            if self
474                .secondary_bytes
475                .load(Ordering::Relaxed)
476                .saturating_add(bytes)
477                <= self.secondary_budget
478            {
479                self.secondary_bytes.fetch_add(bytes, Ordering::Relaxed);
480                merge_sorted(directory, added_rows.clone());
481            } else {
482                let old_bytes = directory.len() * std::mem::size_of::<[TermId; 3]>();
483                self.subject_dir.take();
484                self.secondary_bytes.fetch_sub(old_bytes, Ordering::Relaxed);
485            }
486        }
487        if let Some(Some(directory)) = self.object_dir.get_mut() {
488            let bytes = added_rows.len() * std::mem::size_of::<[TermId; 3]>();
489            if self
490                .secondary_bytes
491                .load(Ordering::Relaxed)
492                .saturating_add(bytes)
493                <= self.secondary_budget
494            {
495                self.secondary_bytes.fetch_add(bytes, Ordering::Relaxed);
496                merge_sorted(
497                    directory,
498                    added_rows.into_iter().map(|[s, p, o]| [o, p, s]).collect(),
499                );
500            } else {
501                let old_bytes = directory.len() * std::mem::size_of::<[TermId; 3]>();
502                self.object_dir.take();
503                self.secondary_bytes.fetch_sub(old_bytes, Ordering::Relaxed);
504            }
505        }
506        if self.policy == IndexPolicy::AllIndexes {
507            for (&predicate, partition) in &self.partitions {
508                self.reverse(predicate, partition);
509            }
510        }
511        if let Some(old_primary_bytes) = old_primary_bytes {
512            crate::profile::record_primary_index_bytes(
513                self.scope.label(),
514                old_primary_bytes,
515                self.primary_bytes(),
516            );
517        }
518    }
519
520    fn primary_bytes(&self) -> usize {
521        self.partitions
522            .values()
523            .map(|partition| partition.pairs.capacity() * std::mem::size_of::<[TermId; 2]>())
524            .sum()
525    }
526
527    fn len(&self) -> usize {
528        self.len
529    }
530
531    fn rows(&self) -> impl Iterator<Item = [TermId; 3]> + '_ {
532        self.partitions
533            .iter()
534            .flat_map(|(&p, partition)| partition.pairs.iter().map(move |&[s, o]| [s, p, o]))
535    }
536
537    fn reserve_secondary(&self, bytes: usize) -> bool {
538        self.secondary_bytes
539            .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |used| {
540                used.checked_add(bytes)
541                    .filter(|total| *total <= self.secondary_budget)
542            })
543            .is_ok()
544    }
545
546    fn record_decision(
547        &self,
548        kind: &'static str,
549        predicate: Option<TermId>,
550        reason: &'static str,
551        rows: usize,
552        bytes: usize,
553        built: Option<(u64, usize)>,
554    ) {
555        crate::profile::record_index_decision(IndexRecord {
556            scope: self.scope.label(),
557            kind,
558            predicate: predicate.and_then(|id| self.predicate_labels.get(&id).cloned()),
559            reason,
560            rows,
561            estimated_bytes: bytes,
562            actual_bytes: built.map_or(0, |(_, bytes)| bytes),
563            budget_bytes: self.secondary_budget,
564            build_us: built.map_or(0, |(us, _)| us),
565            accepted: built.is_some(),
566        });
567    }
568
569    fn reverse<'a>(
570        &self,
571        predicate: TermId,
572        partition: &'a PredicatePartition,
573    ) -> Option<&'a Vec<[TermId; 2]>> {
574        if self.policy == IndexPolicy::BaseOnly {
575            return None;
576        }
577        let probes = partition.reverse_probes.fetch_add(1, Ordering::Relaxed) + 1;
578        if self.policy == IndexPolicy::DemandDriven
579            && (partition.pairs.len() < 64
580                || (probes < 3 && !self.reverse_demand.contains(&predicate)))
581        {
582            return None;
583        }
584        partition
585            .reverse
586            .get_or_init(|| {
587                let bytes = partition.pairs.len() * std::mem::size_of::<[TermId; 2]>();
588                let reason = if self.policy == IndexPolicy::AllIndexes {
589                    "full-index policy"
590                } else if self.reverse_demand.contains(&predicate) {
591                    "compiled demand"
592                } else {
593                    "observed probes"
594                };
595                if !self.reserve_secondary(bytes) {
596                    self.record_decision(
597                        "reverse",
598                        Some(predicate),
599                        reason,
600                        partition.pairs.len(),
601                        bytes,
602                        None,
603                    );
604                    return None;
605                }
606                let started = web_time::Instant::now();
607                let mut rows: Vec<_> = partition.pairs.iter().map(|&[s, o]| [o, s]).collect();
608                rows.sort_unstable();
609                self.record_decision(
610                    "reverse",
611                    Some(predicate),
612                    reason,
613                    rows.len(),
614                    bytes,
615                    Some((
616                        started.elapsed().as_micros() as u64,
617                        rows.capacity() * std::mem::size_of::<[TermId; 2]>(),
618                    )),
619                );
620                Some(rows)
621            })
622            .as_ref()
623    }
624
625    fn subject_directory(&self) -> Option<&Vec<[TermId; 3]>> {
626        if self.policy == IndexPolicy::BaseOnly {
627            return None;
628        }
629        let probes = self.subject_probes.fetch_add(1, Ordering::Relaxed) + 1;
630        if self.policy == IndexPolicy::DemandDriven
631            && (self.len < 256 || (probes < 3 && !self.subject_demand))
632        {
633            return None;
634        }
635        self.subject_dir
636            .get_or_init(|| {
637                let bytes = self.len * std::mem::size_of::<[TermId; 3]>();
638                let reason = if self.policy == IndexPolicy::AllIndexes {
639                    "full-index policy"
640                } else if self.subject_demand {
641                    "compiled demand"
642                } else {
643                    "observed probes"
644                };
645                if !self.reserve_secondary(bytes) {
646                    self.record_decision("subject", None, reason, self.len, bytes, None);
647                    return None;
648                }
649                let started = web_time::Instant::now();
650                let mut rows = Vec::with_capacity(self.len);
651                rows.extend(self.rows());
652                rows.sort_unstable();
653                self.record_decision(
654                    "subject",
655                    None,
656                    reason,
657                    rows.len(),
658                    bytes,
659                    Some((
660                        started.elapsed().as_micros() as u64,
661                        rows.capacity() * std::mem::size_of::<[TermId; 3]>(),
662                    )),
663                );
664                Some(rows)
665            })
666            .as_ref()
667    }
668
669    fn object_directory(&self) -> Option<&Vec<[TermId; 3]>> {
670        if self.policy == IndexPolicy::BaseOnly {
671            return None;
672        }
673        let probes = self.object_probes.fetch_add(1, Ordering::Relaxed) + 1;
674        if self.policy == IndexPolicy::DemandDriven
675            && (self.len < 256 || (probes < 3 && !self.object_demand))
676        {
677            return None;
678        }
679        self.object_dir
680            .get_or_init(|| {
681                let bytes = self.len * std::mem::size_of::<[TermId; 3]>();
682                let reason = if self.policy == IndexPolicy::AllIndexes {
683                    "full-index policy"
684                } else if self.object_demand {
685                    "compiled demand"
686                } else {
687                    "observed probes"
688                };
689                if !self.reserve_secondary(bytes) {
690                    self.record_decision("object", None, reason, self.len, bytes, None);
691                    return None;
692                }
693                let started = web_time::Instant::now();
694                let mut rows = Vec::with_capacity(self.len);
695                rows.extend(self.rows().map(|[s, p, o]| [o, p, s]));
696                rows.sort_unstable();
697                self.record_decision(
698                    "object",
699                    None,
700                    reason,
701                    rows.len(),
702                    bytes,
703                    Some((
704                        started.elapsed().as_micros() as u64,
705                        rows.capacity() * std::mem::size_of::<[TermId; 3]>(),
706                    )),
707                );
708                Some(rows)
709            })
710            .as_ref()
711    }
712
713    fn scan(
714        &self,
715        s: Option<TermId>,
716        p: Option<TermId>,
717        o: Option<TermId>,
718    ) -> Box<dyn Iterator<Item = [TermId; 3]> + '_> {
719        let mask = usize::from(s.is_some())
720            | (usize::from(p.is_some()) << 1)
721            | (usize::from(o.is_some()) << 2);
722        let record = |candidates| crate::profile::record_scan(self.scope.label(), mask, candidates);
723        if let Some(p) = p {
724            let Some(partition) = self.partitions.get(&p) else {
725                record(0);
726                return Box::new(std::iter::empty());
727            };
728            if s.is_none()
729                && let Some(o) = o
730                && let Some(reverse) = self.reverse(p, partition)
731            {
732                let lo = reverse.partition_point(|row| row[0] < o);
733                let hi = reverse.partition_point(|row| row[0] <= o);
734                record(hi - lo);
735                return Box::new(reverse[lo..hi].iter().map(move |&[o, s]| [s, p, o]));
736            }
737            let pairs = &partition.pairs;
738            let pairs = if let Some(s) = s {
739                let lo = pairs.partition_point(|row| row[0] < s);
740                let hi = pairs.partition_point(|row| row[0] <= s);
741                &pairs[lo..hi]
742            } else {
743                pairs.as_slice()
744            };
745            record(pairs.len());
746            Box::new(
747                pairs
748                    .iter()
749                    .filter(move |row| o.is_none_or(|o| row[1] == o))
750                    .map(move |&[s, o]| [s, p, o]),
751            )
752        } else {
753            if let Some(s) = s
754                && let Some(directory) = self.subject_directory()
755            {
756                let lo = directory.partition_point(|row| row[0] < s);
757                let hi = directory.partition_point(|row| row[0] <= s);
758                record(hi - lo);
759                return Box::new(
760                    directory[lo..hi]
761                        .iter()
762                        .filter(move |row| o.is_none_or(|o| row[2] == o))
763                        .copied(),
764                );
765            }
766            if let Some(o) = o
767                && let Some(directory) = self.object_directory()
768            {
769                let lo = directory.partition_point(|row| row[0] < o);
770                let hi = directory.partition_point(|row| row[0] <= o);
771                record(hi - lo);
772                return Box::new(
773                    directory[lo..hi]
774                        .iter()
775                        .filter(move |row| s.is_none_or(|s| row[2] == s))
776                        .map(|&[o, p, s]| [s, p, o]),
777                );
778            }
779            record(self.len);
780            Box::new(self.rows().filter(move |&[subject, _, object]| {
781                s.is_none_or(|s| subject == s) && o.is_none_or(|o| object == o)
782            }))
783        }
784    }
785}
786
787// ── Statistics ───────────────────────────────────────────────────────────────
788
789/// Dataset statistics used in stage 3+ for join-order planning (doc §198).
790pub struct DatasetStatistics {
791    pub triple_count: u64,
792    pub distinct_subjects: u64,
793    pub distinct_objects: u64,
794    /// Triples per predicate ID.
795    pub predicate_cardinality: HashMap<TermId, u64>,
796    subjects: HashSet<TermId>,
797    objects: HashSet<TermId>,
798}
799
800impl DatasetStatistics {
801    fn compute(triples: &TripleIndex) -> Self {
802        Self::compute_rows(triples.rows())
803    }
804
805    fn compute_rows(rows: impl Iterator<Item = [TermId; 3]>) -> Self {
806        let mut triple_count = 0;
807        let mut subjects = HashSet::new();
808        let mut objects = HashSet::new();
809        let mut predicate_cardinality: HashMap<TermId, u64> = HashMap::new();
810        for [s, p, o] in rows {
811            triple_count += 1;
812            subjects.insert(s);
813            objects.insert(o);
814            *predicate_cardinality.entry(p).or_insert(0) += 1;
815        }
816
817        Self {
818            triple_count,
819            distinct_subjects: subjects.len() as u64,
820            distinct_objects: objects.len() as u64,
821            predicate_cardinality,
822            subjects,
823            objects,
824        }
825    }
826
827    fn extend(&mut self, triples: &[[TermId; 3]]) {
828        self.triple_count += triples.len() as u64;
829        for &[subject, predicate, object] in triples {
830            self.subjects.insert(subject);
831            self.objects.insert(object);
832            *self.predicate_cardinality.entry(predicate).or_insert(0) += 1;
833        }
834        self.distinct_subjects = self.subjects.len() as u64;
835        self.distinct_objects = self.objects.len() as u64;
836    }
837}
838
839/// Source rows visible in this dataset's default graph. Embedded snapshots
840/// usually contain almost every source row, so keep exclusions in that case;
841/// sparse separate-data overlap uses inclusions instead.
842enum SourceMembership {
843    All,
844    Included(HashSet<[TermId; 3]>),
845    Excluded(HashSet<[TermId; 3]>),
846}
847
848struct SourceView {
849    index: Arc<TripleIndex>,
850    membership: SourceMembership,
851    include_all: bool,
852}
853
854impl SourceView {
855    fn data_member(&self, row: [TermId; 3]) -> bool {
856        match &self.membership {
857            SourceMembership::All => true,
858            SourceMembership::Included(rows) => rows.contains(&row),
859            SourceMembership::Excluded(rows) => !rows.contains(&row),
860        }
861    }
862
863    fn includes(&self, row: [TermId; 3]) -> bool {
864        self.include_all || self.data_member(row)
865    }
866}
867
868// ── FrozenIndexedDataset ─────────────────────────────────────────────────────
869
870/// Dictionary-encoded session dataset. Inference mutates it only at committed
871/// batch boundaries; after publication, validation reuses the same allocation
872/// through the selected logical view.
873pub struct FrozenIndexedDataset {
874    terms: TermDictionary,
875    /// Triples outside the immutable source partition.
876    default_graph: TripleIndex,
877    source_view: Option<SourceView>,
878    /// Named graphs keyed by graph-IRI ID. Compiled sessions point at the
879    /// immutable source index, rather than copying its rows per session.
880    named_graphs: HashMap<TermId, Arc<TripleIndex>>,
881    reach_cache: RefCell<ReachCache>,
882    /// Monotonic identity for data-dependent caches and physical plans. Every
883    /// committed RDF batch and default-view change advances it.
884    revision: u64,
885    pub stats: DatasetStatistics,
886}
887
888/// A closure cache is an acceleration, never part of query semantics. Bound it
889/// by the number of retained result ids rather than by entry count: one large
890/// `p*` closure is the memory risk, while many empty/small closures are cheap.
891/// On saturation we simply stop admitting new entries, retaining predictable
892/// memory use and the same indexed evaluation behavior for later requests.
893const MAX_CACHED_REACH_IDS: usize = 1_000_000;
894
895#[derive(PartialEq, Eq, Hash)]
896struct ReachCacheKey {
897    node: TermId,
898    step: ReachStep,
899    kind: ClosureKind,
900    graph: GraphSel,
901}
902
903#[derive(Default)]
904struct ReachCache {
905    /// Shared so callers can cheaply keep a result while the cache remains
906    /// borrowable for another query. Results are immutable sets, making this
907    /// aliasing safe and avoiding a clone proportional to the closure size.
908    entries: HashMap<ReachCacheKey, Rc<HashSet<TermId>>>,
909    cached_ids: usize,
910}
911
912impl FrozenIndexedDataset {
913    /// Create a session dataset using the compilation's source dictionary and
914    /// named-graph index. `include_source` applies only to the default view:
915    /// embedded edited data must pass `false` so deleted data membership is not
916    /// restored merely because the triple remains in the shapes graph.
917    pub(crate) fn from_data_with_source(
918        data: &Graph,
919        source: Arc<SourceStorage>,
920        separate: bool,
921        include_source: bool,
922    ) -> Self {
923        let started = crate::profile::is_enabled().then(Instant::now);
924        if started.is_some() {
925            crate::profile::observe_shared_source_primary_bytes(source.index.primary_bytes());
926        }
927        let terms = TermDictionary::from_source(Arc::clone(&source));
928        let mut local = Vec::new();
929        let mut overlapping = HashSet::new();
930        let data_rows = if separate {
931            intern_data_graph(data, &terms)
932        } else {
933            intern_graph(data, &terms)
934        };
935        for row in data_rows {
936            if source.index.contains(row[0], row[1], row[2]) {
937                overlapping.insert(row);
938            } else {
939                local.push(row);
940            }
941        }
942        let local_demand = source
943            .reverse_demand
944            .iter()
945            .map(|p| terms.intern(Term::NamedNode(p.clone())))
946            .collect();
947        let predicate_labels = predicate_labels(&local, &terms);
948        let default_graph = TripleIndex::build_with_scope(
949            local,
950            IndexPolicy::DemandDriven,
951            SESSION_INDEX_BUDGET,
952            local_demand,
953            source.subject_demand,
954            source.object_demand,
955            IndexScope::Session,
956            predicate_labels,
957        );
958        let membership = if overlapping.len() == source.index.len() {
959            SourceMembership::All
960        } else if overlapping.len() > source.index.len() / 2 {
961            let excluded = source
962                .index
963                .rows()
964                .filter(|row| !overlapping.contains(row))
965                .collect();
966            SourceMembership::Excluded(excluded)
967        } else {
968            SourceMembership::Included(overlapping)
969        };
970        let source_view = SourceView {
971            index: Arc::clone(&source.index),
972            membership,
973            include_all: include_source,
974        };
975        let stats = DatasetStatistics::compute_rows(
976            default_graph.rows().chain(
977                source_view
978                    .index
979                    .rows()
980                    .filter(|row| source_view.includes(*row)),
981            ),
982        );
983        crate::profile::record_dataset_build(default_graph.len());
984        let graph_id = terms.intern(Term::NamedNode(
985            NamedNode::new(SHAPES_GRAPH_IRI).expect("static IRI is valid"),
986        ));
987        let named_graphs = HashMap::from([(graph_id, Arc::clone(&source.index))]);
988        if let Some(started) = started {
989            crate::profile::record_session_encode_time(started.elapsed().as_micros() as u64);
990        }
991        Self {
992            terms,
993            default_graph,
994            source_view: Some(source_view),
995            named_graphs,
996            reach_cache: RefCell::new(ReachCache::default()),
997            revision: 0,
998            stats,
999        }
1000    }
1001
1002    /// Build from a single graph loaded into the default graph slot.
1003    pub fn from_graph(graph: &Graph) -> Self {
1004        let terms = TermDictionary::new();
1005        let triples = intern_graph(graph, &terms);
1006        let default_graph = TripleIndex::build(triples);
1007        let stats = DatasetStatistics::compute(&default_graph);
1008        crate::profile::record_dataset_build(default_graph.len());
1009        Self {
1010            terms,
1011            default_graph,
1012            source_view: None,
1013            named_graphs: HashMap::new(),
1014            reach_cache: RefCell::new(ReachCache::default()),
1015            revision: 0,
1016            stats,
1017        }
1018    }
1019
1020    /// Build a default graph from the set union of two source graphs without
1021    /// materializing an intermediate `Graph`.
1022    pub fn from_graph_union(left: &Graph, right: &Graph) -> Self {
1023        let terms = TermDictionary::new();
1024        let mut triples = intern_graph(left, &terms);
1025        triples.extend(intern_graph(right, &terms));
1026        let default_graph = TripleIndex::build(triples);
1027        let stats = DatasetStatistics::compute(&default_graph);
1028        crate::profile::record_dataset_build(default_graph.len());
1029        Self {
1030            terms,
1031            default_graph,
1032            source_view: None,
1033            named_graphs: HashMap::new(),
1034            reach_cache: RefCell::new(ReachCache::default()),
1035            revision: 0,
1036            stats,
1037        }
1038    }
1039
1040    /// Intern a term against this dataset's dictionary, returning its `TermId`.
1041    /// Unknown terms (e.g. query constants absent from the data) receive a fresh
1042    /// id that matches no stored triple — exactly the semantics a scan needs.
1043    pub(crate) fn intern(&self, term: &Term) -> TermId {
1044        self.terms.intern(term.clone())
1045    }
1046
1047    /// Build a [`PlanStats`] for use by the query planner. Converts TermId-keyed
1048    /// statistics to Term-keyed form so the planner can look up predicate
1049    /// cardinalities without touching the dictionary directly.
1050    pub(crate) fn plan_stats(&self) -> shifty_opt::PlanStats {
1051        let predicate_cardinality: HashMap<Term, u64> = self
1052            .stats
1053            .predicate_cardinality
1054            .iter()
1055            .filter_map(|(&id, &count)| Some((self.externalize(id)?, count)))
1056            .collect();
1057        let distinct_predicates = predicate_cardinality.len() as u64;
1058        shifty_opt::PlanStats {
1059            total_triples: self.stats.triple_count,
1060            distinct_subjects: self.stats.distinct_subjects,
1061            distinct_objects: self.stats.distinct_objects,
1062            distinct_predicates,
1063            predicate_cardinality,
1064        }
1065    }
1066
1067    /// Map a `TermId` back to its RDF term. Returns `None` only for ids that did
1068    /// not originate from this dataset.
1069    pub(crate) fn externalize(&self, id: TermId) -> Option<Term> {
1070        self.terms.externalize(id)
1071    }
1072
1073    /// Like [`externalize`](Self::externalize) but for ids that are guaranteed to
1074    /// originate from this dataset (e.g. a [`scan`](Self::scan) result), so the
1075    /// lookup cannot fail.
1076    pub(crate) fn externalize_id(&self, id: TermId) -> Term {
1077        self.terms
1078            .externalize(id)
1079            .expect("scanned TermId originates from this dataset")
1080    }
1081
1082    pub(crate) fn contains_ids(&self, subject: TermId, predicate: TermId, object: TermId) -> bool {
1083        self.default_graph.contains(subject, predicate, object)
1084            || self.source_view.as_ref().is_some_and(|source| {
1085                source.index.contains(subject, predicate, object)
1086                    && source.includes([subject, predicate, object])
1087            })
1088    }
1089
1090    pub(crate) fn encode_triple(&self, triple: &oxrdf::Triple) -> [TermId; 3] {
1091        [
1092            self.terms.intern(triple.subject.clone().into()),
1093            self.terms.intern(Term::NamedNode(triple.predicate.clone())),
1094            self.terms.intern(triple.object.clone()),
1095        ]
1096    }
1097
1098    pub(crate) fn cached_reach(
1099        &self,
1100        node: TermId,
1101        step: &ReachStep,
1102        kind: ClosureKind,
1103        graph: GraphSel,
1104    ) -> Option<Rc<HashSet<TermId>>> {
1105        let result = self
1106            .reach_cache
1107            .borrow()
1108            .entries
1109            .get(&ReachCacheKey {
1110                node,
1111                step: step.clone(),
1112                kind,
1113                graph,
1114            })
1115            .cloned();
1116        crate::profile::record_reach_cache_lookup(result.is_some());
1117        result
1118    }
1119
1120    pub(crate) fn cache_reach(
1121        &self,
1122        node: TermId,
1123        step: &ReachStep,
1124        kind: ClosureKind,
1125        graph: GraphSel,
1126        result: Rc<HashSet<TermId>>,
1127    ) {
1128        let mut cache = self.reach_cache.borrow_mut();
1129        // No eviction policy is needed: this snapshot is short-lived and an
1130        // admission-only cap avoids turning a hot closure into repeated
1131        // allocate-and-evict churn. A miss remains correct and index-backed.
1132        if cache.cached_ids.saturating_add(result.len()) > MAX_CACHED_REACH_IDS {
1133            crate::profile::record_reach_cache_admission(false, result.len());
1134            return;
1135        }
1136        let key = ReachCacheKey {
1137            node,
1138            step: step.clone(),
1139            kind,
1140            graph,
1141        };
1142        if cache.entries.contains_key(&key) {
1143            return;
1144        }
1145        cache.cached_ids += result.len();
1146        cache.entries.insert(key, result);
1147        crate::profile::record_reach_cache_admission(true, 0);
1148    }
1149
1150    /// Scan triples in the selected graph matching an optional S/P/O pattern,
1151    /// yielding `(subject, predicate, object)` term-id triples. Reuses the same
1152    /// sorted-index access paths as the `QueryableDataset` impl.
1153    pub(crate) fn scan(
1154        &self,
1155        s: Option<TermId>,
1156        p: Option<TermId>,
1157        o: Option<TermId>,
1158        graph: GraphSel,
1159    ) -> Box<dyn Iterator<Item = [TermId; 3]> + '_> {
1160        if let (GraphSel::Default, Some(s), Some(p), Some(o)) = (graph, s, p, o) {
1161            let found = self.contains_ids(s, p, o);
1162            crate::profile::record_scan("session", 7, usize::from(found));
1163            return Box::new(found.then_some([s, p, o]).into_iter());
1164        }
1165        let iter = match graph {
1166            GraphSel::Default => default_graph_quads(self, s, p, o),
1167            GraphSel::Named(g) => named_graph_quads(self, g, s, p, o),
1168        };
1169        Box::new(iter.map(|q| {
1170            let q = q.expect("infallible");
1171            [q.subject, q.predicate, q.object]
1172        }))
1173    }
1174
1175    /// Data membership independent of whether the default read graph also
1176    /// includes source-only rows. Focus selection uses this in Union mode.
1177    pub(crate) fn scan_data(
1178        &self,
1179        s: Option<TermId>,
1180        p: Option<TermId>,
1181        o: Option<TermId>,
1182    ) -> Box<dyn Iterator<Item = [TermId; 3]> + '_> {
1183        let local = self.default_graph.scan(s, p, o);
1184        let Some(source) = &self.source_view else {
1185            return local;
1186        };
1187        Box::new(
1188            local.chain(
1189                source
1190                    .index
1191                    .scan(s, p, o)
1192                    .filter(|row| source.data_member(*row)),
1193            ),
1194        )
1195    }
1196
1197    /// Blank nodes present before inference in the data or named shapes graph,
1198    /// using dataset identities after source/data label collisions are resolved.
1199    pub(crate) fn input_blank_nodes(&self) -> HashSet<BlankNode> {
1200        let mut nodes: HashSet<_> = self
1201            .scan_data(None, None, None)
1202            .flat_map(|[subject, _, object]| [subject, object])
1203            .filter_map(|id| self.terms.blank_node(id))
1204            .collect();
1205        if let Some(source) = &self.terms.source {
1206            // The immutable source dictionary contains the shapes graph's
1207            // terms already; inspect each distinct term once per session.
1208            nodes.extend(source.id_to_term.iter().filter_map(|term| match term {
1209                Term::BlankNode(node) => Some(node.clone()),
1210                _ => None,
1211            }));
1212        } else if let Some(graph) = self.terms.get(&Term::NamedNode(
1213            NamedNode::new(SHAPES_GRAPH_IRI).expect("static IRI is valid"),
1214        )) {
1215            nodes.extend(
1216                self.scan(None, None, None, GraphSel::Named(graph))
1217                    .flat_map(|[subject, _, object]| [subject, object])
1218                    .filter_map(|id| self.terms.blank_node(id)),
1219            );
1220        }
1221        nodes
1222    }
1223
1224    /// Relabel inference output for a caller that supplied a separate data
1225    /// graph. Internal data aliases must return to their original labels;
1226    /// shapes nodes with those labels need fresh output labels so write-back
1227    /// cannot merge two distinct nodes in the caller's graph.
1228    pub(crate) fn external_blank_node_map(&self) -> HashMap<BlankNode, BlankNode> {
1229        let aliases = self.terms.local_blank_aliases.borrow();
1230        let originals: HashSet<_> = aliases.keys().cloned().collect();
1231        let mut mapping = HashMap::with_capacity(aliases.len() * 2);
1232        let mut allocated = HashSet::with_capacity(aliases.len());
1233        for (original, alias) in aliases.iter() {
1234            let fresh = loop {
1235                let candidate = BlankNode::default();
1236                if self
1237                    .terms
1238                    .get(&Term::BlankNode(candidate.clone()))
1239                    .is_none()
1240                    && !originals.contains(&candidate)
1241                    && !allocated.contains(&candidate)
1242                {
1243                    break candidate;
1244                }
1245            };
1246            allocated.insert(fresh.clone());
1247            mapping.insert(original.clone(), fresh);
1248            mapping.insert(alias.clone(), original.clone());
1249        }
1250        mapping
1251    }
1252
1253    /// Internal data aliases and the data blank nodes they came from. The map
1254    /// is populated during the existing input encoding pass, so retrieving it
1255    /// does not visit the graph or any index rows.
1256    pub(crate) fn data_blank_node_aliases(&self) -> HashMap<BlankNode, BlankNode> {
1257        self.terms
1258            .local_blank_aliases
1259            .borrow()
1260            .iter()
1261            .map(|(original, alias)| (alias.clone(), original.clone()))
1262            .collect()
1263    }
1264
1265    /// Compatibility projection of evaluated data, excluding source-only rows
1266    /// even when the default query view is a data/shapes union.
1267    pub(crate) fn data_graph_projection(&self) -> Graph {
1268        self.project_rows(self.scan_data(None, None, None))
1269    }
1270
1271    /// Materialize the requested data graph once, relabeling terms as rows
1272    /// leave the index. This avoids first building an internal Graph and then
1273    /// walking that Graph again for a public export.
1274    pub(crate) fn data_graph_projection_mapped(
1275        &self,
1276        blank_nodes: &HashMap<BlankNode, BlankNode>,
1277    ) -> Graph {
1278        self.project_rows_with(self.scan_data(None, None, None), |term| match term {
1279            Term::BlankNode(node) => {
1280                Term::BlankNode(blank_nodes.get(&node).cloned().unwrap_or(node))
1281            }
1282            other => other,
1283        })
1284    }
1285
1286    /// Compatibility projection for APIs that explicitly request a Graph.
1287    pub(crate) fn default_graph_projection(&self) -> Graph {
1288        self.project_rows(self.scan(None, None, None, GraphSel::Default))
1289    }
1290
1291    fn project_rows(&self, rows: impl Iterator<Item = [TermId; 3]>) -> Graph {
1292        self.project_rows_with(rows, |term| term)
1293    }
1294
1295    fn project_rows_with(
1296        &self,
1297        rows: impl Iterator<Item = [TermId; 3]>,
1298        map: impl Fn(Term) -> Term,
1299    ) -> Graph {
1300        let mut graph = Graph::new();
1301        for [subject, predicate, object] in rows {
1302            let subject: oxrdf::NamedOrBlankNode = match map(self.externalize_id(subject)) {
1303                Term::NamedNode(node) => node.into(),
1304                Term::BlankNode(node) => node.into(),
1305                Term::Literal(_) => unreachable!("RDF subjects cannot be literals"),
1306            };
1307            let Term::NamedNode(predicate) = self.externalize_id(predicate) else {
1308                unreachable!("RDF predicates are named nodes")
1309            };
1310            graph.insert(&oxrdf::Triple::new(
1311                subject,
1312                predicate,
1313                map(self.externalize_id(object)),
1314            ));
1315        }
1316        crate::profile::record_graph_projection(graph.len());
1317        graph
1318    }
1319
1320    pub(crate) fn triples_for_predicate(
1321        &self,
1322        predicate: &NamedNode,
1323    ) -> Box<dyn Iterator<Item = (Term, Term)> + '_> {
1324        let p = self.intern(&Term::NamedNode(predicate.clone()));
1325        Box::new(
1326            self.scan(None, Some(p), None, GraphSel::Default)
1327                .map(|[subject, _, object]| {
1328                    (self.externalize_id(subject), self.externalize_id(object))
1329                }),
1330        )
1331    }
1332
1333    pub(crate) fn outgoing(
1334        &self,
1335        subject: &Term,
1336    ) -> Box<dyn Iterator<Item = (NamedNode, Term)> + '_> {
1337        let s = self.intern(subject);
1338        Box::new(
1339            self.scan(Some(s), None, None, GraphSel::Default)
1340                .filter_map(|[_, predicate, object]| {
1341                    let Term::NamedNode(predicate) = self.externalize_id(predicate) else {
1342                        return None;
1343                    };
1344                    Some((predicate, self.externalize_id(object)))
1345                }),
1346        )
1347    }
1348
1349    /// Add a committed inference batch to the default-graph indexes.
1350    pub(crate) fn extend_triples<'a>(
1351        &mut self,
1352        triples: impl IntoIterator<Item = &'a oxrdf::Triple>,
1353    ) {
1354        let started = crate::profile::is_enabled().then(Instant::now);
1355        let mut encoded: Vec<_> = triples
1356            .into_iter()
1357            .map(|triple| self.encode_triple(triple))
1358            .collect();
1359        encoded.sort_unstable();
1360        encoded.dedup();
1361        encoded.retain(|&[s, p, o]| !self.contains_ids(s, p, o));
1362        let committed = encoded.len();
1363        self.stats.extend(&encoded);
1364        for &[_, predicate, _] in &encoded {
1365            if !self.default_graph.predicate_labels.contains_key(&predicate)
1366                && let Term::NamedNode(node) = self.externalize_id(predicate)
1367            {
1368                self.default_graph
1369                    .predicate_labels
1370                    .insert(predicate, node.as_str().to_owned());
1371            }
1372        }
1373        self.default_graph.extend(encoded);
1374        *self.reach_cache.borrow_mut() = ReachCache::default();
1375        if committed != 0 {
1376            self.revision = self
1377                .revision
1378                .checked_add(1)
1379                .expect("dataset revision overflowed u64");
1380        }
1381        if let Some(started) = started {
1382            crate::profile::record_dataset_commit(committed, started.elapsed().as_micros() as u64);
1383        }
1384    }
1385
1386    /// Select the evaluated-data view after union-based inference. Source
1387    /// membership is retained separately, so this changes no stored rows or
1388    /// named-shapes reads and needs no second dictionary/index build.
1389    pub(crate) fn select_data_view(&mut self) {
1390        let Some(source) = &mut self.source_view else {
1391            return;
1392        };
1393        if !source.include_all {
1394            return;
1395        }
1396        source.include_all = false;
1397        self.stats = DatasetStatistics::compute_rows(
1398            self.default_graph
1399                .rows()
1400                .chain(source.index.rows().filter(|row| source.includes(*row))),
1401        );
1402        *self.reach_cache.borrow_mut() = ReachCache::default();
1403        self.revision = self
1404            .revision
1405            .checked_add(1)
1406            .expect("dataset revision overflowed u64");
1407    }
1408
1409    /// Current data/view revision for cache and physical-plan identities.
1410    pub(crate) fn revision(&self) -> u64 {
1411        self.revision
1412    }
1413
1414    /// Build with `context` in the default graph and `shapes` in the named
1415    /// graph `urn:x-shacl:shapes-graph`, mirroring what `SparqlExecutor::build`
1416    /// does with the Oxigraph Store.
1417    pub fn from_graphs(context: &Graph, shapes: &Graph) -> Self {
1418        let terms = TermDictionary::new();
1419        let triples = intern_graph(context, &terms);
1420        let default_graph = TripleIndex::build(triples);
1421        let stats = DatasetStatistics::compute(&default_graph);
1422        crate::profile::record_dataset_build(default_graph.len());
1423
1424        let shapes_iri = NamedNode::new(SHAPES_GRAPH_IRI).expect("static IRI is valid");
1425        let graph_id = terms.intern(Term::NamedNode(shapes_iri));
1426        let named_triples = Arc::new(TripleIndex::build(intern_graph(shapes, &terms)));
1427
1428        let mut named_graphs = HashMap::new();
1429        named_graphs.insert(graph_id, named_triples);
1430
1431        Self {
1432            terms,
1433            default_graph,
1434            source_view: None,
1435            named_graphs,
1436            reach_cache: RefCell::new(ReachCache::default()),
1437            revision: 0,
1438            stats,
1439        }
1440    }
1441
1442    /// Build a union default graph while also exposing `shapes` through the
1443    /// named `$shapesGraph` slot.
1444    pub fn from_graph_union_with_shapes(data: &Graph, shapes: &Graph) -> Self {
1445        let terms = TermDictionary::new();
1446        let mut triples = intern_graph(data, &terms);
1447        triples.extend(intern_graph(shapes, &terms));
1448        let default_graph = TripleIndex::build(triples);
1449        let stats = DatasetStatistics::compute(&default_graph);
1450        crate::profile::record_dataset_build(default_graph.len());
1451
1452        let shapes_iri = NamedNode::new(SHAPES_GRAPH_IRI).expect("static IRI is valid");
1453        let graph_id = terms.intern(Term::NamedNode(shapes_iri));
1454        let named_triples = Arc::new(TripleIndex::build(intern_graph(shapes, &terms)));
1455        let mut named_graphs = HashMap::new();
1456        named_graphs.insert(graph_id, named_triples);
1457
1458        Self {
1459            terms,
1460            default_graph,
1461            source_view: None,
1462            named_graphs,
1463            reach_cache: RefCell::new(ReachCache::default()),
1464            revision: 0,
1465            stats,
1466        }
1467    }
1468}
1469
1470fn intern_graph(graph: &Graph, terms: &TermDictionary) -> Vec<[TermId; 3]> {
1471    graph
1472        .iter()
1473        .map(|triple| {
1474            let s = terms.intern(triple.subject.into_owned().into());
1475            let p = terms.intern(Term::NamedNode(triple.predicate.into_owned()));
1476            let o = terms.intern(triple.object.into_owned());
1477            [s, p, o]
1478        })
1479        .collect()
1480}
1481
1482fn intern_data_graph(graph: &Graph, terms: &TermDictionary) -> Vec<[TermId; 3]> {
1483    graph
1484        .iter()
1485        .map(|triple| {
1486            let s = terms.intern_data(triple.subject.into_owned().into());
1487            let p = terms.intern(Term::NamedNode(triple.predicate.into_owned()));
1488            let o = terms.intern_data(triple.object.into_owned());
1489            [s, p, o]
1490        })
1491        .collect()
1492}
1493
1494fn predicate_labels(rows: &[[TermId; 3]], terms: &TermDictionary) -> HashMap<TermId, String> {
1495    rows.iter()
1496        .map(|row| row[1])
1497        .collect::<HashSet<_>>()
1498        .into_iter()
1499        .filter_map(|id| match terms.externalize(id) {
1500            Some(Term::NamedNode(node)) => Some((id, node.as_str().to_owned())),
1501            _ => None,
1502        })
1503        .collect()
1504}
1505
1506// ── QueryableDataset impl ────────────────────────────────────────────────────
1507
1508#[allow(refining_impl_trait)]
1509impl<'a> QueryableDataset<'a> for &'a FrozenIndexedDataset {
1510    type InternalTerm = TermId;
1511    type Error = Infallible;
1512
1513    fn internal_quads_for_pattern(
1514        &self,
1515        subject: Option<&TermId>,
1516        predicate: Option<&TermId>,
1517        object: Option<&TermId>,
1518        graph_name: Option<Option<&TermId>>,
1519    ) -> QuadIter<'a> {
1520        // Dereference once to get &'a FrozenIndexedDataset, preserving the full
1521        // 'a lifetime instead of the shorter borrow lifetime of &self. (Not an
1522        // auto-deref: the explicit `*` is what keeps the `'a` lifetime.)
1523        #[allow(clippy::explicit_auto_deref)]
1524        let ds: &'a FrozenIndexedDataset = *self;
1525        let s = subject.copied();
1526        let p = predicate.copied();
1527        let o = object.copied();
1528        match graph_name {
1529            Some(None) => default_graph_quads(ds, s, p, o),
1530            Some(Some(&g)) => named_graph_quads(ds, g, s, p, o),
1531            None => all_named_quads(ds, s, p, o),
1532        }
1533    }
1534
1535    fn internal_named_graphs(&self) -> impl Iterator<Item = Result<TermId, Infallible>> + use<'a> {
1536        self.named_graphs
1537            .keys()
1538            .copied()
1539            .map(Ok)
1540            .collect::<Vec<_>>()
1541            .into_iter()
1542    }
1543
1544    fn contains_internal_graph_name(&self, graph_name: &TermId) -> Result<bool, Infallible> {
1545        Ok(self.named_graphs.contains_key(graph_name))
1546    }
1547
1548    fn internalize_term(&self, term: Term) -> Result<TermId, Infallible> {
1549        // Lazily assigns IDs to query constants that weren't in the loaded triples.
1550        // These will never match any triple index entry but get unique IDs so
1551        // term-equality in SPARQL expressions is still correct.
1552        Ok(self.terms.intern(term))
1553    }
1554
1555    fn externalize_term(&self, id: TermId) -> Result<Term, Infallible> {
1556        Ok(self
1557            .terms
1558            .externalize(id)
1559            .expect("TermId always originates from this dataset's internalize_term or internal_quads_for_pattern"))
1560    }
1561}
1562
1563// ─── quad-iterator helpers ───────────────────────────────────────────────────
1564
1565type QuadIter<'a> = Box<dyn Iterator<Item = Result<InternalQuad<TermId>, Infallible>> + 'a>;
1566
1567fn mk_quad(s: TermId, p: TermId, o: TermId, g: Option<TermId>) -> InternalQuad<TermId> {
1568    InternalQuad {
1569        subject: s,
1570        predicate: p,
1571        object: o,
1572        graph_name: g,
1573    }
1574}
1575
1576/// Quads from the default graph matching an optional S/P/O pattern.
1577fn default_graph_quads<'a>(
1578    ds: &'a FrozenIndexedDataset,
1579    s: Option<TermId>,
1580    p: Option<TermId>,
1581    o: Option<TermId>,
1582) -> QuadIter<'a> {
1583    let local = indexed_graph_quads(&ds.default_graph, None, s, p, o);
1584    let Some(source) = &ds.source_view else {
1585        return local;
1586    };
1587    // Local rows were partitioned against the source index on construction;
1588    // commits check default membership before insertion. The two streams are
1589    // therefore disjoint within this view, so concatenation preserves a set.
1590    let shared = indexed_graph_quads(&source.index, None, s, p, o).filter(move |quad| {
1591        let quad = quad.as_ref().expect("infallible RDF scan");
1592        let row = [quad.subject, quad.predicate, quad.object];
1593        source.includes(row)
1594    });
1595    Box::new(local.chain(shared))
1596}
1597
1598fn indexed_graph_quads<'a>(
1599    index: &'a TripleIndex,
1600    graph: Option<TermId>,
1601    s: Option<TermId>,
1602    p: Option<TermId>,
1603    o: Option<TermId>,
1604) -> QuadIter<'a> {
1605    Box::new(
1606        index
1607            .scan(s, p, o)
1608            .map(move |[s, p, o]| Ok(mk_quad(s, p, o, graph))),
1609    )
1610}
1611
1612/// Quads from a specific named graph matching an optional S/P/O pattern.
1613/// Named shapes use the same shared source primary index as default-view
1614/// probes, without copying its rows into each session.
1615fn named_graph_quads<'a>(
1616    ds: &'a FrozenIndexedDataset,
1617    g: TermId,
1618    s: Option<TermId>,
1619    p: Option<TermId>,
1620    o: Option<TermId>,
1621) -> QuadIter<'a> {
1622    let Some(index) = ds.named_graphs.get(&g) else {
1623        return Box::new(std::iter::empty());
1624    };
1625    indexed_graph_quads(index, Some(g), s, p, o)
1626}
1627
1628/// Quads from ALL named graphs (but NOT the default graph). Used when the
1629/// `graph_name` pattern is `None` (SPARQL: no GRAPH clause, any named graph).
1630fn all_named_quads<'a>(
1631    ds: &'a FrozenIndexedDataset,
1632    s: Option<TermId>,
1633    p: Option<TermId>,
1634    o: Option<TermId>,
1635) -> QuadIter<'a> {
1636    Box::new(
1637        ds.named_graphs
1638            .keys()
1639            .copied()
1640            .flat_map(move |g| named_graph_quads(ds, g, s, p, o)),
1641    )
1642}
1643
1644// ── Tests ────────────────────────────────────────────────────────────────────
1645
1646#[cfg(test)]
1647mod tests {
1648    use super::*;
1649    use oxrdf::{NamedNode, Triple};
1650    use spareval::QueryableDataset;
1651
1652    fn nn(iri: &str) -> NamedNode {
1653        NamedNode::new(iri).unwrap()
1654    }
1655
1656    fn triple_nnn(s: &str, p: &str, o: &str) -> Triple {
1657        Triple::new(nn(s), nn(p), nn(o))
1658    }
1659
1660    fn small_graph() -> Graph {
1661        let mut g = Graph::new();
1662        g.insert(triple_nnn("http://ex/a", "http://ex/p", "http://ex/b").as_ref());
1663        g.insert(triple_nnn("http://ex/a", "http://ex/p", "http://ex/c").as_ref());
1664        g.insert(triple_nnn("http://ex/b", "http://ex/q", "http://ex/c").as_ref());
1665        g
1666    }
1667
1668    #[test]
1669    fn profile_accounts_for_source_index_reused_after_encoding() {
1670        let source = SourceStorage::encode(&small_graph());
1671        crate::profile::enable();
1672        let _dataset = FrozenIndexedDataset::from_data_with_source(
1673            &Graph::new(),
1674            Arc::clone(&source),
1675            false,
1676            false,
1677        );
1678        let profile = crate::profile::take().unwrap();
1679        assert_eq!(profile.storage().source_builds, 0);
1680        assert_eq!(
1681            profile.storage().source_primary_bytes as usize,
1682            source.index.primary_bytes()
1683        );
1684    }
1685
1686    #[test]
1687    fn empty_named_graph_remains_visible_to_sparql() {
1688        let source = SourceStorage::encode(&Graph::new());
1689        let dataset =
1690            FrozenIndexedDataset::from_data_with_source(&Graph::new(), source, false, false);
1691        let graph_id = dataset
1692            .terms
1693            .get(&Term::NamedNode(nn(SHAPES_GRAPH_IRI)))
1694            .unwrap();
1695        let queryable = &dataset;
1696
1697        assert_eq!(
1698            queryable
1699                .internal_named_graphs()
1700                .collect::<Result<Vec<_>, _>>()
1701                .unwrap(),
1702            vec![graph_id]
1703        );
1704        assert!(queryable.contains_internal_graph_name(&graph_id).unwrap());
1705        assert!(!queryable.contains_internal_graph_name(&u32::MAX).unwrap());
1706        assert!(
1707            queryable
1708                .internal_quads_for_pattern(None, None, None, Some(Some(&graph_id)))
1709                .next()
1710                .is_none()
1711        );
1712    }
1713
1714    #[test]
1715    fn reverse_index_policies_preserve_all_pattern_answers() {
1716        let rows: Vec<_> = (0..128).map(|s| [s, 9, s % 7]).collect();
1717        let base = TripleIndex::build_with_policy(
1718            rows.clone(),
1719            IndexPolicy::BaseOnly,
1720            0,
1721            HashSet::new(),
1722            false,
1723            false,
1724        );
1725        let all = TripleIndex::build_with_policy(
1726            rows.clone(),
1727            IndexPolicy::AllIndexes,
1728            usize::MAX,
1729            HashSet::new(),
1730            false,
1731            false,
1732        );
1733        let demand = TripleIndex::build_with_policy(
1734            rows.clone(),
1735            IndexPolicy::DemandDriven,
1736            0,
1737            HashSet::from([9]),
1738            false,
1739            false,
1740        );
1741        for (s, p, o) in [
1742            (None, None, None),
1743            (Some(3), None, None),
1744            (None, Some(9), None),
1745            (None, None, Some(2)),
1746            (Some(3), Some(9), None),
1747            (None, Some(9), Some(2)),
1748            (Some(3), None, Some(3)),
1749            (Some(3), Some(9), Some(3)),
1750        ] {
1751            let mut expected: Vec<_> = base.scan(s, p, o).collect();
1752            expected.sort_unstable();
1753            for index in [&all, &demand] {
1754                let mut got: Vec<_> = index.scan(s, p, o).collect();
1755                got.sort_unstable();
1756                assert_eq!(got, expected, "pattern {s:?} {p:?} {o:?}");
1757            }
1758        }
1759        assert_eq!(
1760            all.secondary_bytes.load(Ordering::Relaxed),
1761            128 * (8 + 12 + 12)
1762        );
1763        assert_eq!(demand.secondary_bytes.load(Ordering::Relaxed), 0);
1764    }
1765
1766    #[test]
1767    fn reverse_index_promotes_on_demand_and_merges_committed_rows() {
1768        let rows: Vec<_> = (0..128).map(|s| [s, 9, s % 7]).collect();
1769        let mut index = TripleIndex::build_with_policy(
1770            rows,
1771            IndexPolicy::DemandDriven,
1772            2048,
1773            HashSet::from([9]),
1774            false,
1775            false,
1776        );
1777        assert_eq!(index.scan(None, Some(9), Some(2)).count(), 18);
1778        assert!(index.partitions[&9].reverse.get().is_some());
1779        index.extend(vec![[200, 9, 2], [200, 9, 2], [201, 10, 2]]);
1780        assert!(index.contains(200, 9, 2));
1781        assert_eq!(index.scan(None, Some(9), Some(2)).count(), 19);
1782        assert_eq!(index.scan(None, Some(10), Some(2)).count(), 1);
1783        assert_eq!(index.len(), 130);
1784    }
1785
1786    #[test]
1787    fn general_directories_obey_budget_and_track_commits() {
1788        crate::profile::enable();
1789        let rows: Vec<_> = (0..300).map(|i| [i % 3, i + 10, i % 5]).collect();
1790        let mut index = TripleIndex::build_with_policy(
1791            rows.clone(),
1792            IndexPolicy::DemandDriven,
1793            300 * 12,
1794            HashSet::new(),
1795            true,
1796            true,
1797        );
1798        assert_eq!(index.scan(Some(1), None, None).count(), 100);
1799        assert!(index.subject_dir.get().is_some());
1800        // The first directory consumes the whole byte budget. The object
1801        // probe must use the complete scan fallback without changing answers.
1802        assert_eq!(index.scan(None, None, Some(2)).count(), 60);
1803        assert!(matches!(index.object_dir.get(), Some(None)));
1804        index.extend(vec![[1, 900, 2]]);
1805        assert!(index.contains(1, 900, 2));
1806        assert_eq!(index.scan(Some(1), None, None).count(), 101);
1807        assert_eq!(index.scan(None, None, Some(2)).count(), 61);
1808        let profile = crate::profile::take().unwrap();
1809        assert_eq!(profile.indexes().len(), 3);
1810        assert_eq!(profile.indexes()[0].kind, "subject");
1811        assert!(profile.indexes()[0].accepted);
1812        assert_eq!(profile.indexes()[0].actual_bytes, 300 * 12);
1813        assert_eq!(profile.indexes()[1].kind, "object");
1814        assert!(!profile.indexes()[1].accepted);
1815        assert_eq!(profile.indexes()[1].budget_bytes, 300 * 12);
1816        assert_eq!(profile.indexes()[2].kind, "subject");
1817        assert!(!profile.indexes()[2].accepted);
1818        assert_eq!(profile.indexes()[2].estimated_bytes, 301 * 12);
1819        assert_eq!(
1820            profile.storage().session_primary_bytes as usize,
1821            index.primary_bytes()
1822        );
1823    }
1824
1825    #[test]
1826    fn source_index_profile_names_observed_predicate() {
1827        let mut graph = Graph::new();
1828        for index in 0..128 {
1829            graph.insert(&triple_nnn(
1830                &format!("http://ex/s{index}"),
1831                "http://ex/p",
1832                "http://ex/o",
1833            ));
1834        }
1835        crate::profile::enable();
1836        let source = SourceStorage::encode(&graph);
1837        let p = source.term_to_id[&Term::NamedNode(nn("http://ex/p"))];
1838        let o = source.term_to_id[&Term::NamedNode(nn("http://ex/o"))];
1839        for _ in 0..3 {
1840            assert_eq!(source.index.scan(None, Some(p), Some(o)).count(), 128);
1841        }
1842        let profile = crate::profile::take().unwrap();
1843        assert_eq!(profile.indexes().len(), 1);
1844        let index = &profile.indexes()[0];
1845        assert_eq!(index.scope, "source");
1846        assert_eq!(index.kind, "reverse");
1847        assert_eq!(index.predicate.as_deref(), Some("http://ex/p"));
1848        assert_eq!(index.reason, "observed probes");
1849        assert!(index.actual_bytes >= index.estimated_bytes);
1850        assert!(index.accepted);
1851    }
1852
1853    #[test]
1854    fn intern_round_trips() {
1855        let g = small_graph();
1856        let ds = FrozenIndexedDataset::from_graph(&g);
1857        let a = Term::NamedNode(nn("http://ex/a"));
1858        let id = ds.terms.intern(a.clone());
1859        assert_eq!(ds.terms.externalize(id), Some(a));
1860    }
1861
1862    #[test]
1863    fn contains_triple() {
1864        let g = small_graph();
1865        let ds = FrozenIndexedDataset::from_graph(&g);
1866        let s = ds.terms.get(&Term::NamedNode(nn("http://ex/a"))).unwrap();
1867        let p = ds.terms.get(&Term::NamedNode(nn("http://ex/p"))).unwrap();
1868        let o = ds.terms.get(&Term::NamedNode(nn("http://ex/b"))).unwrap();
1869        assert!(ds.default_graph.contains(s, p, o));
1870    }
1871
1872    #[test]
1873    fn missing_triple_not_found() {
1874        let g = small_graph();
1875        let ds = FrozenIndexedDataset::from_graph(&g);
1876        let s = ds.terms.get(&Term::NamedNode(nn("http://ex/a"))).unwrap();
1877        let p = ds.terms.get(&Term::NamedNode(nn("http://ex/q"))).unwrap();
1878        let o = ds.terms.get(&Term::NamedNode(nn("http://ex/c"))).unwrap();
1879        assert!(!ds.default_graph.contains(s, p, o));
1880    }
1881
1882    #[test]
1883    fn range_s_returns_correct_triples() {
1884        let g = small_graph();
1885        let ds = FrozenIndexedDataset::from_graph(&g);
1886        let s = ds.terms.get(&Term::NamedNode(nn("http://ex/a"))).unwrap();
1887        assert_eq!(ds.default_graph.scan(Some(s), None, None).count(), 2);
1888    }
1889
1890    #[test]
1891    fn extend_triples_updates_indexes_and_statistics() {
1892        let g = small_graph();
1893        let mut ds = FrozenIndexedDataset::from_graph(&g);
1894        let added = triple_nnn("http://ex/new", "http://ex/p", "http://ex/b");
1895        assert_eq!(ds.revision(), 0);
1896        ds.extend_triples([&added, &added]);
1897        assert_eq!(ds.revision(), 1);
1898        ds.extend_triples([&added]);
1899        assert_eq!(ds.revision(), 1, "a no-op batch is not a commit");
1900
1901        let s = ds.intern(&Term::NamedNode(nn("http://ex/new")));
1902        let p = ds.intern(&Term::NamedNode(nn("http://ex/p")));
1903        let o = ds.intern(&Term::NamedNode(nn("http://ex/b")));
1904        assert!(ds.contains_ids(s, p, o));
1905        assert_eq!(ds.stats.triple_count, 4);
1906        assert_eq!(ds.stats.predicate_cardinality[&p], 3);
1907    }
1908
1909    #[test]
1910    fn inferred_predicate_has_an_index_profile_label() {
1911        let mut ds = FrozenIndexedDataset::from_graph(&Graph::new());
1912        let predicate = nn("http://ex/inferred");
1913        let object = nn("http://ex/value");
1914        let triples: Vec<_> = (0..128)
1915            .map(|i| {
1916                Triple::new(
1917                    nn(&format!("http://ex/s{i}")),
1918                    predicate.clone(),
1919                    object.clone(),
1920                )
1921            })
1922            .collect();
1923        ds.extend_triples(triples.iter());
1924        let p = ds.intern(&Term::NamedNode(predicate));
1925        let o = ds.intern(&Term::NamedNode(object));
1926        crate::profile::enable();
1927        for _ in 0..3 {
1928            assert_eq!(ds.default_graph.scan(None, Some(p), Some(o)).count(), 128);
1929        }
1930        let profile = crate::profile::take().unwrap();
1931        assert_eq!(
1932            profile.indexes()[0].predicate.as_deref(),
1933            Some("http://ex/inferred")
1934        );
1935    }
1936
1937    #[test]
1938    fn internalize_unknown_term_gets_unique_id() {
1939        let g = small_graph();
1940        let ds = FrozenIndexedDataset::from_graph(&g);
1941        let rds: &FrozenIndexedDataset = &ds;
1942        let unknown1 = Term::NamedNode(nn("http://ex/unknown1"));
1943        let unknown2 = Term::NamedNode(nn("http://ex/unknown2"));
1944        let id1 = rds.internalize_term(unknown1.clone()).unwrap();
1945        let id2 = rds.internalize_term(unknown2.clone()).unwrap();
1946        assert_ne!(id1, id2, "different unknown terms must get different IDs");
1947        // Same term gets same ID (idempotent)
1948        let id1b = rds.internalize_term(unknown1).unwrap();
1949        assert_eq!(id1, id1b);
1950    }
1951
1952    #[test]
1953    fn queryable_dataset_default_graph_pattern() {
1954        let g = small_graph();
1955        let ds = FrozenIndexedDataset::from_graph(&g);
1956        let rds: &FrozenIndexedDataset = &ds;
1957        // Query: ?s ?p ?o in default graph → should return all 3 triples
1958        let quads: Vec<_> = rds
1959            .internal_quads_for_pattern(None, None, None, Some(None))
1960            .map(|r| r.unwrap())
1961            .collect();
1962        assert_eq!(quads.len(), 3);
1963    }
1964
1965    #[test]
1966    fn queryable_dataset_named_graph_empty_when_not_loaded() {
1967        let g = small_graph();
1968        let ds = FrozenIndexedDataset::from_graph(&g);
1969        let rds: &FrozenIndexedDataset = &ds;
1970        // any named graph → nothing (no named graphs loaded)
1971        let quads: Vec<_> = rds
1972            .internal_quads_for_pattern(None, None, None, None)
1973            .collect();
1974        assert!(quads.is_empty());
1975    }
1976
1977    #[test]
1978    fn from_graphs_loads_both_default_and_named() {
1979        let data = small_graph();
1980        let mut shapes = Graph::new();
1981        shapes.insert(
1982            triple_nnn(
1983                "http://ex/S",
1984                "http://www.w3.org/ns/shacl#targetNode",
1985                "http://ex/a",
1986            )
1987            .as_ref(),
1988        );
1989        let ds = FrozenIndexedDataset::from_graphs(&data, &shapes);
1990        let rds: &FrozenIndexedDataset = &ds;
1991
1992        // default graph has 3 triples
1993        let default_quads: Vec<_> = rds
1994            .internal_quads_for_pattern(None, None, None, Some(None))
1995            .map(|r| r.unwrap())
1996            .collect();
1997        assert_eq!(default_quads.len(), 3);
1998
1999        // named graph has 1 triple
2000        let shapes_iri_id = ds
2001            .terms
2002            .get(&Term::NamedNode(nn(SHAPES_GRAPH_IRI)))
2003            .expect("shapes graph IRI should be interned");
2004        let named_quads: Vec<_> = rds
2005            .internal_quads_for_pattern(None, None, None, Some(Some(&shapes_iri_id)))
2006            .map(|r| r.unwrap())
2007            .collect();
2008        assert_eq!(named_quads.len(), 1);
2009    }
2010
2011    #[test]
2012    fn named_graph_bound_subject_and_predicate_ranges_preserve_answers() {
2013        let data = Graph::new();
2014        let mut shapes = Graph::new();
2015        for triple in [
2016            triple_nnn("http://ex/a", "http://ex/p", "http://ex/one"),
2017            triple_nnn("http://ex/a", "http://ex/p", "http://ex/two"),
2018            triple_nnn("http://ex/a", "http://ex/q", "http://ex/three"),
2019            triple_nnn("http://ex/b", "http://ex/p", "http://ex/four"),
2020        ] {
2021            shapes.insert(&triple);
2022        }
2023        let ds = FrozenIndexedDataset::from_graphs(&data, &shapes);
2024        let a = ds.intern(&Term::NamedNode(nn("http://ex/a")));
2025        let p = ds.intern(&Term::NamedNode(nn("http://ex/p")));
2026        let g = ds.intern(&Term::NamedNode(nn(SHAPES_GRAPH_IRI)));
2027        assert_eq!(ds.scan(Some(a), None, None, GraphSel::Named(g)).count(), 3);
2028        assert_eq!(
2029            ds.scan(Some(a), Some(p), None, GraphSel::Named(g)).count(),
2030            2
2031        );
2032        assert_eq!(
2033            ds.scan(
2034                Some(a),
2035                Some(p),
2036                Some(ds.intern(&Term::NamedNode(nn("http://ex/two")))),
2037                GraphSel::Named(g)
2038            )
2039            .count(),
2040            1
2041        );
2042    }
2043
2044    #[test]
2045    fn source_ids_and_named_index_are_shared_without_restoring_embedded_deletions() {
2046        let mut shapes = Graph::new();
2047        let overlap = triple_nnn("http://ex/a", "http://ex/p", "http://ex/b");
2048        shapes.insert(&overlap);
2049        let source = SourceStorage::encode(&shapes);
2050        let empty = Graph::new();
2051        let separate =
2052            FrozenIndexedDataset::from_data_with_source(&empty, Arc::clone(&source), true, true);
2053        let embedded_after_delete =
2054            FrozenIndexedDataset::from_data_with_source(&empty, Arc::clone(&source), false, false);
2055        let s = separate.intern(&Term::NamedNode(nn("http://ex/a")));
2056        let p = separate.intern(&Term::NamedNode(nn("http://ex/p")));
2057        let o = separate.intern(&Term::NamedNode(nn("http://ex/b")));
2058        let g = separate.intern(&Term::NamedNode(nn(SHAPES_GRAPH_IRI)));
2059        assert_eq!(
2060            s,
2061            embedded_after_delete.intern(&Term::NamedNode(nn("http://ex/a")))
2062        );
2063        assert!(separate.contains_ids(s, p, o));
2064        assert!(!embedded_after_delete.contains_ids(s, p, o));
2065        assert_eq!(separate.stats.triple_count, 1);
2066        assert_eq!(embedded_after_delete.stats.triple_count, 0);
2067        assert_eq!(separate.default_graph.len(), 0);
2068        assert_eq!(
2069            separate.scan(None, None, None, GraphSel::Default).count(),
2070            1
2071        );
2072        assert_eq!(
2073            embedded_after_delete
2074                .scan(None, None, None, GraphSel::Default)
2075                .count(),
2076            0
2077        );
2078        assert_eq!(
2079            embedded_after_delete
2080                .scan(Some(s), Some(p), Some(o), GraphSel::Named(g))
2081                .count(),
2082            1
2083        );
2084        assert!(Arc::ptr_eq(
2085            separate.named_graphs.get(&g).unwrap(),
2086            embedded_after_delete.named_graphs.get(&g).unwrap(),
2087        ));
2088    }
2089
2090    #[test]
2091    fn separate_overlap_has_two_memberships_but_one_union_result() {
2092        let mut shapes = Graph::new();
2093        let overlap = triple_nnn("http://ex/a", "http://ex/p", "http://ex/b");
2094        shapes.insert(&overlap);
2095        shapes.insert(&triple_nnn(
2096            "http://ex/a",
2097            "http://ex/q",
2098            "http://ex/source",
2099        ));
2100        let mut data = Graph::new();
2101        data.insert(&overlap);
2102        data.insert(&triple_nnn("http://ex/a", "http://ex/r", "http://ex/local"));
2103        let source = SourceStorage::encode(&shapes);
2104        let mut union =
2105            FrozenIndexedDataset::from_data_with_source(&data, Arc::clone(&source), true, true);
2106        let data_only = FrozenIndexedDataset::from_data_with_source(&data, source, true, false);
2107        let g = union.intern(&Term::NamedNode(nn(SHAPES_GRAPH_IRI)));
2108        assert_eq!(union.scan(None, None, None, GraphSel::Default).count(), 3);
2109        assert_eq!(
2110            data_only.scan(None, None, None, GraphSel::Default).count(),
2111            2
2112        );
2113        assert_eq!(union.scan(None, None, None, GraphSel::Named(g)).count(), 2);
2114        assert_eq!(union.stats.triple_count, 3);
2115        assert_eq!(data_only.stats.triple_count, 2);
2116        union.select_data_view();
2117        assert_eq!(
2118            union
2119                .scan(None, None, None, GraphSel::Default)
2120                .collect::<HashSet<_>>(),
2121            data_only
2122                .scan(None, None, None, GraphSel::Default)
2123                .collect::<HashSet<_>>()
2124        );
2125        assert_eq!(union.scan(None, None, None, GraphSel::Named(g)).count(), 2);
2126        assert_eq!(union.stats.triple_count, 2);
2127    }
2128
2129    #[test]
2130    fn separately_loaded_blank_node_labels_keep_distinct_identity() {
2131        let shapes = shifty_parse::load_turtle(
2132            br#"@prefix ex: <http://ex/> . _:same ex:p ex:value ."#,
2133            None,
2134        )
2135        .unwrap();
2136        let data = shifty_parse::load_turtle(
2137            br#"@prefix ex: <http://ex/> . _:same ex:p ex:value ."#,
2138            None,
2139        )
2140        .unwrap();
2141        let source = SourceStorage::encode(&shapes.graph);
2142        let dataset = FrozenIndexedDataset::from_data_with_source(&data.graph, source, true, true);
2143        let subjects: HashSet<_> = dataset
2144            .scan(None, None, None, GraphSel::Default)
2145            .map(|[subject, _, _]| subject)
2146            .collect();
2147        assert_eq!(dataset.stats.triple_count, 2);
2148        assert_eq!(subjects.len(), 2);
2149    }
2150
2151    #[test]
2152    fn embedded_source_membership_uses_exclusions_after_deletion() {
2153        let mut source_graph = Graph::new();
2154        let first = triple_nnn("http://ex/a", "http://ex/p", "http://ex/one");
2155        let second = triple_nnn("http://ex/a", "http://ex/p", "http://ex/two");
2156        source_graph.insert(&first);
2157        source_graph.insert(&second);
2158        let source = SourceStorage::encode(&source_graph);
2159        let full = FrozenIndexedDataset::from_data_with_source(
2160            &source_graph,
2161            Arc::clone(&source),
2162            false,
2163            false,
2164        );
2165        assert!(matches!(
2166            full.source_view.as_ref().unwrap().membership,
2167            SourceMembership::All
2168        ));
2169        let mut edited = source_graph.clone();
2170        edited.remove(&second);
2171        let after_delete =
2172            FrozenIndexedDataset::from_data_with_source(&edited, source, false, false);
2173        assert_eq!(
2174            after_delete
2175                .scan(None, None, None, GraphSel::Default)
2176                .count(),
2177            1
2178        );
2179        assert_eq!(after_delete.stats.triple_count, 1);
2180        assert_eq!(after_delete.default_graph.len(), 0);
2181    }
2182
2183    #[test]
2184    fn graph_union_builds_one_deduplicated_default_graph() {
2185        let left = small_graph();
2186        let mut right = Graph::new();
2187        right.insert(triple_nnn("http://ex/a", "http://ex/p", "http://ex/b").as_ref());
2188        right.insert(triple_nnn("http://ex/new", "http://ex/p", "http://ex/b").as_ref());
2189
2190        let ds = FrozenIndexedDataset::from_graph_union(&left, &right);
2191
2192        assert_eq!(ds.stats.triple_count, 4);
2193    }
2194}