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        #[cfg(feature = "odt")]
257        (Selector::OdtParagraph { .. }, R::Text | R::Metadata)
258        | (Selector::OdtHeading { .. }, R::Text | R::Metadata)
259        | (Selector::OdtTable { .. }, R::Text | R::Metadata)
260        | (Selector::OdtCell { .. }, R::Text | R::Metadata)
261        | (Selector::OdtList { .. }, R::Text | R::Metadata)
262        | (Selector::OdtFind { .. }, R::Text) => Ok(ObservePlan {
263            shape: PlanShape::DeepenThenObserve,
264            index_reads: 3,
265            required_nodes: 4,
266            will_materialize: kinds(&[
267                "OdtModel",
268                "OdtContent",
269                "PackageMemberDecoded",
270                "PackageMemberRaw",
271            ]),
272            will_not_materialize: kinds(&["other-parts", "whole-document"]),
273        }),
274        #[cfg(feature = "odt")]
275        (Selector::OdtPart(_), R::Metadata) => Ok(ObservePlan {
276            shape: PlanShape::DeepenThenObserve,
277            index_reads: 1,
278            required_nodes: 2,
279            will_materialize: kinds(&["OdtModel"]),
280            will_not_materialize: kinds(&["other-parts", "whole-document"]),
281        }),
282        #[cfg(feature = "odt")]
283        (Selector::OdtPart(_), R::ExactBytes | R::DecodedBytes) => Ok(ObservePlan {
284            shape: PlanShape::DeepenThenObserve,
285            index_reads: 2,
286            required_nodes: 3,
287            will_materialize: kinds(&["OdtModel", "PackageMemberRaw", "PackageMemberDecoded"]),
288            will_not_materialize: kinds(&["other-parts", "whole-document"]),
289        }),
290        _ => Err(Error::unsupported_feature(format!(
291            "unsupported observation: selector {} with representation {}",
292            req.selector.canonical(),
293            req.representation.name()
294        ))),
295    }
296}
297
298#[derive(Debug, Clone, Copy)]
299enum PageRepr {
300    Text,
301    Preview,
302    Structure,
303}
304
305fn kinds(names: &[&str]) -> Vec<String> {
306    names.iter().map(|s| (*s).to_string()).collect()
307}
308
309fn index_plan(kind: &str) -> ObservePlan {
310    ObservePlan {
311        shape: PlanShape::IndexLookup,
312        index_reads: 1,
313        required_nodes: 1,
314        will_materialize: kinds(&[kind]),
315        will_not_materialize: kinds(&["images", "xobjects", "PageContent", "whole-document"]),
316    }
317}
318
319/// Plan a common (format-neutral) observation. Pure and capability-checked: an
320/// unsupported pair fails closed with the same typed error the evaluator raises.
321fn common_plan(manifest: &FieldRoot, req: &ObserveRequest) -> Result<ObservePlan> {
322    use crate::field::capabilities;
323    let fmt = DocumentFormat::from_provenance(&manifest.provenance).ok_or_else(|| {
324        Error::unsupported_feature(
325            "field manifest does not record a document format; common observations are unavailable",
326        )
327    })?;
328    if !capabilities::common_supported(fmt, &req.selector, req.representation) {
329        return Err(Error::unsupported_feature(format!(
330            "unsupported common observation: format {} does not support selector {} with representation {}",
331            fmt.name(),
332            req.selector.canonical(),
333            req.representation.name()
334        )));
335    }
336    // Document metadata is answered from already-validated state; every other
337    // common observation materializes the format's model/content closure.
338    if matches!(req.selector, Selector::Metadata) {
339        return Ok(ObservePlan {
340            shape: PlanShape::CachedObservation,
341            index_reads: 0,
342            required_nodes: 0,
343            will_materialize: Vec::new(),
344            will_not_materialize: kinds(&["whole-document", "seed-nodes"]),
345        });
346    }
347    Ok(ObservePlan {
348        shape: PlanShape::DeepenThenObserve,
349        index_reads: 2,
350        required_nodes: 4,
351        will_materialize: kinds(common_materialize(fmt)),
352        will_not_materialize: kinds(&["other-spine-items", "other-stories", "whole-document"]),
353    })
354}
355
356fn common_materialize(fmt: DocumentFormat) -> &'static [&'static str] {
357    match fmt {
358        DocumentFormat::Pdf => &["PageContent", "TextRuns"],
359        DocumentFormat::Docx => &[
360            "DocxModel",
361            "DocxStory",
362            "PackageMemberDecoded",
363            "PackageMemberRaw",
364        ],
365        DocumentFormat::Epub => &[
366            "EpubModel",
367            "EpubContent",
368            "PackageMemberDecoded",
369            "PackageMemberRaw",
370        ],
371        DocumentFormat::Odt => &[
372            "OdtModel",
373            "OdtContent",
374            "PackageMemberDecoded",
375            "PackageMemberRaw",
376        ],
377        DocumentFormat::Opaque => &[],
378    }
379}
380
381fn index_entries(
382    manifest: &FieldRoot,
383    store: &FieldStore,
384    key: SelectorKey,
385) -> Result<Vec<IndexEntry>> {
386    if !manifest.has_index() {
387        return Ok(Vec::new());
388    }
389    let istore = FsIndexStore::open(store.root())?;
390    let root = NodeId::from_bytes(manifest.index_root);
391    lookup(&istore, &root, &key)
392}
393
394fn page_plan(
395    manifest: &FieldRoot,
396    store: &FieldStore,
397    page: u32,
398    repr: PageRepr,
399) -> Result<ObservePlan> {
400    let entry = index_entries(manifest, store, SelectorKey::new(SEL_PAGE, page))?
401        .into_iter()
402        .next()
403        .ok_or_else(|| {
404            Error::unsupported_feature(format!(
405                "no page matching selector number {page} in the observation index"
406            ))
407        })?;
408    let (_ops, text, preview) = derived_nodes(page, entry.node_id);
409    let target = match repr {
410        PageRepr::Text => text,
411        PageRepr::Preview | PageRepr::Structure => preview,
412    };
413    let present = store.seeds().contains_node(&target.content_id())?;
414    let shape = if present {
415        PlanShape::NodeMaterialize
416    } else {
417        PlanShape::DeepenThenObserve
418    };
419    let (materialize, required) = match repr {
420        PageRepr::Text => (kinds(&["PageContent", "ContentOperators", "TextRuns"]), 3),
421        PageRepr::Preview => (kinds(&["PageContent", "PagePreview"]), 2),
422        PageRepr::Structure => (
423            kinds(&["PageContent", "PagePreview", "ContentOperators"]),
424            3,
425        ),
426    };
427    Ok(ObservePlan {
428        shape,
429        index_reads: 1,
430        required_nodes: required,
431        will_materialize: materialize,
432        will_not_materialize: kinds(&["images", "xobjects", "whole-document"]),
433    })
434}
435
436#[cfg(test)]
437mod tests {
438    use super::*;
439
440    #[test]
441    fn shape_names_are_lower_snake_case() {
442        for shape in [
443            PlanShape::CachedObservation,
444            PlanShape::IndexLookup,
445            PlanShape::SourceSlice,
446            PlanShape::NodeMaterialize,
447            PlanShape::DeepenThenObserve,
448            PlanShape::FullMaterialize,
449        ] {
450            let name = shape.name();
451            assert!(name.chars().all(|c| c.is_ascii_lowercase() || c == '_'));
452        }
453    }
454}