Skip to main content

vole_document/field/
dag.rs

1//! The procedural seed DAG: bounded closure traversal and materialization.
2//!
3//! Every node names a **bounded, versioned materializer**; there is no arbitrary
4//! execution (ADR-0025). A materializer is a pure function of the field's exact
5//! descriptor, the seed store, and the node's dependency outputs. Unknown kinds
6//! or materializer versions fail closed.
7//!
8//! Exact node kinds (`Q_ref`) resolve to byte-identical spans of the source via
9//! the *existing* partial-materialization machinery, so a narrow observation does
10//! not force a whole-document reconstruction. Derived node kinds (`Q_gen`, e.g.
11//! decoded streams, text runs, previews) are deterministic projections and are
12//! always labelled as such.
13
14use crate::container::{ObjectSource, ParsedDescriptor};
15use crate::error::{Error, Result};
16use crate::limits::Limits;
17use crate::materialize::observation::{select_ops, selection_references, serve_selection};
18use crate::store::{NodeId, SeedStore};
19
20use super::derive;
21use super::node::{NodeKind, SeedNode, read_object_params, read_span_params, read_u32_params};
22
23/// Supplies exact source byte ranges to a seed-DAG materialization.
24///
25/// The full implementation is a parsed descriptor
26/// ([`impl SourceServer for ParsedDescriptor`]); a partial loader that reads only
27/// the records a query needs is the other. Every materializer that resolves an
28/// exact `Q_ref` node goes through this one method, so the DAG logic cannot
29/// diverge between the complete and partial sources.
30pub trait SourceServer {
31    /// Bytes of the source range `[offset, offset + len)`.
32    fn serve_range(&self, offset: u64, len: u64, limits: Limits) -> Result<Vec<u8>>;
33    /// The whole reconstructed source (only a `DocumentExact` node needs this).
34    fn serve_document(&self, limits: Limits) -> Result<Vec<u8>>;
35}
36
37impl SourceServer for ParsedDescriptor {
38    fn serve_range(&self, offset: u64, len: u64, limits: Limits) -> Result<Vec<u8>> {
39        serve_source_range(self, offset, len, limits)
40    }
41
42    fn serve_document(&self, limits: Limits) -> Result<Vec<u8>> {
43        crate::materialize::materialize(self, limits)
44    }
45}
46
47/// Hard cap on the number of nodes one materialization may evaluate.
48pub const MAX_EVAL_NODES: u64 = 1 << 20;
49/// Hard cap on total intermediate+output bytes one materialization may produce.
50pub const MAX_EVAL_BYTES: u64 = 1 << 32;
51
52/// A bounded evaluation budget, shared across a recursive materialization.
53#[derive(Debug, Clone)]
54pub struct EvalBudget {
55    /// Maximum nodes evaluated.
56    pub max_nodes: u64,
57    /// Maximum total bytes produced (intermediate + final).
58    pub max_bytes: u64,
59    /// Nodes evaluated so far.
60    pub nodes: u64,
61    /// Bytes produced so far.
62    pub produced: u64,
63}
64
65impl Default for EvalBudget {
66    fn default() -> Self {
67        EvalBudget {
68            max_nodes: MAX_EVAL_NODES,
69            max_bytes: MAX_EVAL_BYTES,
70            nodes: 0,
71            produced: 0,
72        }
73    }
74}
75
76impl EvalBudget {
77    fn charge_node(&mut self) -> Result<()> {
78        self.nodes = self
79            .nodes
80            .checked_add(1)
81            .ok_or_else(|| Error::resource_limit("seed evaluation node count overflow"))?;
82        if self.nodes > self.max_nodes {
83            return Err(Error::resource_limit(format!(
84                "seed evaluation exceeded {} nodes",
85                self.max_nodes
86            )));
87        }
88        Ok(())
89    }
90
91    pub(crate) fn charge_bytes(&mut self, n: u64) -> Result<()> {
92        self.produced = self
93            .produced
94            .checked_add(n)
95            .ok_or_else(|| Error::resource_limit("seed evaluation byte count overflow"))?;
96        if self.produced > self.max_bytes {
97            return Err(Error::resource_limit(format!(
98                "seed evaluation exceeded {} bytes",
99                self.max_bytes
100            )));
101        }
102        Ok(())
103    }
104}
105
106/// Load and decode one node from the store by id, verifying content identity.
107pub fn load_node(store: &dyn SeedStore, id: &NodeId) -> Result<SeedNode> {
108    let bytes = store.get_node(id)?;
109    let node = SeedNode::decode_canonical(&bytes)?;
110    if node.content_id() != *id {
111        return Err(Error::integrity_mismatch(format!(
112            "seed node {id} decoded to a different content id"
113        )));
114    }
115    node.check_limits(&Limits::DEFAULT)?;
116    Ok(node)
117}
118
119/// The dependency ids of a node's already-decoded canonical bytes.
120pub fn deps_of_canonical(bytes: &[u8]) -> Result<Vec<NodeId>> {
121    Ok(SeedNode::decode_canonical(bytes)?.deps)
122}
123
124/// Serve an exact source byte range `[offset, offset+len)` using the descriptor's
125/// program via the shared partial-materialization path. Does **not** materialize
126/// the whole document.
127fn serve_source_range(
128    parsed: &ParsedDescriptor,
129    offset: u64,
130    len: u64,
131    limits: Limits,
132) -> Result<Vec<u8>> {
133    let d = &parsed.descriptor;
134    if d.objects
135        .iter()
136        .any(|o| matches!(o, ObjectSource::External { .. }))
137    {
138        return Err(Error::unsupported_feature(
139            "field v1 requires an inline descriptor (no external objects)",
140        ));
141    }
142    let end = offset
143        .checked_add(len)
144        .ok_or_else(|| Error::usage("source slice end overflows"))?;
145    if end > d.source_len {
146        return Err(Error::usage(format!(
147            "source slice {offset}..{end} exceeds source length {}",
148            d.source_len
149        )));
150    }
151    let objects: Vec<Vec<u8>> = d
152        .objects
153        .iter()
154        .map(|o| o.as_inline().unwrap_or(&[]).to_vec())
155        .collect();
156    let object_lens: Vec<u64> = objects.iter().map(|o| o.len() as u64).collect();
157    let channel_lens: Vec<u64> = d.channels.iter().map(|c| c.decoded_length).collect();
158    let window = select_ops(&d.program, &object_lens, &channel_lens, offset, end, limits)?;
159    let (objects_used, channels_used) =
160        selection_references(&window.ops, objects.len(), d.channels.len());
161    let served = serve_selection(
162        &objects,
163        &d.channels,
164        &d.models,
165        window,
166        &objects_used,
167        &channels_used,
168        offset,
169        end,
170        limits,
171    )?;
172    Ok(served.bytes)
173}
174
175/// A node-output cache keyed by [`NodeId`]. Because a node's id binds its full
176/// dependency closure, an unchanged closure hits and a changed dependency misses;
177/// there is no invalidation pass (ADR-0025).
178///
179/// Implementations are disposable: [`materialize_node_cached`] treats any `get`
180/// error as a miss and never trusts bytes it cannot validate, so a corrupt cache
181/// causes recomputation rather than wrong output.
182pub trait OutputCache {
183    /// Fetch a cached node output, or `None` on a miss. A `get` error is treated
184    /// as a miss by the caller (the cache is disposable, never authority).
185    fn get(&self, id: &NodeId) -> Result<Option<Vec<u8>>>;
186    /// Store a node output. Best-effort: a `put` error does not fail the
187    /// materialization.
188    fn put(&mut self, id: &NodeId, bytes: &[u8]) -> Result<()>;
189}
190
191/// A cache that stores nothing; used by the backward-compatible
192/// [`materialize_node`] wrapper and the `use_cache = false` cold court.
193#[derive(Debug, Clone, Copy, Default)]
194pub struct NoCache;
195
196impl OutputCache for NoCache {
197    fn get(&self, _id: &NodeId) -> Result<Option<Vec<u8>>> {
198        Ok(None)
199    }
200
201    fn put(&mut self, _id: &NodeId, _bytes: &[u8]) -> Result<()> {
202        Ok(())
203    }
204}
205
206/// Execution accounting for one materialization (ADR-0027). Reuse is claimed by
207/// an *execution counter*, never by wall-clock: `nodes_reused > 0` and a smaller
208/// `nodes_executed` are the evidence that persisted work was served from disk.
209#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
210pub struct ReuseStats {
211    /// Nodes actually evaluated (cache misses).
212    pub nodes_executed: u64,
213    /// Nodes served whole from the cache (their subtrees were not traversed).
214    pub nodes_reused: u64,
215    /// Output bytes written to the cache during this materialization.
216    pub cache_bytes_written: u64,
217}
218
219/// The inverse work of one reconstruction, in abstract integer units (ADR-0034,
220/// plan §91). A unit is one node execution **or** one cold input byte the
221/// reconstruction had to read; it is never derived from the source size.
222///
223/// For a *cold* run (`use_cache = false`, or a cleared cache) `node_executions`
224/// is the run's `nodes_executed`; for a warm run the numerator comes from the
225/// difference the persisted store made.
226#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
227pub struct InverseWork {
228    /// Node executions the run performed.
229    pub node_executions: u64,
230    /// Cold input bytes the run read (descriptor + manifest + index + seed).
231    pub input_bytes: u64,
232}
233
234impl InverseWork {
235    /// Assemble a receipt from the two independently measured integers.
236    pub const fn new(node_executions: u64, input_bytes: u64) -> Self {
237        InverseWork {
238            node_executions,
239            input_bytes,
240        }
241    }
242
243    /// Total work units: one per execution plus one per cold input byte.
244    pub const fn units(self) -> u64 {
245        self.node_executions.saturating_add(self.input_bytes)
246    }
247}
248
249/// `retained_inverse_work_fraction` (ADR-0034, plan §91):
250///
251/// ```text
252/// reused_persisted_inverse_work / total_inverse_work_required_by_cold_reconstruction
253/// ```
254///
255/// `cold` and `warm` are two receipts of the **same** query (cold = cache
256/// disabled or cleared; warm = the persisted store present). Work avoided is the
257/// drop in executions plus the drop in cold input bytes; the denominator is the
258/// cold run's total. Both are integer work units ([`InverseWork::units`]), so the
259/// fraction is derived from receipted integers, never from source size.
260///
261/// Returns `1.0` when the cold run required no work (an empty reconstruction),
262/// which is the honest limit rather than a fabricated ratio.
263pub fn retained_inverse_work_fraction(cold: InverseWork, warm: InverseWork) -> f64 {
264    let total = cold.units();
265    if total == 0 {
266        return 1.0;
267    }
268    let reused = cold
269        .node_executions
270        .saturating_sub(warm.node_executions)
271        .saturating_add(cold.input_bytes.saturating_sub(warm.input_bytes));
272    reused as f64 / total as f64
273}
274
275/// Materialize one node's output bytes, recursively resolving dependencies.
276pub fn materialize_node(
277    parsed: &ParsedDescriptor,
278    store: &dyn SeedStore,
279    node: &SeedNode,
280    limits: Limits,
281    budget: &mut EvalBudget,
282    depth: u16,
283) -> Result<Vec<u8>> {
284    let mut cache = NoCache;
285    let mut reuse = ReuseStats::default();
286    materialize_inner(
287        parsed, store, &mut cache, node, limits, budget, depth, &mut reuse,
288    )
289}
290
291/// Materialize one node's output bytes against an explicit [`SourceServer`].
292///
293/// This is the partial-reader entry point: the caller supplies a source that can
294/// serve ranges without being handed a fully parsed descriptor.
295pub fn materialize_node_with(
296    source: &dyn SourceServer,
297    store: &dyn SeedStore,
298    node: &SeedNode,
299    limits: Limits,
300    budget: &mut EvalBudget,
301    depth: u16,
302) -> Result<Vec<u8>> {
303    let mut cache = NoCache;
304    let mut reuse = ReuseStats::default();
305    materialize_inner(
306        source, store, &mut cache, node, limits, budget, depth, &mut reuse,
307    )
308}
309
310/// Materialize one node's output, consulting `cache` at **every** node (including
311/// dependencies).
312///
313/// A hit returns the cached bytes *without recursing into the node's dependency
314/// closure* and increments [`ReuseStats::nodes_reused`]; a miss evaluates the node
315/// (recursing through the same cache) and stores the output. Exact `Q_ref` nodes
316/// are cached too, since their output is equally a pure function of their id.
317#[allow(clippy::too_many_arguments)]
318pub fn materialize_node_cached(
319    parsed: &ParsedDescriptor,
320    store: &dyn SeedStore,
321    cache: &mut dyn OutputCache,
322    node: &SeedNode,
323    limits: Limits,
324    budget: &mut EvalBudget,
325    depth: u16,
326    reuse: &mut ReuseStats,
327) -> Result<Vec<u8>> {
328    materialize_inner(parsed, store, cache, node, limits, budget, depth, reuse)
329}
330
331/// The [`SourceServer`] counterpart of [`materialize_node_cached`].
332#[allow(clippy::too_many_arguments)]
333pub fn materialize_node_cached_with(
334    source: &dyn SourceServer,
335    store: &dyn SeedStore,
336    cache: &mut dyn OutputCache,
337    node: &SeedNode,
338    limits: Limits,
339    budget: &mut EvalBudget,
340    depth: u16,
341    reuse: &mut ReuseStats,
342) -> Result<Vec<u8>> {
343    materialize_inner(source, store, cache, node, limits, budget, depth, reuse)
344}
345
346#[allow(clippy::too_many_arguments)]
347fn materialize_inner(
348    source: &dyn SourceServer,
349    store: &dyn SeedStore,
350    cache: &mut dyn OutputCache,
351    node: &SeedNode,
352    limits: Limits,
353    budget: &mut EvalBudget,
354    depth: u16,
355    reuse: &mut ReuseStats,
356) -> Result<Vec<u8>> {
357    if depth == 0 {
358        return Err(Error::resource_limit("seed DAG exceeded its depth bound"));
359    }
360
361    let id = node.content_id();
362    // A hit is the whole subtree: return it without traversing dependencies. A
363    // cache error is a miss (the cache is disposable, never authority). Oversized
364    // cached bytes are likewise treated as a poisoned miss, not returned.
365    if let Ok(Some(bytes)) = cache.get(&id)
366        && bytes.len() as u64 <= node.limits.max_output_bytes
367    {
368        reuse.nodes_reused = reuse.nodes_reused.saturating_add(1);
369        budget.charge_bytes(bytes.len() as u64)?;
370        return Ok(bytes);
371    }
372
373    budget.charge_node()?;
374    reuse.nodes_executed = reuse.nodes_executed.saturating_add(1);
375
376    let out = match node.kind {
377        NodeKind::DocumentExact => source.serve_document(limits)?,
378        NodeKind::SourceSlice | NodeKind::ResourceRef => {
379            let (offset, len) = read_span_params(&node.params)?;
380            source.serve_range(offset, len, limits)?
381        }
382        NodeKind::PdfRevision | NodeKind::PdfObject | NodeKind::PdfStreamEncoded => {
383            let (_number, _generation, extra) = read_object_params(&node.params)?;
384            // `extra` packs `offset` in the high 32 bits and `len` in the low 32.
385            let offset = extra >> 32;
386            let len = extra & 0xFFFF_FFFF;
387            source.serve_range(offset, len, limits)?
388        }
389        NodeKind::Concat | NodeKind::PageContent => {
390            let mut out = Vec::new();
391            for dep in &node.deps {
392                let child = load_node(store, dep)?;
393                let bytes = materialize_inner(
394                    source,
395                    store,
396                    cache,
397                    &child,
398                    limits,
399                    budget,
400                    depth - 1,
401                    reuse,
402                )?;
403                budget.charge_bytes(bytes.len() as u64)?;
404                out.extend_from_slice(&bytes);
405            }
406            out
407        }
408        NodeKind::Literal => node.params.clone(),
409        // A shared resource's canonical payload *is* its exact bytes; identity is
410        // content identity, so identical bytes across documents share this node.
411        NodeKind::ResourceBlob => node.params.clone(),
412        NodeKind::PackageRoot => source.serve_document(limits)?,
413        NodeKind::PackageMemberRaw => {
414            let (offset, len) = read_span_params(&node.params)?;
415            source.serve_range(offset, len, limits)?
416        }
417        NodeKind::PackageMemberDecoded => {
418            let (_ordinal, method, _extra) = read_object_params(&node.params)?;
419            let dep = node
420                .deps
421                .first()
422                .ok_or_else(|| Error::usage("PackageMemberDecoded has no dependency"))?;
423            let child = load_node(store, dep)?;
424            let encoded = materialize_inner(
425                source,
426                store,
427                cache,
428                &child,
429                limits,
430                budget,
431                depth - 1,
432                reuse,
433            )?;
434            match method {
435                // Stored (method 0): the raw span *is* the decoded bytes.
436                0 => {
437                    if encoded.len() as u64 != node.logical_output_len {
438                        return Err(Error::reconstruction_mismatch(format!(
439                            "stored member is {} bytes but the node declared {}",
440                            encoded.len(),
441                            node.logical_output_len
442                        )));
443                    }
444                    encoded
445                }
446                // Deflate (method 8): ZIP stores bare DEFLATE, not zlib-wrapped.
447                8 => derive::inflate_raw_deflate(&encoded, node.logical_output_len, limits)?,
448                // Any other method is a typed decline; the exact bytes are untouched.
449                other => {
450                    return Err(Error::unsupported_feature(format!(
451                        "zip member compression method {other} has no decoded representation"
452                    )));
453                }
454            }
455        }
456        NodeKind::PackageOpcModel => {
457            // The canonical OPC graph is derived on demand from the exact package
458            // source (the single dependency is the exact `PackageRoot`). XML parsing
459            // and all bounds live in `field::opc` / `adapter::package::opc`.
460            #[cfg(feature = "opc")]
461            {
462                let dep = node
463                    .deps
464                    .first()
465                    .ok_or_else(|| Error::usage("PackageOpcModel has no dependency"))?;
466                let child = load_node(store, dep)?;
467                let source = materialize_inner(
468                    source,
469                    store,
470                    cache,
471                    &child,
472                    limits,
473                    budget,
474                    depth - 1,
475                    reuse,
476                )?;
477                crate::field::opc::build_opc_model(&source, limits)?
478            }
479            #[cfg(not(feature = "opc"))]
480            {
481                return Err(Error::unsupported_feature(
482                    "OPC support is not compiled in (feature `opc`)",
483                ));
484            }
485        }
486        NodeKind::DocxModel => {
487            #[cfg(feature = "docx")]
488            {
489                let dep = node
490                    .deps
491                    .first()
492                    .ok_or_else(|| Error::usage("DocxModel has no dependency"))?;
493                let child = load_node(store, dep)?;
494                let opc_bytes = materialize_inner(
495                    source,
496                    store,
497                    cache,
498                    &child,
499                    limits,
500                    budget,
501                    depth - 1,
502                    reuse,
503                )?;
504                crate::adapter::docx::build_docx_model(&opc_bytes, limits)?
505            }
506            #[cfg(not(feature = "docx"))]
507            {
508                return Err(Error::unsupported_feature(
509                    "DOCX support is not compiled in (feature `docx`)",
510                ));
511            }
512        }
513        NodeKind::DocxStory => {
514            #[cfg(feature = "docx")]
515            {
516                let (story, part_name, profile) =
517                    crate::adapter::docx::read_story_params(&node.params)?;
518                let part_dep = node
519                    .deps
520                    .first()
521                    .ok_or_else(|| Error::usage("DocxStory has no part dependency"))?;
522                let part_node = load_node(store, part_dep)?;
523                let part_bytes = materialize_inner(
524                    source,
525                    store,
526                    cache,
527                    &part_node,
528                    limits,
529                    budget,
530                    depth - 1,
531                    reuse,
532                )?;
533                let styles = match node.deps.get(1) {
534                    Some(styles_dep) => {
535                        let styles_node = load_node(store, styles_dep)?;
536                        let styles_bytes = materialize_inner(
537                            source,
538                            store,
539                            cache,
540                            &styles_node,
541                            limits,
542                            budget,
543                            depth - 1,
544                            reuse,
545                        )?;
546                        Some(crate::adapter::docx::parse_styles(&styles_bytes, limits)?)
547                    }
548                    None => None,
549                };
550                crate::adapter::docx::wml::parse_story(
551                    &part_bytes,
552                    &part_name,
553                    story,
554                    &profile,
555                    styles.as_ref(),
556                    limits,
557                )?
558                .encode()
559            }
560            #[cfg(not(feature = "docx"))]
561            {
562                return Err(Error::unsupported_feature(
563                    "DOCX support is not compiled in (feature `docx`)",
564                ));
565            }
566        }
567        NodeKind::EpubModel => {
568            // The canonical EPUB (OCF) graph is derived on demand from the exact
569            // package source (the single dependency is the exact `PackageRoot`). It
570            // does not route through OPC. XML parsing and all bounds live in
571            // `adapter::epub`.
572            #[cfg(feature = "epub")]
573            {
574                let dep = node
575                    .deps
576                    .first()
577                    .ok_or_else(|| Error::usage("EpubModel has no dependency"))?;
578                let child = load_node(store, dep)?;
579                let source = materialize_inner(
580                    source,
581                    store,
582                    cache,
583                    &child,
584                    limits,
585                    budget,
586                    depth - 1,
587                    reuse,
588                )?;
589                crate::adapter::epub::build_epub_model(&source, limits)?
590            }
591            #[cfg(not(feature = "epub"))]
592            {
593                return Err(Error::unsupported_feature(
594                    "EPUB support is not compiled in (feature `epub`)",
595                ));
596            }
597        }
598        NodeKind::EpubContent => {
599            // One spine item's XHTML content document parsed into its bounded native
600            // model. Its single dependency is the decoded member node; the parse is
601            // bounded entirely inside `adapter::epub::content`. It never executes
602            // scripts and never fetches an external target.
603            #[cfg(feature = "epub")]
604            {
605                let (_spine, _ordinal, base_dir, _profile) =
606                    crate::adapter::epub::read_content_params(&node.params)?;
607                let dep = node
608                    .deps
609                    .first()
610                    .ok_or_else(|| Error::usage("EpubContent has no part dependency"))?;
611                let child = load_node(store, dep)?;
612                let bytes = materialize_inner(
613                    source,
614                    store,
615                    cache,
616                    &child,
617                    limits,
618                    budget,
619                    depth - 1,
620                    reuse,
621                )?;
622                crate::adapter::epub::parse_content(&bytes, &base_dir, limits)?.encode()
623            }
624            #[cfg(not(feature = "epub"))]
625            {
626                return Err(Error::unsupported_feature(
627                    "EPUB support is not compiled in (feature `epub`)",
628                ));
629            }
630        }
631        NodeKind::PdfStreamDecoded => {
632            let dep = node
633                .deps
634                .first()
635                .ok_or_else(|| Error::usage("PdfStreamDecoded has no dependency"))?;
636            let child = load_node(store, dep)?;
637            let encoded = materialize_inner(
638                source,
639                store,
640                cache,
641                &child,
642                limits,
643                budget,
644                depth - 1,
645                reuse,
646            )?;
647            derive::inflate_zlib(&encoded, node.logical_output_len, limits)?
648        }
649        NodeKind::ContentOperators => {
650            let dep = node
651                .deps
652                .first()
653                .ok_or_else(|| Error::usage("ContentOperators has no dependency"))?;
654            let child = load_node(store, dep)?;
655            let decoded = materialize_inner(
656                source,
657                store,
658                cache,
659                &child,
660                limits,
661                budget,
662                depth - 1,
663                reuse,
664            )?;
665            derive::content_operators(&decoded, limits)?
666        }
667        NodeKind::TextRuns => {
668            let dep = node
669                .deps
670                .first()
671                .ok_or_else(|| Error::usage("TextRuns has no dependency"))?;
672            let child = load_node(store, dep)?;
673            let ops = materialize_inner(
674                source,
675                store,
676                cache,
677                &child,
678                limits,
679                budget,
680                depth - 1,
681                reuse,
682            )?;
683            derive::text_runs(&ops, limits)?
684        }
685        NodeKind::PagePreview => {
686            let dep = node
687                .deps
688                .first()
689                .ok_or_else(|| Error::usage("PagePreview has no dependency"))?;
690            let child = load_node(store, dep)?;
691            let content = materialize_inner(
692                source,
693                store,
694                cache,
695                &child,
696                limits,
697                budget,
698                depth - 1,
699                reuse,
700            )?;
701            let page = read_u32_params(&node.params)?;
702            derive::page_preview(page, &content, limits)?
703        }
704    };
705
706    if out.len() as u64 > node.limits.max_output_bytes {
707        return Err(Error::resource_limit(format!(
708            "node {} produced {} bytes > its cap {}",
709            node.kind.name(),
710            out.len(),
711            node.limits.max_output_bytes
712        )));
713    }
714    budget.charge_bytes(out.len() as u64)?;
715    // Best-effort persistence: a cache write failure never fails the observation.
716    if cache.put(&id, &out).is_ok() {
717        reuse.cache_bytes_written = reuse.cache_bytes_written.saturating_add(out.len() as u64);
718    }
719    Ok(out)
720}
721
722#[cfg(test)]
723mod tests {
724    use super::*;
725    use crate::container::ObjectSource;
726    use crate::dra::{Op, Program};
727    use crate::integrity::sha256;
728    use crate::{EXACTNESS_PROFILE_EXACT_BYTES, SOURCE_FORMAT_OPAQUE};
729
730    fn parsed_for(source: &[u8]) -> ParsedDescriptor {
731        let d = crate::container::Descriptor {
732            universe: crate::container::UNIVERSE.to_string(),
733            source_format: SOURCE_FORMAT_OPAQUE,
734            format_basis: "opaque:test".to_string(),
735            models: vec![],
736            channels: vec![],
737            objects: vec![ObjectSource::Inline(source.to_vec())],
738            program: Program::new(vec![Op::EmitObject { object_id: 0 }]),
739            observation_index: None,
740            seek_directory: false,
741            source_sha256: sha256(source),
742            source_len: source.len() as u64,
743        };
744        let (bytes, _cost) = d.serialize().unwrap();
745        let _ = EXACTNESS_PROFILE_EXACT_BYTES;
746        crate::container::Descriptor::parse(&bytes, Limits::DEFAULT).unwrap()
747    }
748
749    #[test]
750    fn source_slice_matches_exact_bytes() {
751        let parsed = parsed_for(b"hello field world");
752        let mut budget = EvalBudget::default();
753        let (off, len) = (6u64, 5u64);
754        let node = SeedNode::new(
755            NodeKind::SourceSlice,
756            len,
757            crate::field::node::span_params(off, len),
758            vec![],
759            "test",
760        );
761        let bytes = materialize_node(
762            &parsed,
763            &crate::store::FsSeedStore::open(
764                std::env::temp_dir().join(format!("vole-dag-{}", std::process::id())),
765            )
766            .unwrap(),
767            &node,
768            Limits::DEFAULT,
769            &mut budget,
770            8,
771        )
772        .unwrap();
773        assert_eq!(bytes, b"field");
774    }
775
776    #[test]
777    fn literal_roundtrips() {
778        let parsed = parsed_for(b"x");
779        let mut budget = EvalBudget::default();
780        let node = SeedNode::new(NodeKind::Literal, 3, b"abc".to_vec(), vec![], "test");
781        let bytes = materialize_node(
782            &parsed,
783            &crate::store::FsSeedStore::open(
784                std::env::temp_dir().join(format!("vole-lit-{}", std::process::id())),
785            )
786            .unwrap(),
787            &node,
788            Limits::DEFAULT,
789            &mut budget,
790            4,
791        )
792        .unwrap();
793        assert_eq!(bytes, b"abc");
794    }
795}