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    /// The canonical ODT (ODF) discovery model: `mimetype` conformance facts and the
93    /// parsed `META-INF/manifest.xml` file entries with the main content part resolved
94    /// semantically, derived on demand from the exact package source. Derived, never
95    /// exact (Phase 13.3).
96    OdtModel = 0x17,
97    /// The OpenDocument main part (`content.xml`, `office:text`) parsed into its
98    /// bounded native content model (paragraphs/headings/spans/lists/tables/links/
99    /// bookmarks/notes/resources/tracked changes/sections), honoring a declared
100    /// extraction profile. Derived, never exact (Phase 13.3).
101    OdtContent = 0x18,
102}
103
104impl NodeKind {
105    /// Map a raw kind byte.
106    pub const fn from_u8(b: u8) -> Option<NodeKind> {
107        Some(match b {
108            0x01 => NodeKind::DocumentExact,
109            0x02 => NodeKind::SourceSlice,
110            0x03 => NodeKind::PdfRevision,
111            0x04 => NodeKind::PdfObject,
112            0x05 => NodeKind::PdfStreamEncoded,
113            0x06 => NodeKind::PdfStreamDecoded,
114            0x07 => NodeKind::ContentOperators,
115            0x08 => NodeKind::TextRuns,
116            0x09 => NodeKind::PageContent,
117            0x0A => NodeKind::PagePreview,
118            0x0B => NodeKind::ResourceRef,
119            0x0C => NodeKind::Concat,
120            0x0D => NodeKind::Literal,
121            0x0E => NodeKind::PackageRoot,
122            0x0F => NodeKind::PackageMemberRaw,
123            0x10 => NodeKind::PackageMemberDecoded,
124            0x11 => NodeKind::PackageOpcModel,
125            0x12 => NodeKind::DocxModel,
126            0x13 => NodeKind::DocxStory,
127            0x14 => NodeKind::EpubModel,
128            0x15 => NodeKind::EpubContent,
129            0x16 => NodeKind::ResourceBlob,
130            0x17 => NodeKind::OdtModel,
131            0x18 => NodeKind::OdtContent,
132            _ => return None,
133        })
134    }
135
136    /// Stable short name.
137    pub const fn name(self) -> &'static str {
138        match self {
139            NodeKind::DocumentExact => "DocumentExact",
140            NodeKind::SourceSlice => "SourceSlice",
141            NodeKind::PdfRevision => "PdfRevision",
142            NodeKind::PdfObject => "PdfObject",
143            NodeKind::PdfStreamEncoded => "PdfStreamEncoded",
144            NodeKind::PdfStreamDecoded => "PdfStreamDecoded",
145            NodeKind::ContentOperators => "ContentOperators",
146            NodeKind::TextRuns => "TextRuns",
147            NodeKind::PageContent => "PageContent",
148            NodeKind::PagePreview => "PagePreview",
149            NodeKind::ResourceRef => "ResourceRef",
150            NodeKind::Concat => "Concat",
151            NodeKind::Literal => "Literal",
152            NodeKind::PackageRoot => "PackageRoot",
153            NodeKind::PackageMemberRaw => "PackageMemberRaw",
154            NodeKind::PackageMemberDecoded => "PackageMemberDecoded",
155            NodeKind::PackageOpcModel => "PackageOpcModel",
156            NodeKind::DocxModel => "DocxModel",
157            NodeKind::DocxStory => "DocxStory",
158            NodeKind::EpubModel => "EpubModel",
159            NodeKind::EpubContent => "EpubContent",
160            NodeKind::ResourceBlob => "ResourceBlob",
161            NodeKind::OdtModel => "OdtModel",
162            NodeKind::OdtContent => "OdtContent",
163        }
164    }
165
166    /// Whether the output bytes are an exact, byte-identical observation of the
167    /// source (`true`) or a deterministic derived projection (`false`).
168    ///
169    /// This is the `Q_ref` / `Q_gen` boundary (ADR-0026).
170    pub const fn is_exact(self) -> bool {
171        matches!(
172            self,
173            NodeKind::DocumentExact
174                | NodeKind::SourceSlice
175                | NodeKind::PdfRevision
176                | NodeKind::PdfObject
177                | NodeKind::PdfStreamEncoded
178                | NodeKind::ResourceRef
179                | NodeKind::Concat
180                | NodeKind::Literal
181                // Package physical leaves are exact source spans; a decoded
182                // member (`PackageMemberDecoded`) is derived and is **not** exact.
183                | NodeKind::PackageRoot
184                | NodeKind::PackageMemberRaw
185                // A shared resource is the exact embedded bytes.
186                | NodeKind::ResourceBlob
187        )
188    }
189}
190
191/// A bounded resource envelope declared by a node.
192#[derive(Debug, Clone, Copy, PartialEq, Eq)]
193pub struct NodeLimits {
194    /// Maximum materialized output length this node may produce.
195    pub max_output_bytes: u64,
196    /// Maximum dependency depth permitted below this node.
197    pub max_depth: u16,
198    /// Maximum dependency fanout permitted.
199    pub max_fanout: u16,
200}
201
202impl NodeLimits {
203    /// Conservative defaults used by the PDF inverse compiler.
204    pub const DEFAULT: NodeLimits = NodeLimits {
205        max_output_bytes: 1 << 31,
206        max_depth: 64,
207        max_fanout: MAX_NODE_DEPS as u16,
208    };
209}
210
211/// One canonical procedural seed node.
212#[derive(Debug, Clone, PartialEq, Eq)]
213pub struct SeedNode {
214    /// Computation kind.
215    pub kind: NodeKind,
216    /// Materializer registry id (the kind value for v1).
217    pub materializer_id: u16,
218    /// Materializer semantics version.
219    pub materializer_version: u16,
220    /// Declared logical output length (a bound, checked on materialization).
221    pub logical_output_len: u64,
222    /// Kind-specific canonical parameters.
223    pub params: Vec<u8>,
224    /// Canonical dependency ids actually read (the dynamic read set).
225    pub deps: Vec<NodeId>,
226    /// Short provenance/basis string (adapter-supplied, advisory).
227    pub provenance: String,
228    /// Resource envelope.
229    pub limits: NodeLimits,
230}
231
232impl SeedNode {
233    /// Construct a node with the current materializer version.
234    pub fn new(
235        kind: NodeKind,
236        logical_output_len: u64,
237        params: Vec<u8>,
238        deps: Vec<NodeId>,
239        provenance: impl Into<String>,
240    ) -> Self {
241        SeedNode {
242            kind,
243            materializer_id: kind as u16,
244            materializer_version: MATERIALIZER_VERSION,
245            logical_output_len,
246            params,
247            deps,
248            provenance: provenance.into(),
249            limits: NodeLimits::DEFAULT,
250        }
251    }
252
253    /// Canonically encode this node. The encoding never includes the node id.
254    pub fn encode_canonical(&self) -> Vec<u8> {
255        let mut out = Vec::with_capacity(64 + self.params.len());
256        out.push(NODE_MAGIC);
257        out.push(crate::store::SEED_FORMAT_VERSION);
258        out.push(self.kind as u8);
259        out.push(0); // reserved
260        out.extend_from_slice(&self.materializer_id.to_le_bytes());
261        out.extend_from_slice(&self.materializer_version.to_le_bytes());
262        out.extend_from_slice(&self.logical_output_len.to_le_bytes());
263        out.extend_from_slice(&(self.deps.len() as u32).to_le_bytes());
264        out.extend_from_slice(&(self.params.len() as u32).to_le_bytes());
265        out.extend_from_slice(&self.limits.max_output_bytes.to_le_bytes());
266        out.extend_from_slice(&self.limits.max_depth.to_le_bytes());
267        out.extend_from_slice(&self.limits.max_fanout.to_le_bytes());
268        for dep in &self.deps {
269            out.extend_from_slice(dep.as_bytes());
270        }
271        out.extend_from_slice(&self.params);
272        let prov = self.provenance.as_bytes();
273        out.extend_from_slice(&(prov.len() as u32).to_le_bytes());
274        out.extend_from_slice(prov);
275        out
276    }
277
278    /// Parse a canonical node, enforcing structural bounds.
279    pub fn decode_canonical(bytes: &[u8]) -> Result<SeedNode> {
280        let mut r = Reader::new(bytes);
281        if r.u8()? != NODE_MAGIC {
282            return Err(Error::unsupported_version("seed node: bad magic"));
283        }
284        let version = r.u8()?;
285        if version != crate::store::SEED_FORMAT_VERSION {
286            return Err(Error::unsupported_version(format!(
287                "seed node format version {version} is not supported"
288            )));
289        }
290        let kind_byte = r.u8()?;
291        let kind = NodeKind::from_u8(kind_byte)
292            .ok_or_else(|| Error::unsupported_version(format!("unknown node kind {kind_byte}")))?;
293        let _reserved = r.u8()?;
294        let materializer_id = r.u16()?;
295        let materializer_version = r.u16()?;
296        let logical_output_len = r.u64()?;
297        let dep_count = r.u32()? as usize;
298        let param_len = r.u32()? as usize;
299        let max_output_bytes = r.u64()?;
300        let max_depth = r.u16()?;
301        let max_fanout = r.u16()?;
302        if dep_count > MAX_NODE_DEPS {
303            return Err(Error::resource_limit(format!(
304                "seed node declares {dep_count} deps (max {MAX_NODE_DEPS})"
305            )));
306        }
307        let mut deps = Vec::with_capacity(dep_count);
308        for _ in 0..dep_count {
309            let mut b = [0u8; 32];
310            b.copy_from_slice(r.bytes(32)?);
311            deps.push(NodeId::from_bytes(b));
312        }
313        let params = r.bytes(param_len)?.to_vec();
314        let prov_len = r.u32()? as usize;
315        let prov = r.bytes(prov_len)?;
316        let provenance = core::str::from_utf8(prov)
317            .map_err(|_| Error::usage("seed node provenance is not UTF-8"))?
318            .to_string();
319        if !r.at_end() {
320            return Err(Error::usage("seed node has trailing bytes"));
321        }
322        if materializer_id != kind as u16 {
323            return Err(Error::unsupported_version(
324                "seed node materializer id does not match its kind",
325            ));
326        }
327        Ok(SeedNode {
328            kind,
329            materializer_id,
330            materializer_version,
331            logical_output_len,
332            params,
333            deps,
334            provenance,
335            limits: NodeLimits {
336                max_output_bytes,
337                max_depth,
338                max_fanout,
339            },
340        })
341    }
342
343    /// The content id of this node's canonical encoding.
344    pub fn content_id(&self) -> NodeId {
345        NodeId::of_node(&self.encode_canonical())
346    }
347
348    /// Validate the declared envelope against the global [`Limits`].
349    pub fn check_limits(&self, limits: &Limits) -> Result<()> {
350        if self.logical_output_len > self.limits.max_output_bytes {
351            return Err(Error::resource_limit(format!(
352                "seed node declares output {} > its own cap {}",
353                self.logical_output_len, self.limits.max_output_bytes
354            )));
355        }
356        let _ = limits;
357        Ok(())
358    }
359}
360
361/// A tiny bounds-checked little-endian reader.
362struct Reader<'a> {
363    b: &'a [u8],
364    at: usize,
365}
366
367impl<'a> Reader<'a> {
368    fn new(b: &'a [u8]) -> Self {
369        Reader { b, at: 0 }
370    }
371    fn bytes(&mut self, n: usize) -> Result<&'a [u8]> {
372        let end = self
373            .at
374            .checked_add(n)
375            .ok_or_else(|| Error::usage("seed node read overflow"))?;
376        if end > self.b.len() {
377            return Err(Error::usage("truncated seed node"));
378        }
379        let s = &self.b[self.at..end];
380        self.at = end;
381        Ok(s)
382    }
383    fn u8(&mut self) -> Result<u8> {
384        Ok(self.bytes(1)?[0])
385    }
386    fn u16(&mut self) -> Result<u16> {
387        let b = self.bytes(2)?;
388        Ok(u16::from_le_bytes([b[0], b[1]]))
389    }
390    fn u32(&mut self) -> Result<u32> {
391        let b = self.bytes(4)?;
392        Ok(u32::from_le_bytes([b[0], b[1], b[2], b[3]]))
393    }
394    fn u64(&mut self) -> Result<u64> {
395        let b = self.bytes(8)?;
396        let mut a = [0u8; 8];
397        a.copy_from_slice(b);
398        Ok(u64::from_le_bytes(a))
399    }
400    fn at_end(&self) -> bool {
401        self.at == self.b.len()
402    }
403}
404
405/// Encode an `[offset, len]` parameter block.
406pub fn span_params(offset: u64, len: u64) -> Vec<u8> {
407    let mut p = Vec::with_capacity(16);
408    p.extend_from_slice(&offset.to_le_bytes());
409    p.extend_from_slice(&len.to_le_bytes());
410    p
411}
412
413/// Decode an `[offset, len]` parameter block.
414pub fn read_span_params(params: &[u8]) -> Result<(u64, u64)> {
415    if params.len() != 16 {
416        return Err(Error::usage("span params must be 16 bytes"));
417    }
418    let mut a = [0u8; 8];
419    a.copy_from_slice(&params[0..8]);
420    let offset = u64::from_le_bytes(a);
421    a.copy_from_slice(&params[8..16]);
422    let len = u64::from_le_bytes(a);
423    Ok((offset, len))
424}
425
426/// Encode a `(u32, u16, ...)` object-identity parameter block: `object`,
427/// `generation`, then a trailing kind-specific `u64`.
428pub fn object_params(object: u32, generation: u16, extra: u64) -> Vec<u8> {
429    let mut p = Vec::with_capacity(16);
430    p.extend_from_slice(&object.to_le_bytes());
431    p.extend_from_slice(&generation.to_le_bytes());
432    p.extend_from_slice(&0u16.to_le_bytes());
433    p.extend_from_slice(&extra.to_le_bytes());
434    p
435}
436
437/// Decode an object-identity parameter block.
438pub fn read_object_params(params: &[u8]) -> Result<(u32, u16, u64)> {
439    if params.len() != 16 {
440        return Err(Error::usage("object params must be 16 bytes"));
441    }
442    let object = u32::from_le_bytes([params[0], params[1], params[2], params[3]]);
443    let generation = u16::from_le_bytes([params[4], params[5]]);
444    let mut a = [0u8; 8];
445    a.copy_from_slice(&params[8..16]);
446    let extra = u64::from_le_bytes(a);
447    Ok((object, generation, extra))
448}
449
450/// Encode a single `u32` parameter.
451pub fn u32_params(v: u32) -> Vec<u8> {
452    v.to_le_bytes().to_vec()
453}
454
455/// Decode a single `u32` parameter.
456pub fn read_u32_params(params: &[u8]) -> Result<u32> {
457    if params.len() != 4 {
458        return Err(Error::usage("u32 params must be 4 bytes"));
459    }
460    Ok(u32::from_le_bytes([
461        params[0], params[1], params[2], params[3],
462    ]))
463}
464
465#[cfg(test)]
466mod tests {
467    use super::*;
468
469    #[test]
470    fn canonical_roundtrip_is_stable() {
471        let n = SeedNode::new(
472            NodeKind::PdfStreamDecoded,
473            4096,
474            span_params(100, 200),
475            vec![NodeId::from_bytes([7u8; 32])],
476            "pdf:stream-decoded",
477        );
478        let enc = n.encode_canonical();
479        let back = SeedNode::decode_canonical(&enc).unwrap();
480        assert_eq!(n, back);
481        assert_eq!(back.content_id(), n.content_id());
482        assert_eq!(n.encode_canonical(), enc);
483    }
484
485    #[test]
486    fn dependency_change_changes_id() {
487        let a = SeedNode::new(
488            NodeKind::Concat,
489            10,
490            vec![],
491            vec![NodeId::from_bytes([1u8; 32])],
492            "t",
493        );
494        let b = SeedNode::new(
495            NodeKind::Concat,
496            10,
497            vec![],
498            vec![NodeId::from_bytes([2u8; 32])],
499            "t",
500        );
501        assert_ne!(a.content_id(), b.content_id());
502    }
503
504    #[test]
505    fn unknown_kind_and_version_fail_closed() {
506        let mut n = SeedNode::new(NodeKind::Literal, 1, vec![9], vec![], "t").encode_canonical();
507        n[0] = 0x00;
508        assert!(SeedNode::decode_canonical(&n).is_err());
509        let mut n2 = SeedNode::new(NodeKind::Literal, 1, vec![9], vec![], "t").encode_canonical();
510        n2[1] = 0x7F;
511        assert!(SeedNode::decode_canonical(&n2).is_err());
512    }
513
514    #[test]
515    fn trailing_bytes_are_rejected() {
516        let mut enc = SeedNode::new(NodeKind::Literal, 1, vec![], vec![], "t").encode_canonical();
517        enc.push(0);
518        assert!(SeedNode::decode_canonical(&enc).is_err());
519    }
520
521    #[test]
522    fn param_helpers_roundtrip() {
523        let (o, l) = read_span_params(&span_params(5, 9)).unwrap();
524        assert_eq!((o, l), (5, 9));
525        let (ob, g, e) = read_object_params(&object_params(42, 3, 77)).unwrap();
526        assert_eq!((ob, g, e), (42, 3, 77));
527        assert_eq!(read_u32_params(&u32_params(1234)).unwrap(), 1234);
528    }
529}