Skip to main content

vole_document/field/
node.rs

1//! Canonical procedural seed nodes (Phase 11, ADR-0025).
2//!
3//! A [`SeedNode`] describes one **computation**, not one storage slot: a bounded,
4//! versioned materializer over zero or more dependencies, producing a declared
5//! output kind and logical length. Nodes are canonically encoded (little-endian,
6//! length-prefixed, no serde) so that the same computation always hashes to the
7//! same [`NodeId`] regardless of platform or insertion order.
8//!
9//! The node's `content_id` is **not** stored inside the node: it is
10//! `NodeId::of_node(canonical_bytes)`, which is what makes the graph
11//! content-addressed and immutable. Changing a dependency's bytes yields a
12//! different dependency id, hence a different node id; there is no mutation.
13
14use crate::error::{Error, Result};
15use crate::limits::Limits;
16use crate::store::NodeId;
17
18/// Canonical header byte for the node encoding.
19pub const NODE_MAGIC: u8 = 0xB1;
20/// Recommended maximum canonical node size (framing discipline).
21pub const MAX_NODE_BYTES: usize = 64 * 1024;
22/// Maximum dependency fanout of a single node.
23pub const MAX_NODE_DEPS: usize = 256;
24/// The materializer semantics version for v1.
25pub const MATERIALIZER_VERSION: u16 = 1;
26
27/// What a node computes. The numeric values are part of the canonical encoding.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29#[repr(u8)]
30pub enum NodeKind {
31    /// The whole exact source, materialized from the descriptor blob.
32    DocumentExact = 0x01,
33    /// An exact byte span of the source.
34    SourceSlice = 0x02,
35    /// An exact physical revision span.
36    PdfRevision = 0x03,
37    /// An exact indirect-object byte span (`n g obj … endobj`).
38    PdfObject = 0x04,
39    /// The exact encoded bytes of a stream object's data.
40    PdfStreamEncoded = 0x05,
41    /// The decoded (inflated) bytes of a lone `/FlateDecode` stream.
42    PdfStreamDecoded = 0x06,
43    /// A decoded content stream's operator token span.
44    ContentOperators = 0x07,
45    /// A deterministic text-run projection of content operators.
46    TextRuns = 0x08,
47    /// A page's concatenated decoded content stream.
48    PageContent = 0x09,
49    /// A deterministic structured page preview.
50    PagePreview = 0x0A,
51    /// A physical byte span of one resource object referenced by a page.
52    ResourceRef = 0x0B,
53    /// Concatenation of dependency outputs (exact).
54    Concat = 0x0C,
55    /// A raw exact literal held in the seed store.
56    Literal = 0x0D,
57    /// The whole exact package (ZIP/OCF/OPC) source, materialized through the
58    /// descriptor (`serve_document`). Exact (Phase 12.2).
59    PackageRoot = 0x0E,
60    /// One package member's exact raw compressed/stored span (a `SourceSlice`
61    /// with package provenance). Exact (Phase 12.2).
62    PackageMemberRaw = 0x0F,
63    /// One package member's decoded bytes: raw-DEFLATE inflate (method 8) or the
64    /// stored identity (method 0). Derived, never exact (Phase 12.2).
65    PackageMemberDecoded = 0x10,
66    /// The canonical generic-OPC package graph (content types, parts, package and
67    /// part relationships) derived on demand from the exact package source.
68    /// Derived, never exact (Phase 12.3).
69    PackageOpcModel = 0x11,
70    /// The canonical DOCX discovery model (main part, styles part, stories)
71    /// derived on demand from the canonical OPC model. Derived, never exact
72    /// (Phase 12.4).
73    DocxModel = 0x12,
74    /// One WordprocessingML story parsed into its canonical [`crate::adapter::docx::wml::StoryModel`],
75    /// honoring a declared extraction profile. Derived, never exact (Phase 12.4).
76    DocxStory = 0x13,
77    /// The canonical EPUB (OCF) discovery model: `mimetype` conformance facts,
78    /// container rootfiles, and the Package Document metadata/manifest/spine,
79    /// derived on demand from the exact package source. Derived, never exact
80    /// (Phase 12.5).
81    EpubModel = 0x14,
82    /// One spine item's XHTML content document parsed into its bounded native
83    /// content model (headings/paragraphs/lists/tables/links/resources), honoring
84    /// a declared extraction profile. Derived, never exact (Phase 12.6).
85    EpubContent = 0x15,
86    /// A byte-identical shareable resource (image/font/attachment) held inline and
87    /// addressed by **content identity** (Phase 12.8, ADR-0034). Its canonical
88    /// encoding embeds the exact bytes, so two documents carrying the same resource
89    /// share one node id (and one persisted blob) with no second identity scheme.
90    /// Exact (a resource's bytes are the source bytes).
91    ResourceBlob = 0x16,
92}
93
94impl NodeKind {
95    /// Map a raw kind byte.
96    pub const fn from_u8(b: u8) -> Option<NodeKind> {
97        Some(match b {
98            0x01 => NodeKind::DocumentExact,
99            0x02 => NodeKind::SourceSlice,
100            0x03 => NodeKind::PdfRevision,
101            0x04 => NodeKind::PdfObject,
102            0x05 => NodeKind::PdfStreamEncoded,
103            0x06 => NodeKind::PdfStreamDecoded,
104            0x07 => NodeKind::ContentOperators,
105            0x08 => NodeKind::TextRuns,
106            0x09 => NodeKind::PageContent,
107            0x0A => NodeKind::PagePreview,
108            0x0B => NodeKind::ResourceRef,
109            0x0C => NodeKind::Concat,
110            0x0D => NodeKind::Literal,
111            0x0E => NodeKind::PackageRoot,
112            0x0F => NodeKind::PackageMemberRaw,
113            0x10 => NodeKind::PackageMemberDecoded,
114            0x11 => NodeKind::PackageOpcModel,
115            0x12 => NodeKind::DocxModel,
116            0x13 => NodeKind::DocxStory,
117            0x14 => NodeKind::EpubModel,
118            0x15 => NodeKind::EpubContent,
119            0x16 => NodeKind::ResourceBlob,
120            _ => return None,
121        })
122    }
123
124    /// Stable short name.
125    pub const fn name(self) -> &'static str {
126        match self {
127            NodeKind::DocumentExact => "DocumentExact",
128            NodeKind::SourceSlice => "SourceSlice",
129            NodeKind::PdfRevision => "PdfRevision",
130            NodeKind::PdfObject => "PdfObject",
131            NodeKind::PdfStreamEncoded => "PdfStreamEncoded",
132            NodeKind::PdfStreamDecoded => "PdfStreamDecoded",
133            NodeKind::ContentOperators => "ContentOperators",
134            NodeKind::TextRuns => "TextRuns",
135            NodeKind::PageContent => "PageContent",
136            NodeKind::PagePreview => "PagePreview",
137            NodeKind::ResourceRef => "ResourceRef",
138            NodeKind::Concat => "Concat",
139            NodeKind::Literal => "Literal",
140            NodeKind::PackageRoot => "PackageRoot",
141            NodeKind::PackageMemberRaw => "PackageMemberRaw",
142            NodeKind::PackageMemberDecoded => "PackageMemberDecoded",
143            NodeKind::PackageOpcModel => "PackageOpcModel",
144            NodeKind::DocxModel => "DocxModel",
145            NodeKind::DocxStory => "DocxStory",
146            NodeKind::EpubModel => "EpubModel",
147            NodeKind::EpubContent => "EpubContent",
148            NodeKind::ResourceBlob => "ResourceBlob",
149        }
150    }
151
152    /// Whether the output bytes are an exact, byte-identical observation of the
153    /// source (`true`) or a deterministic derived projection (`false`).
154    ///
155    /// This is the `Q_ref` / `Q_gen` boundary (ADR-0026).
156    pub const fn is_exact(self) -> bool {
157        matches!(
158            self,
159            NodeKind::DocumentExact
160                | NodeKind::SourceSlice
161                | NodeKind::PdfRevision
162                | NodeKind::PdfObject
163                | NodeKind::PdfStreamEncoded
164                | NodeKind::ResourceRef
165                | NodeKind::Concat
166                | NodeKind::Literal
167                // Package physical leaves are exact source spans; a decoded
168                // member (`PackageMemberDecoded`) is derived and is **not** exact.
169                | NodeKind::PackageRoot
170                | NodeKind::PackageMemberRaw
171                // A shared resource is the exact embedded bytes.
172                | NodeKind::ResourceBlob
173        )
174    }
175}
176
177/// A bounded resource envelope declared by a node.
178#[derive(Debug, Clone, Copy, PartialEq, Eq)]
179pub struct NodeLimits {
180    /// Maximum materialized output length this node may produce.
181    pub max_output_bytes: u64,
182    /// Maximum dependency depth permitted below this node.
183    pub max_depth: u16,
184    /// Maximum dependency fanout permitted.
185    pub max_fanout: u16,
186}
187
188impl NodeLimits {
189    /// Conservative defaults used by the PDF inverse compiler.
190    pub const DEFAULT: NodeLimits = NodeLimits {
191        max_output_bytes: 1 << 31,
192        max_depth: 64,
193        max_fanout: MAX_NODE_DEPS as u16,
194    };
195}
196
197/// One canonical procedural seed node.
198#[derive(Debug, Clone, PartialEq, Eq)]
199pub struct SeedNode {
200    /// Computation kind.
201    pub kind: NodeKind,
202    /// Materializer registry id (the kind value for v1).
203    pub materializer_id: u16,
204    /// Materializer semantics version.
205    pub materializer_version: u16,
206    /// Declared logical output length (a bound, checked on materialization).
207    pub logical_output_len: u64,
208    /// Kind-specific canonical parameters.
209    pub params: Vec<u8>,
210    /// Canonical dependency ids actually read (the dynamic read set).
211    pub deps: Vec<NodeId>,
212    /// Short provenance/basis string (adapter-supplied, advisory).
213    pub provenance: String,
214    /// Resource envelope.
215    pub limits: NodeLimits,
216}
217
218impl SeedNode {
219    /// Construct a node with the current materializer version.
220    pub fn new(
221        kind: NodeKind,
222        logical_output_len: u64,
223        params: Vec<u8>,
224        deps: Vec<NodeId>,
225        provenance: impl Into<String>,
226    ) -> Self {
227        SeedNode {
228            kind,
229            materializer_id: kind as u16,
230            materializer_version: MATERIALIZER_VERSION,
231            logical_output_len,
232            params,
233            deps,
234            provenance: provenance.into(),
235            limits: NodeLimits::DEFAULT,
236        }
237    }
238
239    /// Canonically encode this node. The encoding never includes the node id.
240    pub fn encode_canonical(&self) -> Vec<u8> {
241        let mut out = Vec::with_capacity(64 + self.params.len());
242        out.push(NODE_MAGIC);
243        out.push(crate::store::SEED_FORMAT_VERSION);
244        out.push(self.kind as u8);
245        out.push(0); // reserved
246        out.extend_from_slice(&self.materializer_id.to_le_bytes());
247        out.extend_from_slice(&self.materializer_version.to_le_bytes());
248        out.extend_from_slice(&self.logical_output_len.to_le_bytes());
249        out.extend_from_slice(&(self.deps.len() as u32).to_le_bytes());
250        out.extend_from_slice(&(self.params.len() as u32).to_le_bytes());
251        out.extend_from_slice(&self.limits.max_output_bytes.to_le_bytes());
252        out.extend_from_slice(&self.limits.max_depth.to_le_bytes());
253        out.extend_from_slice(&self.limits.max_fanout.to_le_bytes());
254        for dep in &self.deps {
255            out.extend_from_slice(dep.as_bytes());
256        }
257        out.extend_from_slice(&self.params);
258        let prov = self.provenance.as_bytes();
259        out.extend_from_slice(&(prov.len() as u32).to_le_bytes());
260        out.extend_from_slice(prov);
261        out
262    }
263
264    /// Parse a canonical node, enforcing structural bounds.
265    pub fn decode_canonical(bytes: &[u8]) -> Result<SeedNode> {
266        let mut r = Reader::new(bytes);
267        if r.u8()? != NODE_MAGIC {
268            return Err(Error::unsupported_version("seed node: bad magic"));
269        }
270        let version = r.u8()?;
271        if version != crate::store::SEED_FORMAT_VERSION {
272            return Err(Error::unsupported_version(format!(
273                "seed node format version {version} is not supported"
274            )));
275        }
276        let kind_byte = r.u8()?;
277        let kind = NodeKind::from_u8(kind_byte)
278            .ok_or_else(|| Error::unsupported_version(format!("unknown node kind {kind_byte}")))?;
279        let _reserved = r.u8()?;
280        let materializer_id = r.u16()?;
281        let materializer_version = r.u16()?;
282        let logical_output_len = r.u64()?;
283        let dep_count = r.u32()? as usize;
284        let param_len = r.u32()? as usize;
285        let max_output_bytes = r.u64()?;
286        let max_depth = r.u16()?;
287        let max_fanout = r.u16()?;
288        if dep_count > MAX_NODE_DEPS {
289            return Err(Error::resource_limit(format!(
290                "seed node declares {dep_count} deps (max {MAX_NODE_DEPS})"
291            )));
292        }
293        let mut deps = Vec::with_capacity(dep_count);
294        for _ in 0..dep_count {
295            let mut b = [0u8; 32];
296            b.copy_from_slice(r.bytes(32)?);
297            deps.push(NodeId::from_bytes(b));
298        }
299        let params = r.bytes(param_len)?.to_vec();
300        let prov_len = r.u32()? as usize;
301        let prov = r.bytes(prov_len)?;
302        let provenance = core::str::from_utf8(prov)
303            .map_err(|_| Error::usage("seed node provenance is not UTF-8"))?
304            .to_string();
305        if !r.at_end() {
306            return Err(Error::usage("seed node has trailing bytes"));
307        }
308        if materializer_id != kind as u16 {
309            return Err(Error::unsupported_version(
310                "seed node materializer id does not match its kind",
311            ));
312        }
313        Ok(SeedNode {
314            kind,
315            materializer_id,
316            materializer_version,
317            logical_output_len,
318            params,
319            deps,
320            provenance,
321            limits: NodeLimits {
322                max_output_bytes,
323                max_depth,
324                max_fanout,
325            },
326        })
327    }
328
329    /// The content id of this node's canonical encoding.
330    pub fn content_id(&self) -> NodeId {
331        NodeId::of_node(&self.encode_canonical())
332    }
333
334    /// Validate the declared envelope against the global [`Limits`].
335    pub fn check_limits(&self, limits: &Limits) -> Result<()> {
336        if self.logical_output_len > self.limits.max_output_bytes {
337            return Err(Error::resource_limit(format!(
338                "seed node declares output {} > its own cap {}",
339                self.logical_output_len, self.limits.max_output_bytes
340            )));
341        }
342        let _ = limits;
343        Ok(())
344    }
345}
346
347/// A tiny bounds-checked little-endian reader.
348struct Reader<'a> {
349    b: &'a [u8],
350    at: usize,
351}
352
353impl<'a> Reader<'a> {
354    fn new(b: &'a [u8]) -> Self {
355        Reader { b, at: 0 }
356    }
357    fn bytes(&mut self, n: usize) -> Result<&'a [u8]> {
358        let end = self
359            .at
360            .checked_add(n)
361            .ok_or_else(|| Error::usage("seed node read overflow"))?;
362        if end > self.b.len() {
363            return Err(Error::usage("truncated seed node"));
364        }
365        let s = &self.b[self.at..end];
366        self.at = end;
367        Ok(s)
368    }
369    fn u8(&mut self) -> Result<u8> {
370        Ok(self.bytes(1)?[0])
371    }
372    fn u16(&mut self) -> Result<u16> {
373        let b = self.bytes(2)?;
374        Ok(u16::from_le_bytes([b[0], b[1]]))
375    }
376    fn u32(&mut self) -> Result<u32> {
377        let b = self.bytes(4)?;
378        Ok(u32::from_le_bytes([b[0], b[1], b[2], b[3]]))
379    }
380    fn u64(&mut self) -> Result<u64> {
381        let b = self.bytes(8)?;
382        let mut a = [0u8; 8];
383        a.copy_from_slice(b);
384        Ok(u64::from_le_bytes(a))
385    }
386    fn at_end(&self) -> bool {
387        self.at == self.b.len()
388    }
389}
390
391/// Encode an `[offset, len]` parameter block.
392pub fn span_params(offset: u64, len: u64) -> Vec<u8> {
393    let mut p = Vec::with_capacity(16);
394    p.extend_from_slice(&offset.to_le_bytes());
395    p.extend_from_slice(&len.to_le_bytes());
396    p
397}
398
399/// Decode an `[offset, len]` parameter block.
400pub fn read_span_params(params: &[u8]) -> Result<(u64, u64)> {
401    if params.len() != 16 {
402        return Err(Error::usage("span params must be 16 bytes"));
403    }
404    let mut a = [0u8; 8];
405    a.copy_from_slice(&params[0..8]);
406    let offset = u64::from_le_bytes(a);
407    a.copy_from_slice(&params[8..16]);
408    let len = u64::from_le_bytes(a);
409    Ok((offset, len))
410}
411
412/// Encode a `(u32, u16, ...)` object-identity parameter block: `object`,
413/// `generation`, then a trailing kind-specific `u64`.
414pub fn object_params(object: u32, generation: u16, extra: u64) -> Vec<u8> {
415    let mut p = Vec::with_capacity(16);
416    p.extend_from_slice(&object.to_le_bytes());
417    p.extend_from_slice(&generation.to_le_bytes());
418    p.extend_from_slice(&0u16.to_le_bytes());
419    p.extend_from_slice(&extra.to_le_bytes());
420    p
421}
422
423/// Decode an object-identity parameter block.
424pub fn read_object_params(params: &[u8]) -> Result<(u32, u16, u64)> {
425    if params.len() != 16 {
426        return Err(Error::usage("object params must be 16 bytes"));
427    }
428    let object = u32::from_le_bytes([params[0], params[1], params[2], params[3]]);
429    let generation = u16::from_le_bytes([params[4], params[5]]);
430    let mut a = [0u8; 8];
431    a.copy_from_slice(&params[8..16]);
432    let extra = u64::from_le_bytes(a);
433    Ok((object, generation, extra))
434}
435
436/// Encode a single `u32` parameter.
437pub fn u32_params(v: u32) -> Vec<u8> {
438    v.to_le_bytes().to_vec()
439}
440
441/// Decode a single `u32` parameter.
442pub fn read_u32_params(params: &[u8]) -> Result<u32> {
443    if params.len() != 4 {
444        return Err(Error::usage("u32 params must be 4 bytes"));
445    }
446    Ok(u32::from_le_bytes([
447        params[0], params[1], params[2], params[3],
448    ]))
449}
450
451#[cfg(test)]
452mod tests {
453    use super::*;
454
455    #[test]
456    fn canonical_roundtrip_is_stable() {
457        let n = SeedNode::new(
458            NodeKind::PdfStreamDecoded,
459            4096,
460            span_params(100, 200),
461            vec![NodeId::from_bytes([7u8; 32])],
462            "pdf:stream-decoded",
463        );
464        let enc = n.encode_canonical();
465        let back = SeedNode::decode_canonical(&enc).unwrap();
466        assert_eq!(n, back);
467        assert_eq!(back.content_id(), n.content_id());
468        assert_eq!(n.encode_canonical(), enc);
469    }
470
471    #[test]
472    fn dependency_change_changes_id() {
473        let a = SeedNode::new(
474            NodeKind::Concat,
475            10,
476            vec![],
477            vec![NodeId::from_bytes([1u8; 32])],
478            "t",
479        );
480        let b = SeedNode::new(
481            NodeKind::Concat,
482            10,
483            vec![],
484            vec![NodeId::from_bytes([2u8; 32])],
485            "t",
486        );
487        assert_ne!(a.content_id(), b.content_id());
488    }
489
490    #[test]
491    fn unknown_kind_and_version_fail_closed() {
492        let mut n = SeedNode::new(NodeKind::Literal, 1, vec![9], vec![], "t").encode_canonical();
493        n[0] = 0x00;
494        assert!(SeedNode::decode_canonical(&n).is_err());
495        let mut n2 = SeedNode::new(NodeKind::Literal, 1, vec![9], vec![], "t").encode_canonical();
496        n2[1] = 0x7F;
497        assert!(SeedNode::decode_canonical(&n2).is_err());
498    }
499
500    #[test]
501    fn trailing_bytes_are_rejected() {
502        let mut enc = SeedNode::new(NodeKind::Literal, 1, vec![], vec![], "t").encode_canonical();
503        enc.push(0);
504        assert!(SeedNode::decode_canonical(&enc).is_err());
505    }
506
507    #[test]
508    fn param_helpers_roundtrip() {
509        let (o, l) = read_span_params(&span_params(5, 9)).unwrap();
510        assert_eq!((o, l), (5, 9));
511        let (ob, g, e) = read_object_params(&object_params(42, 3, 77)).unwrap();
512        assert_eq!((ob, g, e), (42, 3, 77));
513        assert_eq!(read_u32_params(&u32_params(1234)).unwrap(), 1234);
514    }
515}