Skip to main content

purrdf_slice/
cache.rs

1// SPDX-FileCopyrightText: 2026 Blackcat Informatics® Inc. <paudley@blackcatinformatics.ca>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Phase-specific, path-independent, semantic Merkle cache keys plus
5//! SCC / profile composition (§12 / §8, child S6a).
6//!
7//! # Principle 12 — semantic, phase-specific, path-independent cache keys
8//!
9//! The legacy cache hashed *physical relative paths, file sizes, and raw bytes*
10//! (`src/purrdf_tools/generator.py::source_hash`), which conflicts with the
11//! doctrine that the slice-group path carries no semantics: moving
12//! `slices/core/x` to another group must **not** invalidate its semantic
13//! compilation. This module replaces that with a Merkle-style key built **only**
14//! from content / IRI / version-string inputs. **No filesystem directory name
15//! ever enters a key.** Every input is one of:
16//!
17//! - the slice IRI (public, persistent identity — never a numeric interner ID),
18//! - a content digest (raw byte digest or canonical-RDF *semantic* digest),
19//! - a normalized *logical* artifact path (intra-slice, group-prefix-free),
20//! - the dependency-output digests of upstream units (Merkle composition),
21//! - a [`ToolchainContext`] version string + reasoning-profile id.
22//!
23//! Different [`Phase`]s select different digest **roots** so that a change that
24//! is invisible to a phase does not invalidate that phase's key:
25//!
26//! - [`Phase::Parse`] / [`Phase::Syntax`] are **byte-sensitive** — they include
27//!   the *raw* artifact digest, so a comment-only edit *does* change them.
28//! - [`Phase::Reason`] is **semantics-sensitive** — it includes the *semantic*
29//!   (canonical N-Triples) digest and **excludes raw bytes / comments**, so a
30//!   comment-only edit that leaves the canonical RDF unchanged produces the
31//!   **same** reasoning key (the headline acceptance).
32//! - [`Phase::Shacl`] reasons over semantic module/data + shapes (semantic).
33//! - [`Phase::Bundle`] packages raw bytes + metadata (byte-sensitive).
34//!
35//! # Principle 8 — source / link / product units
36//!
37//! - **Source unit** = one slice (parse, lint, inventory, hash).
38//! - **Link unit** = a dependency strongly-connected component; mutually
39//!   dependent slices collapse into one [`LinkUnit`] (so a core cycle reasons as
40//!   one union), while singletons remain their own link unit. Member slice IRIs
41//!   are retained on the link unit so **output attribution stays at slice
42//!   granularity even when execution occurs over an SCC**.
43//! - **Product unit** = a dependency-closed profile / bundle (validate full
44//!   composition) — see [`ProductUnit`] / [`dependency_closure`].
45//!
46//! # Persistent-vs-runtime ID rule (S0.5)
47//!
48//! Cache keys hash **content / IRIs / version strings**, never numeric runtime
49//! interner IDs (`UnitId` / `TermId` / `QuadId` / …). The slice IRI is the
50//! persistent identity; the group path is never read.
51
52use std::collections::{BTreeMap, BTreeSet};
53
54use petgraph::graph::{DiGraph, NodeIndex};
55use sha2::{Digest, Sha256};
56
57use crate::artifact::{ArtifactRecord, ArtifactRole};
58use crate::catalog::{SliceCatalog, SliceRecord};
59use crate::error::SliceError;
60use crate::ownership::{DependencyEdge, ReconciliationStatus, SliceIri};
61
62// ── Phases ──────────────────────────────────────────────────────────────────
63
64/// A compilation phase. Each phase selects a different Merkle root so a change
65/// invisible to the phase does not invalidate its key (RFC §12).
66#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
67pub enum Phase {
68    /// Raw-byte parse of source artifacts. Byte-sensitive (raw digest).
69    Parse,
70    /// Turtle / RDF syntax check. Byte-sensitive (raw digest).
71    Syntax,
72    /// SHACL validation: semantic module/data + shapes + config. Semantic.
73    Shacl,
74    /// OWL/logic reasoning: semantic module + dependency closure + rules.
75    /// Semantic — **excludes raw bytes / comments** (comment-only invariance).
76    Reason,
77    /// Packaging into the GTS bundle: raw bytes + metadata. Byte-sensitive.
78    Bundle,
79}
80
81impl Phase {
82    /// A stable, path-free discriminator string folded into the key so the same
83    /// inputs under different phases never collide.
84    fn tag(self) -> &'static str {
85        match self {
86            Self::Parse => "phase:parse",
87            Self::Syntax => "phase:syntax",
88            Self::Shacl => "phase:shacl",
89            Self::Reason => "phase:reason",
90            Self::Bundle => "phase:bundle",
91        }
92    }
93
94    /// Whether this phase is **byte-sensitive** (folds the raw artifact digest,
95    /// so a comment-only edit changes the key). The complement — [`Phase::Shacl`]
96    /// and [`Phase::Reason`] — is **semantics-sensitive** and folds the
97    /// canonical-RDF *semantic* digest instead, achieving comment-only
98    /// invariance for the reasoning phase.
99    pub fn is_byte_sensitive(self) -> bool {
100        match self {
101            Self::Parse | Self::Syntax | Self::Bundle => true,
102            Self::Shacl | Self::Reason => false,
103        }
104    }
105
106    /// Whether this phase folds the dependency closure of upstream units (RFC
107    /// §12: reasoning = semantic module + dependency closure + rules). Parse /
108    /// syntax of a single source unit are intra-slice and ignore the closure.
109    fn folds_dependencies(self) -> bool {
110        match self {
111            Self::Reason | Self::Shacl | Self::Bundle => true,
112            Self::Parse | Self::Syntax => false,
113        }
114    }
115
116    /// The artifact roles whose digests feed *this* phase's root. Selecting
117    /// roles per phase is what makes the key phase-specific: a docs-only or
118    /// example-only change cannot invalidate the reasoning key.
119    fn includes_role(self, role: &ArtifactRole) -> bool {
120        match self {
121            // Parse / syntax look at every authored artifact's bytes.
122            Self::Parse | Self::Syntax | Self::Bundle => true,
123            // Reasoning closure = ontology modules + shapes + rules; not docs,
124            // examples, citations, translations, or test/query prose. The
125            // manifest is ALWAYS semantically load-bearing (tier / sliceDependsOn
126            // / profile) and so is folded under the semantic phases too — its
127            // *semantic* digest, so a comment-only manifest edit is still
128            // invisible. (manifest.ttl is RDF, so it always carries a semantic
129            // digest — see catalog.rs `compute_semantic_digest`.)
130            Self::Reason => matches!(
131                role,
132                ArtifactRole::Module | ArtifactRole::Shapes | ArtifactRole::Manifest
133            ),
134            // SHACL = semantic module/data + shapes + manifest-borne facts.
135            Self::Shacl => matches!(
136                role,
137                ArtifactRole::Module | ArtifactRole::Shapes | ArtifactRole::Manifest
138            ),
139        }
140    }
141}
142
143// ── Toolchain context ───────────────────────────────────────────────────────
144
145/// The toolchain / configuration context folded into every key: compiler/rule
146/// version and the active reasoning-profile id. These are **version strings**,
147/// never numeric runtime IDs (S0.5).
148#[derive(Debug, Clone, PartialEq, Eq)]
149pub struct ToolchainContext {
150    /// The compiler / rule-engine version string (e.g. `"purrdf-logic v1"`).
151    pub compiler_version: String,
152    /// The active reasoning-profile id (e.g. `"el"`, `"dl"`, `"native"`).
153    pub reasoning_profile: String,
154}
155
156impl ToolchainContext {
157    /// Construct a toolchain context.
158    pub fn new(compiler_version: impl Into<String>, reasoning_profile: impl Into<String>) -> Self {
159        Self {
160            compiler_version: compiler_version.into(),
161            reasoning_profile: reasoning_profile.into(),
162        }
163    }
164}
165
166// ── Compilation units (RFC §8) ──────────────────────────────────────────────
167
168/// A **link unit**: a dependency strongly-connected component (RFC §8). Mutually
169/// dependent slices collapse into one link unit (reason them as one union);
170/// singletons are their own unit. The member slice IRIs are retained so output
171/// attribution stays at slice granularity even when execution is over the SCC.
172#[derive(Debug, Clone, PartialEq, Eq)]
173pub struct LinkUnit {
174    /// The slice IRIs that form this SCC, sorted for determinism. A single-slice
175    /// (acyclic) unit has exactly one member.
176    pub members: Vec<SliceIri>,
177}
178
179impl LinkUnit {
180    /// Whether this link unit is a genuine cycle (more than one member).
181    pub fn is_cycle(&self) -> bool {
182        self.members.len() > 1
183    }
184
185    /// Whether the given slice IRI is a member of this link unit (attribution at
186    /// slice granularity).
187    pub fn contains(&self, slice: &str) -> bool {
188        self.members.iter().any(|m| m == slice)
189    }
190}
191
192/// A **product unit**: a dependency-closed profile / bundle (RFC §8). It names a
193/// root slice (or profile seed) and the transitive closure of its dependencies.
194#[derive(Debug, Clone, PartialEq, Eq)]
195pub struct ProductUnit {
196    /// The seed slice IRI(s) the product is built around (sorted).
197    pub seeds: Vec<SliceIri>,
198    /// The dependency-closed member set: seeds ∪ all transitive dependencies
199    /// (sorted, deduplicated).
200    pub closure: Vec<SliceIri>,
201}
202
203// ── SCC composition ─────────────────────────────────────────────────────────
204
205/// Which dependency edges drive composition. Per RFC §10 only *semantic* edges
206/// reconcile with a real build dependency; we further require the edge be
207/// `Matched` or `Undeclared` (i.e. backed by semantic evidence), never a
208/// `Stale` synthetic edge (declared-but-unobserved).
209fn is_build_edge(edge: &DependencyEdge) -> bool {
210    edge.edge_kind.is_semantic() && edge.reconciliation != ReconciliationStatus::Stale
211}
212
213/// Build a directed dependency graph projected from S4 edges, over the slice
214/// IRIs of a catalog: a `from → to` edge means *from depends on to*. Every slice
215/// in the catalog is a node, even with no edges (singletons must still appear as
216/// their own link unit / product seed).
217fn build_unit_graph(catalog: &SliceCatalog, edges: &[DependencyEdge]) -> DiGraph<SliceIri, ()> {
218    let mut graph = DiGraph::new();
219    let mut index: BTreeMap<SliceIri, NodeIndex> = BTreeMap::new();
220
221    // Sort slice IRIs for deterministic node insertion order.
222    let mut slices: Vec<SliceIri> = catalog
223        .records()
224        .iter()
225        .map(|r| r.manifest.slice_iri.clone())
226        .collect();
227    slices.sort();
228    slices.dedup();
229    for slice in &slices {
230        let idx = graph.add_node(slice.clone());
231        index.insert(slice.clone(), idx);
232    }
233
234    // Add build-relevant edges (semantic, non-stale). Deduplicate so multiple
235    // evidence rows for one (from,to) do not multiply edges.
236    let mut seen: BTreeSet<(SliceIri, SliceIri)> = BTreeSet::new();
237    for edge in edges {
238        if !is_build_edge(edge) {
239            continue;
240        }
241        let key = (edge.from_slice.clone(), edge.to_slice.clone());
242        if !seen.insert(key) {
243            continue;
244        }
245        let (Some(&from), Some(&to)) = (index.get(&edge.from_slice), index.get(&edge.to_slice))
246        else {
247            // An edge to/from a slice not in the catalog is ignored — the
248            // catalog node set is authoritative.
249            continue;
250        };
251        graph.add_edge(from, to, ());
252    }
253
254    graph
255}
256
257/// Compute the **link units** (SCCs) of a catalog under its S4 dependency edges
258/// (RFC §8). Mutually dependent slices collapse to one [`LinkUnit`]; singletons
259/// remain individually nameable. The result is deterministic: members are
260/// sorted within each unit, and units are sorted by their smallest member.
261pub fn link_units(catalog: &SliceCatalog, edges: &[DependencyEdge]) -> Vec<LinkUnit> {
262    let graph = build_unit_graph(catalog, edges);
263    let mut units: Vec<LinkUnit> = petgraph::algo::tarjan_scc(&graph)
264        .into_iter()
265        .map(|component| {
266            let mut members: Vec<SliceIri> =
267                component.into_iter().map(|n| graph[n].clone()).collect();
268            members.sort();
269            LinkUnit { members }
270        })
271        .collect();
272    units.sort_by(|a, b| a.members.first().cmp(&b.members.first()));
273    units
274}
275
276/// The dependency-closed member set of `seeds` over the catalog's S4 edges:
277/// `seeds ∪ all transitive dependencies` (RFC §8 product unit / §4 closure).
278/// Path-independent — built from slice IRIs only.
279pub fn dependency_closure(
280    catalog: &SliceCatalog,
281    edges: &[DependencyEdge],
282    seeds: &[SliceIri],
283) -> Vec<SliceIri> {
284    // Adjacency: from → {to} over build edges.
285    let valid: BTreeSet<&SliceIri> = catalog
286        .records()
287        .iter()
288        .map(|r| &r.manifest.slice_iri)
289        .collect();
290    let mut adj: BTreeMap<SliceIri, BTreeSet<SliceIri>> = BTreeMap::new();
291    for edge in edges {
292        if !is_build_edge(edge) {
293            continue;
294        }
295        adj.entry(edge.from_slice.clone())
296            .or_default()
297            .insert(edge.to_slice.clone());
298    }
299
300    let mut closure: BTreeSet<SliceIri> = BTreeSet::new();
301    let mut stack: Vec<SliceIri> = Vec::new();
302    for seed in seeds {
303        if valid.contains(seed) {
304            stack.push(seed.clone());
305        }
306    }
307    while let Some(node) = stack.pop() {
308        if !closure.insert(node.clone()) {
309            continue;
310        }
311        if let Some(targets) = adj.get(&node) {
312            for t in targets {
313                if valid.contains(t) && !closure.contains(t) {
314                    stack.push(t.clone());
315                }
316            }
317        }
318    }
319    closure.into_iter().collect()
320}
321
322/// Build a [`ProductUnit`] for `seeds`: a dependency-closed profile (RFC §8).
323pub fn product_unit(
324    catalog: &SliceCatalog,
325    edges: &[DependencyEdge],
326    seeds: &[SliceIri],
327) -> ProductUnit {
328    let mut seed_vec: Vec<SliceIri> = seeds.to_vec();
329    seed_vec.sort();
330    seed_vec.dedup();
331    let closure = dependency_closure(catalog, edges, &seed_vec);
332    ProductUnit {
333        seeds: seed_vec,
334        closure,
335    }
336}
337
338// ── Per-slice phase digest (the Merkle leaf) ────────────────────────────────
339
340/// Select the per-artifact digest a phase folds. Byte-sensitive phases fold the
341/// **raw** digest (comments matter); semantics-sensitive phases fold the
342/// **semantic** (canonical N-Triples) digest (comments do not). This single
343/// selection is the mechanism behind the comment-only reasoning invariance.
344fn phase_artifact_digest(phase: Phase, artifact: &ArtifactRecord) -> Result<String, SliceError> {
345    if phase.is_byte_sensitive() {
346        return Ok(artifact.raw_digest.clone());
347    }
348    // Semantic phase: an RDF artifact must carry a semantic digest; a
349    // semantics-sensitive phase that only includes RDF roles (Module/Shapes)
350    // therefore always has one. Missing it is a hard failure (no-optionality).
351    match &artifact.semantic_digest {
352        Some(d) => Ok(d.clone()),
353        None => Err(SliceError::InvalidManifest(format!(
354            "phase {} requires a semantic digest for artifact {} (role {:?}) but none was computed",
355            phase.tag(),
356            artifact.logical_path,
357            artifact.role
358        ))),
359    }
360}
361
362/// Compute the **per-slice phase leaf digest**: a SHA-256 over the phase tag, the
363/// slice IRI, and the selected (role-filtered) artifact digests keyed by logical
364/// path. Path-independent: only the slice IRI and *logical* (intra-slice) paths
365/// enter — never the group directory.
366fn slice_phase_leaf(
367    phase: Phase,
368    record: &SliceRecord,
369    toolchain: &ToolchainContext,
370) -> Result<String, SliceError> {
371    let mut hasher = Sha256::new();
372    hasher.update(phase.tag().as_bytes());
373    hasher.update(b"\x1f");
374    hasher.update(b"slice-iri\x1f");
375    hasher.update(record.manifest.slice_iri.as_bytes());
376    hasher.update(b"\x1f");
377    hasher.update(b"compiler\x1f");
378    hasher.update(toolchain.compiler_version.as_bytes());
379    hasher.update(b"\x1f");
380    hasher.update(b"reasoning-profile\x1f");
381    hasher.update(toolchain.reasoning_profile.as_bytes());
382    hasher.update(b"\x1f");
383
384    // The manifest is always semantically load-bearing (tier / deps / profiles):
385    // fold its digest under every phase, selecting raw vs semantic per phase.
386    // We fold every included artifact in sorted logical-path order.
387    let mut leaves: BTreeMap<&str, (String, &ArtifactRole)> = BTreeMap::new();
388    for artifact in &record.artifacts {
389        if !phase.includes_role(&artifact.role) {
390            continue;
391        }
392        let digest = phase_artifact_digest(phase, artifact)?;
393        leaves.insert(artifact.logical_path.as_str(), (digest, &artifact.role));
394    }
395
396    hasher.update(b"artifacts\x1f");
397    for (logical_path, (digest, role)) in &leaves {
398        // The logical path is intra-slice (group-prefix-free) and stays in the
399        // key for artifact attribution; it is NOT a filesystem directory name.
400        hasher.update(logical_path.as_bytes());
401        hasher.update(b"\x1f");
402        hasher.update(format!("{role:?}").as_bytes());
403        hasher.update(b"\x1f");
404        hasher.update(digest.as_bytes());
405        hasher.update(b"\x1e");
406    }
407
408    Ok(hex(hasher.finalize().as_slice()))
409}
410
411// ── Merkle cache key ────────────────────────────────────────────────────────
412
413/// A computed cache key for a (phase, unit) pair: the Merkle root plus the unit
414/// members it covers (for diagnostics / attribution).
415#[derive(Debug, Clone, PartialEq, Eq)]
416pub struct CacheKey {
417    /// The phase this key is for.
418    pub phase: Phase,
419    /// The slice IRIs covered by this key (one for a source unit, the SCC
420    /// members for a link unit, the closure for a product unit).
421    pub members: Vec<SliceIri>,
422    /// The hex SHA-256 Merkle root.
423    pub root: String,
424}
425
426/// Internal Merkle-root builder shared by source / link / product keys.
427///
428/// Folds, in deterministic order: the phase tag, the toolchain context, each
429/// member slice's phase leaf digest, and — for dependency-folding phases — the
430/// phase leaf digests of every transitive dependency (the upstream
431/// "dependency-output digests" of RFC §12). Path-independent throughout.
432fn merkle_root(
433    phase: Phase,
434    catalog: &SliceCatalog,
435    edges: &[DependencyEdge],
436    members: &[SliceIri],
437    toolchain: &ToolchainContext,
438) -> Result<String, SliceError> {
439    // The set of slices whose leaves enter the key: the members, plus their
440    // dependency closure when the phase folds dependencies.
441    let mut covered: BTreeSet<SliceIri> = members.iter().cloned().collect();
442    if phase.folds_dependencies() {
443        for dep in dependency_closure(catalog, edges, members) {
444            covered.insert(dep);
445        }
446    }
447
448    // Compute each covered slice's phase leaf; hard-fail on an unknown slice.
449    let mut leaves: BTreeMap<SliceIri, String> = BTreeMap::new();
450    for slice in &covered {
451        let record = catalog.get(slice).ok_or_else(|| {
452            SliceError::InvalidManifest(format!("cache key references unknown slice IRI {slice}"))
453        })?;
454        leaves.insert(slice.clone(), slice_phase_leaf(phase, record, toolchain)?);
455    }
456
457    let mut hasher = Sha256::new();
458    hasher.update(b"merkle-root\x1f");
459    hasher.update(phase.tag().as_bytes());
460    hasher.update(b"\x1f");
461    hasher.update(toolchain.compiler_version.as_bytes());
462    hasher.update(b"\x1f");
463    hasher.update(toolchain.reasoning_profile.as_bytes());
464    hasher.update(b"\x1f");
465    // Sorted by slice IRI → order-independent of catalog discovery order and of
466    // the filesystem group path.
467    for (slice, leaf) in &leaves {
468        hasher.update(slice.as_bytes());
469        hasher.update(b"\x1f");
470        hasher.update(leaf.as_bytes());
471        hasher.update(b"\x1e");
472    }
473    Ok(hex(hasher.finalize().as_slice()))
474}
475
476/// Compute the cache key for a **source unit** (one slice) at `phase`.
477///
478/// For dependency-folding phases the upstream closure's leaves are folded too,
479/// so a change to a dependency invalidates the dependent's key (Merkle
480/// composition). Path-independent: moving the slice's directory changes nothing.
481pub fn source_unit_key(
482    phase: Phase,
483    catalog: &SliceCatalog,
484    edges: &[DependencyEdge],
485    slice: &str,
486    toolchain: &ToolchainContext,
487) -> Result<CacheKey, SliceError> {
488    let members = vec![slice.to_string()];
489    let root = merkle_root(phase, catalog, edges, &members, toolchain)?;
490    Ok(CacheKey {
491        phase,
492        members,
493        root,
494    })
495}
496
497/// Compute the cache key for a **link unit** (SCC) at `phase`. All SCC members
498/// are folded together (they reason as one union), and — for dependency-folding
499/// phases — the union's dependency closure as well.
500pub fn link_unit_key(
501    phase: Phase,
502    catalog: &SliceCatalog,
503    edges: &[DependencyEdge],
504    unit: &LinkUnit,
505    toolchain: &ToolchainContext,
506) -> Result<CacheKey, SliceError> {
507    let mut members = unit.members.clone();
508    members.sort();
509    let root = merkle_root(phase, catalog, edges, &members, toolchain)?;
510    Ok(CacheKey {
511        phase,
512        members,
513        root,
514    })
515}
516
517/// Compute the cache key for a **product unit** (dependency-closed profile) at
518/// `phase`. The whole closure's leaves are folded (validate full composition).
519pub fn product_unit_key(
520    phase: Phase,
521    catalog: &SliceCatalog,
522    edges: &[DependencyEdge],
523    unit: &ProductUnit,
524    toolchain: &ToolchainContext,
525) -> Result<CacheKey, SliceError> {
526    // The product key covers the full closure regardless of phase folding (the
527    // product *is* the composition), so we pass the closure as members.
528    let root = merkle_root(phase, catalog, edges, &unit.closure, toolchain)?;
529    Ok(CacheKey {
530        phase,
531        members: unit.closure.clone(),
532        root,
533    })
534}
535
536// ── Hex helper ──────────────────────────────────────────────────────────────
537
538fn hex(bytes: &[u8]) -> String {
539    use std::fmt::Write as _;
540    let mut s = String::with_capacity(bytes.len() * 2);
541    for b in bytes {
542        let _ = write!(s, "{b:02x}");
543    }
544    s
545}
546
547#[cfg(test)]
548mod tests;