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::document_format::DocumentFormat;
16use crate::field::index::{FsIndexStore, IndexEntry, SEL_PAGE, SelectorKey, lookup};
17use crate::field::manifest::FieldRoot;
18use crate::store::NodeId;
19
20use super::observe::{ObserveRequest, Representation, Selector, derived_nodes};
21
22/// The selected execution shape of an observation.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum PlanShape {
25    /// Answered purely from validated metadata (no node materialization).
26    CachedObservation,
27    /// Resolved through the hierarchical index to one exact node.
28    IndexLookup,
29    /// A `SourceSlice` node synthesized on the fly from the descriptor program.
30    SourceSlice,
31    /// One or more existing seed nodes are materialized as-is.
32    NodeMaterialize,
33    /// A Stage-C promotion runs first, then the derived node is materialized.
34    DeepenThenObserve,
35    /// The whole source is materialized (`materialize_exact`).
36    FullMaterialize,
37}
38
39impl PlanShape {
40    /// Stable lower-case name (used in EXPLAIN JSON).
41    pub const fn name(self) -> &'static str {
42        match self {
43            PlanShape::CachedObservation => "cached_observation",
44            PlanShape::IndexLookup => "index_lookup",
45            PlanShape::SourceSlice => "source_slice",
46            PlanShape::NodeMaterialize => "node_materialize",
47            PlanShape::DeepenThenObserve => "deepen_then_observe",
48            PlanShape::FullMaterialize => "full_materialize",
49        }
50    }
51}
52
53/// The plan for one observation. `required_nodes` and the materialization lists
54/// are deterministic estimates derived from the selector and index shape; they do
55/// not require loading node bodies.
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct ObservePlan {
58    /// The selected shape.
59    pub shape: PlanShape,
60    /// Index descents the observation will perform.
61    pub index_reads: u64,
62    /// Seed nodes the observation is expected to fetch.
63    pub required_nodes: u64,
64    /// Node kinds / byte classes that will be materialized.
65    pub will_materialize: Vec<String>,
66    /// Node kinds / byte classes that will not be materialized.
67    pub will_not_materialize: Vec<String>,
68}
69
70/// Plan an observation. Pure: no materialization and no writes.
71pub fn plan(manifest: &FieldRoot, store: &FieldStore, req: &ObserveRequest) -> Result<ObservePlan> {
72    // Common (format-neutral) selectors plan through the detected format's
73    // capability set, so an unsupported pair fails closed here exactly as it does
74    // at evaluation time (Phase 12.7).
75    if req.selector.is_common() {
76        return common_plan(manifest, req);
77    }
78    use Representation as R;
79    match (&req.selector, req.representation) {
80        (Selector::Document, R::FullDocument | R::ExactBytes) => Ok(ObservePlan {
81            shape: PlanShape::FullMaterialize,
82            index_reads: 0,
83            required_nodes: 1,
84            will_materialize: kinds(&["DocumentExact"]),
85            will_not_materialize: Vec::new(),
86        }),
87        (Selector::Document, R::Metadata) => Ok(ObservePlan {
88            shape: PlanShape::CachedObservation,
89            index_reads: 0,
90            required_nodes: 0,
91            will_materialize: Vec::new(),
92            will_not_materialize: kinds(&["whole-document", "seed-nodes"]),
93        }),
94        (Selector::ByteRange { .. }, R::ExactBytes) => Ok(ObservePlan {
95            shape: PlanShape::SourceSlice,
96            index_reads: 0,
97            required_nodes: 0,
98            will_materialize: kinds(&["SourceSlice"]),
99            will_not_materialize: kinds(&["whole-document"]),
100        }),
101        (Selector::Object(_), R::ExactBytes | R::EncodedBytes) => Ok(index_plan("PdfObject")),
102        (Selector::Revision(_), R::ExactBytes) => Ok(index_plan("PdfRevision")),
103        (Selector::Member(_), R::EncodedBytes) => Ok(ObservePlan {
104            shape: PlanShape::IndexLookup,
105            index_reads: 1,
106            required_nodes: 1,
107            will_materialize: kinds(&["PackageMemberRaw"]),
108            will_not_materialize: kinds(&["other-members", "whole-document"]),
109        }),
110        (Selector::Member(_), R::DecodedBytes) => Ok(ObservePlan {
111            shape: PlanShape::NodeMaterialize,
112            index_reads: 1,
113            required_nodes: 2,
114            will_materialize: kinds(&["PackageMemberDecoded", "PackageMemberRaw"]),
115            will_not_materialize: kinds(&["other-members", "whole-document"]),
116        }),
117        (Selector::PackagePart(_), R::Metadata) => Ok(ObservePlan {
118            shape: PlanShape::DeepenThenObserve,
119            index_reads: 1,
120            required_nodes: 2,
121            will_materialize: kinds(&["PackageOpcModel"]),
122            will_not_materialize: kinds(&["other-parts", "whole-document"]),
123        }),
124        (Selector::PackagePart(_), R::ExactBytes) => Ok(ObservePlan {
125            shape: PlanShape::DeepenThenObserve,
126            index_reads: 2,
127            required_nodes: 3,
128            will_materialize: kinds(&["PackageOpcModel", "PackageMemberRaw"]),
129            will_not_materialize: kinds(&["other-parts", "whole-document"]),
130        }),
131        (Selector::PackagePart(_), R::DecodedBytes) => Ok(ObservePlan {
132            shape: PlanShape::DeepenThenObserve,
133            index_reads: 2,
134            required_nodes: 4,
135            will_materialize: kinds(&[
136                "PackageOpcModel",
137                "PackageMemberDecoded",
138                "PackageMemberRaw",
139            ]),
140            will_not_materialize: kinds(&["other-parts", "whole-document"]),
141        }),
142        (Selector::Relationship(_), R::Metadata) => Ok(ObservePlan {
143            shape: PlanShape::DeepenThenObserve,
144            index_reads: 1,
145            required_nodes: 2,
146            will_materialize: kinds(&["PackageOpcModel"]),
147            will_not_materialize: kinds(&["other-relationships", "whole-document"]),
148        }),
149        (Selector::Relationship(_), R::ExactBytes) => Ok(ObservePlan {
150            shape: PlanShape::DeepenThenObserve,
151            index_reads: 2,
152            required_nodes: 3,
153            will_materialize: kinds(&["PackageOpcModel", "PackageMemberRaw"]),
154            will_not_materialize: kinds(&["external-targets", "whole-document"]),
155        }),
156        (Selector::Relationship(_), R::DecodedBytes) => Ok(ObservePlan {
157            shape: PlanShape::DeepenThenObserve,
158            index_reads: 2,
159            required_nodes: 4,
160            will_materialize: kinds(&[
161                "PackageOpcModel",
162                "PackageMemberDecoded",
163                "PackageMemberRaw",
164            ]),
165            will_not_materialize: kinds(&["external-targets", "whole-document"]),
166        }),
167        (Selector::Stream(_), R::EncodedBytes) => Ok(index_plan("PdfStreamEncoded")),
168        (Selector::Stream(_), R::DecodedBytes) => Ok(ObservePlan {
169            shape: PlanShape::NodeMaterialize,
170            index_reads: 1,
171            required_nodes: 2,
172            will_materialize: kinds(&["PdfStreamDecoded", "PdfStreamEncoded"]),
173            will_not_materialize: kinds(&["images", "whole-document"]),
174        }),
175        (Selector::Stream(_), R::Operators) => Ok(ObservePlan {
176            shape: PlanShape::NodeMaterialize,
177            index_reads: 1,
178            required_nodes: 2,
179            will_materialize: kinds(&["PdfStreamDecoded", "ContentOperators"]),
180            will_not_materialize: kinds(&["images", "whole-document"]),
181        }),
182        (Selector::Page(n), R::Text) => page_plan(manifest, store, *n, PageRepr::Text),
183        (Selector::Page(n), R::Preview) => page_plan(manifest, store, *n, PageRepr::Preview),
184        (Selector::Page(n), R::Structure) => page_plan(manifest, store, *n, PageRepr::Structure),
185        (Selector::TextMatch(_), R::Text) => Ok(ObservePlan {
186            shape: PlanShape::DeepenThenObserve,
187            index_reads: 1,
188            required_nodes: 3,
189            will_materialize: kinds(&["PageContent", "ContentOperators", "TextRuns"]),
190            will_not_materialize: kinds(&["images", "xobjects", "whole-document"]),
191        }),
192        #[cfg(feature = "docx")]
193        (Selector::DocxStory { .. }, R::Text | R::Structure | R::Metadata)
194        | (Selector::DocxParagraph { .. }, R::Text | R::Metadata)
195        | (Selector::DocxTable { .. }, R::Text | R::Metadata)
196        | (Selector::DocxCell { .. }, R::Text | R::Metadata)
197        | (Selector::DocxFind { .. }, R::Text) => Ok(ObservePlan {
198            shape: PlanShape::DeepenThenObserve,
199            index_reads: 3,
200            required_nodes: 4,
201            will_materialize: kinds(&[
202                "DocxModel",
203                "DocxStory",
204                "PackageMemberDecoded",
205                "PackageMemberRaw",
206            ]),
207            will_not_materialize: kinds(&["other-stories", "whole-document"]),
208        }),
209        #[cfg(feature = "epub")]
210        (Selector::EpubPackage, R::Metadata | R::Structure)
211        | (Selector::EpubNav, R::Metadata | R::Structure)
212        | (Selector::EpubNavNode { .. }, R::Metadata) => Ok(ObservePlan {
213            shape: PlanShape::DeepenThenObserve,
214            index_reads: 1,
215            required_nodes: 3,
216            will_materialize: kinds(&["EpubModel", "PackageMemberDecoded", "PackageMemberRaw"]),
217            will_not_materialize: kinds(&["other-resources", "whole-document"]),
218        }),
219        #[cfg(feature = "epub")]
220        (Selector::EpubManifestItem { .. }, R::Metadata)
221        | (Selector::EpubSpineItem { .. }, R::Metadata)
222        | (Selector::EpubResource(_), R::Metadata) => Ok(ObservePlan {
223            shape: PlanShape::DeepenThenObserve,
224            index_reads: 1,
225            required_nodes: 2,
226            will_materialize: kinds(&["EpubModel"]),
227            will_not_materialize: kinds(&["external-targets", "whole-document"]),
228        }),
229        #[cfg(feature = "epub")]
230        (Selector::EpubSpineItem { .. }, R::Text | R::Structure | R::Preview)
231        | (Selector::EpubBlock { .. }, R::Text | R::Metadata | R::Structure)
232        | (Selector::EpubCell { .. }, R::Text | R::Metadata)
233        | (Selector::EpubLink { .. }, R::Metadata)
234        | (Selector::EpubFind { .. }, R::Text) => Ok(ObservePlan {
235            shape: PlanShape::DeepenThenObserve,
236            index_reads: 2,
237            required_nodes: 4,
238            will_materialize: kinds(&[
239                "EpubModel",
240                "EpubContent",
241                "PackageMemberDecoded",
242                "PackageMemberRaw",
243            ]),
244            will_not_materialize: kinds(&["other-spine-items", "whole-document"]),
245        }),
246        #[cfg(feature = "epub")]
247        (Selector::EpubManifestItem { .. }, R::ExactBytes | R::DecodedBytes)
248        | (Selector::EpubSpineItem { .. }, R::ExactBytes | R::DecodedBytes)
249        | (Selector::EpubResource(_), R::ExactBytes | R::DecodedBytes) => Ok(ObservePlan {
250            shape: PlanShape::DeepenThenObserve,
251            index_reads: 2,
252            required_nodes: 3,
253            will_materialize: kinds(&["EpubModel", "PackageMemberRaw", "PackageMemberDecoded"]),
254            will_not_materialize: kinds(&["external-targets", "whole-document"]),
255        }),
256        _ => Err(Error::unsupported_feature(format!(
257            "unsupported observation: selector {} with representation {}",
258            req.selector.canonical(),
259            req.representation.name()
260        ))),
261    }
262}
263
264#[derive(Debug, Clone, Copy)]
265enum PageRepr {
266    Text,
267    Preview,
268    Structure,
269}
270
271fn kinds(names: &[&str]) -> Vec<String> {
272    names.iter().map(|s| (*s).to_string()).collect()
273}
274
275fn index_plan(kind: &str) -> ObservePlan {
276    ObservePlan {
277        shape: PlanShape::IndexLookup,
278        index_reads: 1,
279        required_nodes: 1,
280        will_materialize: kinds(&[kind]),
281        will_not_materialize: kinds(&["images", "xobjects", "PageContent", "whole-document"]),
282    }
283}
284
285/// Plan a common (format-neutral) observation. Pure and capability-checked: an
286/// unsupported pair fails closed with the same typed error the evaluator raises.
287fn common_plan(manifest: &FieldRoot, req: &ObserveRequest) -> Result<ObservePlan> {
288    use crate::field::capabilities;
289    let fmt = DocumentFormat::from_provenance(&manifest.provenance).ok_or_else(|| {
290        Error::unsupported_feature(
291            "field manifest does not record a document format; common observations are unavailable",
292        )
293    })?;
294    if !capabilities::common_supported(fmt, &req.selector, req.representation) {
295        return Err(Error::unsupported_feature(format!(
296            "unsupported common observation: format {} does not support selector {} with representation {}",
297            fmt.name(),
298            req.selector.canonical(),
299            req.representation.name()
300        )));
301    }
302    // Document metadata is answered from already-validated state; every other
303    // common observation materializes the format's model/content closure.
304    if matches!(req.selector, Selector::Metadata) {
305        return Ok(ObservePlan {
306            shape: PlanShape::CachedObservation,
307            index_reads: 0,
308            required_nodes: 0,
309            will_materialize: Vec::new(),
310            will_not_materialize: kinds(&["whole-document", "seed-nodes"]),
311        });
312    }
313    Ok(ObservePlan {
314        shape: PlanShape::DeepenThenObserve,
315        index_reads: 2,
316        required_nodes: 4,
317        will_materialize: kinds(common_materialize(fmt)),
318        will_not_materialize: kinds(&["other-spine-items", "other-stories", "whole-document"]),
319    })
320}
321
322fn common_materialize(fmt: DocumentFormat) -> &'static [&'static str] {
323    match fmt {
324        DocumentFormat::Pdf => &["PageContent", "TextRuns"],
325        DocumentFormat::Docx => &[
326            "DocxModel",
327            "DocxStory",
328            "PackageMemberDecoded",
329            "PackageMemberRaw",
330        ],
331        DocumentFormat::Epub => &[
332            "EpubModel",
333            "EpubContent",
334            "PackageMemberDecoded",
335            "PackageMemberRaw",
336        ],
337        DocumentFormat::Opaque => &[],
338    }
339}
340
341fn index_entries(
342    manifest: &FieldRoot,
343    store: &FieldStore,
344    key: SelectorKey,
345) -> Result<Vec<IndexEntry>> {
346    if !manifest.has_index() {
347        return Ok(Vec::new());
348    }
349    let istore = FsIndexStore::open(store.root())?;
350    let root = NodeId::from_bytes(manifest.index_root);
351    lookup(&istore, &root, &key)
352}
353
354fn page_plan(
355    manifest: &FieldRoot,
356    store: &FieldStore,
357    page: u32,
358    repr: PageRepr,
359) -> Result<ObservePlan> {
360    let entry = index_entries(manifest, store, SelectorKey::new(SEL_PAGE, page))?
361        .into_iter()
362        .next()
363        .ok_or_else(|| {
364            Error::unsupported_feature(format!(
365                "no page matching selector number {page} in the observation index"
366            ))
367        })?;
368    let (_ops, text, preview) = derived_nodes(page, entry.node_id);
369    let target = match repr {
370        PageRepr::Text => text,
371        PageRepr::Preview | PageRepr::Structure => preview,
372    };
373    let present = store.seeds().contains_node(&target.content_id())?;
374    let shape = if present {
375        PlanShape::NodeMaterialize
376    } else {
377        PlanShape::DeepenThenObserve
378    };
379    let (materialize, required) = match repr {
380        PageRepr::Text => (kinds(&["PageContent", "ContentOperators", "TextRuns"]), 3),
381        PageRepr::Preview => (kinds(&["PageContent", "PagePreview"]), 2),
382        PageRepr::Structure => (
383            kinds(&["PageContent", "PagePreview", "ContentOperators"]),
384            3,
385        ),
386    };
387    Ok(ObservePlan {
388        shape,
389        index_reads: 1,
390        required_nodes: required,
391        will_materialize: materialize,
392        will_not_materialize: kinds(&["images", "xobjects", "whole-document"]),
393    })
394}
395
396#[cfg(test)]
397mod tests {
398    use super::*;
399
400    #[test]
401    fn shape_names_are_lower_snake_case() {
402        for shape in [
403            PlanShape::CachedObservation,
404            PlanShape::IndexLookup,
405            PlanShape::SourceSlice,
406            PlanShape::NodeMaterialize,
407            PlanShape::DeepenThenObserve,
408            PlanShape::FullMaterialize,
409        ] {
410            let name = shape.name();
411            assert!(name.chars().all(|c| c.is_ascii_lowercase() || c == '_'));
412        }
413    }
414}