Skip to main content

vole_document/field/
plan.rs

1//! The deterministic observation planner (Phase 11.5–11.6, ADR-0026, DEC-9).
2//!
3//! [`plan`] is a pure function of validated metadata: it reads the field manifest
4//! and (read-only) the hierarchical index, decides which frontier the observation
5//! will select, and reports what will and will not be materialized. It never
6//! materializes seed nodes and never writes to the store, so calling it cannot
7//! change the outcome of a subsequent [`crate::field::observe::observe`].
8//!
9//! Correctness is a precondition, not a cost. Shapes are named, not scored here:
10//! the reference implementation has exactly one admissible shape per supported
11//! selector/representation pair, and an unsupported pair is a typed error.
12
13use crate::error::{Error, Result};
14use crate::field::FieldStore;
15use crate::field::index::{FsIndexStore, IndexEntry, SEL_PAGE, SelectorKey, lookup};
16use crate::field::manifest::FieldRoot;
17use crate::store::NodeId;
18
19use super::observe::{ObserveRequest, Representation, Selector, derived_nodes};
20
21/// The selected execution shape of an observation.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum PlanShape {
24    /// Answered purely from validated metadata (no node materialization).
25    CachedObservation,
26    /// Resolved through the hierarchical index to one exact node.
27    IndexLookup,
28    /// A `SourceSlice` node synthesized on the fly from the descriptor program.
29    SourceSlice,
30    /// One or more existing seed nodes are materialized as-is.
31    NodeMaterialize,
32    /// A Stage-C promotion runs first, then the derived node is materialized.
33    DeepenThenObserve,
34    /// The whole source is materialized (`materialize_exact`).
35    FullMaterialize,
36}
37
38impl PlanShape {
39    /// Stable lower-case name (used in EXPLAIN JSON).
40    pub const fn name(self) -> &'static str {
41        match self {
42            PlanShape::CachedObservation => "cached_observation",
43            PlanShape::IndexLookup => "index_lookup",
44            PlanShape::SourceSlice => "source_slice",
45            PlanShape::NodeMaterialize => "node_materialize",
46            PlanShape::DeepenThenObserve => "deepen_then_observe",
47            PlanShape::FullMaterialize => "full_materialize",
48        }
49    }
50}
51
52/// The plan for one observation. `required_nodes` and the materialization lists
53/// are deterministic estimates derived from the selector and index shape; they do
54/// not require loading node bodies.
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub struct ObservePlan {
57    /// The selected shape.
58    pub shape: PlanShape,
59    /// Index descents the observation will perform.
60    pub index_reads: u64,
61    /// Seed nodes the observation is expected to fetch.
62    pub required_nodes: u64,
63    /// Node kinds / byte classes that will be materialized.
64    pub will_materialize: Vec<String>,
65    /// Node kinds / byte classes that will not be materialized.
66    pub will_not_materialize: Vec<String>,
67}
68
69/// Plan an observation. Pure: no materialization and no writes.
70pub fn plan(manifest: &FieldRoot, store: &FieldStore, req: &ObserveRequest) -> Result<ObservePlan> {
71    use Representation as R;
72    match (&req.selector, req.representation) {
73        (Selector::Document, R::FullDocument | R::ExactBytes) => Ok(ObservePlan {
74            shape: PlanShape::FullMaterialize,
75            index_reads: 0,
76            required_nodes: 1,
77            will_materialize: kinds(&["DocumentExact"]),
78            will_not_materialize: Vec::new(),
79        }),
80        (Selector::Document, R::Metadata) => Ok(ObservePlan {
81            shape: PlanShape::CachedObservation,
82            index_reads: 0,
83            required_nodes: 0,
84            will_materialize: Vec::new(),
85            will_not_materialize: kinds(&["whole-document", "seed-nodes"]),
86        }),
87        (Selector::ByteRange { .. }, R::ExactBytes) => Ok(ObservePlan {
88            shape: PlanShape::SourceSlice,
89            index_reads: 0,
90            required_nodes: 0,
91            will_materialize: kinds(&["SourceSlice"]),
92            will_not_materialize: kinds(&["whole-document"]),
93        }),
94        (Selector::Object(_), R::ExactBytes | R::EncodedBytes) => Ok(index_plan("PdfObject")),
95        (Selector::Revision(_), R::ExactBytes) => Ok(index_plan("PdfRevision")),
96        (Selector::Stream(_), R::EncodedBytes) => Ok(index_plan("PdfStreamEncoded")),
97        (Selector::Stream(_), R::DecodedBytes) => Ok(ObservePlan {
98            shape: PlanShape::NodeMaterialize,
99            index_reads: 1,
100            required_nodes: 2,
101            will_materialize: kinds(&["PdfStreamDecoded", "PdfStreamEncoded"]),
102            will_not_materialize: kinds(&["images", "whole-document"]),
103        }),
104        (Selector::Stream(_), R::Operators) => Ok(ObservePlan {
105            shape: PlanShape::NodeMaterialize,
106            index_reads: 1,
107            required_nodes: 2,
108            will_materialize: kinds(&["PdfStreamDecoded", "ContentOperators"]),
109            will_not_materialize: kinds(&["images", "whole-document"]),
110        }),
111        (Selector::Page(n), R::Text) => page_plan(manifest, store, *n, PageRepr::Text),
112        (Selector::Page(n), R::Preview) => page_plan(manifest, store, *n, PageRepr::Preview),
113        (Selector::Page(n), R::Structure) => page_plan(manifest, store, *n, PageRepr::Structure),
114        (Selector::TextMatch(_), R::Text) => Ok(ObservePlan {
115            shape: PlanShape::DeepenThenObserve,
116            index_reads: 1,
117            required_nodes: 3,
118            will_materialize: kinds(&["PageContent", "ContentOperators", "TextRuns"]),
119            will_not_materialize: kinds(&["images", "xobjects", "whole-document"]),
120        }),
121        _ => Err(Error::unsupported_feature(format!(
122            "unsupported observation: selector {} with representation {}",
123            req.selector.canonical(),
124            req.representation.name()
125        ))),
126    }
127}
128
129#[derive(Debug, Clone, Copy)]
130enum PageRepr {
131    Text,
132    Preview,
133    Structure,
134}
135
136fn kinds(names: &[&str]) -> Vec<String> {
137    names.iter().map(|s| (*s).to_string()).collect()
138}
139
140fn index_plan(kind: &str) -> ObservePlan {
141    ObservePlan {
142        shape: PlanShape::IndexLookup,
143        index_reads: 1,
144        required_nodes: 1,
145        will_materialize: kinds(&[kind]),
146        will_not_materialize: kinds(&["images", "xobjects", "PageContent", "whole-document"]),
147    }
148}
149
150fn index_entries(
151    manifest: &FieldRoot,
152    store: &FieldStore,
153    key: SelectorKey,
154) -> Result<Vec<IndexEntry>> {
155    if !manifest.has_index() {
156        return Ok(Vec::new());
157    }
158    let istore = FsIndexStore::open(store.root())?;
159    let root = NodeId::from_bytes(manifest.index_root);
160    lookup(&istore, &root, &key)
161}
162
163fn page_plan(
164    manifest: &FieldRoot,
165    store: &FieldStore,
166    page: u32,
167    repr: PageRepr,
168) -> Result<ObservePlan> {
169    let entry = index_entries(manifest, store, SelectorKey::new(SEL_PAGE, page))?
170        .into_iter()
171        .next()
172        .ok_or_else(|| {
173            Error::unsupported_feature(format!(
174                "no page matching selector number {page} in the observation index"
175            ))
176        })?;
177    let (_ops, text, preview) = derived_nodes(page, entry.node_id);
178    let target = match repr {
179        PageRepr::Text => text,
180        PageRepr::Preview | PageRepr::Structure => preview,
181    };
182    let present = store.seeds().contains_node(&target.content_id())?;
183    let shape = if present {
184        PlanShape::NodeMaterialize
185    } else {
186        PlanShape::DeepenThenObserve
187    };
188    let (materialize, required) = match repr {
189        PageRepr::Text => (kinds(&["PageContent", "ContentOperators", "TextRuns"]), 3),
190        PageRepr::Preview => (kinds(&["PageContent", "PagePreview"]), 2),
191        PageRepr::Structure => (
192            kinds(&["PageContent", "PagePreview", "ContentOperators"]),
193            3,
194        ),
195    };
196    Ok(ObservePlan {
197        shape,
198        index_reads: 1,
199        required_nodes: required,
200        will_materialize: materialize,
201        will_not_materialize: kinds(&["images", "xobjects", "whole-document"]),
202    })
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208
209    #[test]
210    fn shape_names_are_lower_snake_case() {
211        for shape in [
212            PlanShape::CachedObservation,
213            PlanShape::IndexLookup,
214            PlanShape::SourceSlice,
215            PlanShape::NodeMaterialize,
216            PlanShape::DeepenThenObserve,
217            PlanShape::FullMaterialize,
218        ] {
219            let name = shape.name();
220            assert!(name.chars().all(|c| c.is_ascii_lowercase() || c == '_'));
221        }
222    }
223}