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;