Skip to main content

vole_document/field/
observe.rs

1//! The observation query engine (Phase 11.5–11.7).
2//!
3//! An [`observe`] call resolves one typed selector/representation pair against a
4//! persisted field and returns a [`FieldAnswer`] with full provenance plus
5//! [`ObserveStats`] describing the work actually done, and the current/promoted
6//! [`FieldId`] so a caller can chain without re-deepening. The engine is
7//! deterministic and read-only with respect to archival authority: it never
8//! consults an agent, model, or search process, and it can never influence
9//! `materialize_exact` (ADR-0024, DEC-6).
10//!
11//! ## The frontier rule (11.6)
12//!
13//! A narrow observation materializes only the dependency closure it needs. In
14//! particular, `Page(n) + Structure` reads the page's decoded content streams and
15//! its preview node — it never reads image/XObject bytes and never reconstructs
16//! the whole document. The split byte classes in [`ObserveStats`] make this
17//! auditable: the **seed** closure is reported separately as `seed_bytes_read`,
18//! and the descriptor bytes a narrow observation necessarily reads to open the
19//! field are charged honestly in `descriptor_bytes_read` rather than hidden.
20//!
21//! ## No guessing
22//!
23//! An unsupported selector/representation pair is a typed
24//! [`crate::ErrorClass::UnsupportedFeature`], never a silently empty answer.
25
26use std::cell::Cell;
27use std::time::Instant;
28
29#[cfg(feature = "docx")]
30use crate::adapter::docx::wml::StoryModel;
31#[cfg(feature = "docx")]
32use crate::adapter::docx::{DocxExtractProfile, DocxModel, DocxPartRef, DocxStory, story_params};
33#[cfg(feature = "epub")]
34use crate::adapter::epub::{EpubExtractProfile, EpubModel, ManifestItem, PackageDoc};
35use crate::error::{Error, Result};
36use crate::field::cache::DerivedCache;
37use crate::field::dag::{self, EvalBudget, ReuseStats, SourceServer};
38use crate::field::document_format::DocumentFormat;
39#[cfg(feature = "docx")]
40use crate::field::index::SEL_DOCX_MODEL;
41#[cfg(feature = "epub")]
42use crate::field::index::SEL_EPUB_MODEL;
43#[cfg(feature = "opc")]
44use crate::field::index::SEL_OPC_MODEL;
45use crate::field::index::{
46    FsIndexStore, IndexEntry, SEL_OBJECT, SEL_PACKAGE_MEMBER_DECODED, SEL_PACKAGE_MEMBER_RAW,
47    SEL_PAGE, SEL_REVISION, SEL_STREAM, SEL_STREAM_DECODED, SelectorKey, lookup,
48};
49use crate::field::ingest;
50use crate::field::manifest::FieldRoot;
51use crate::field::node::{NodeKind, SeedNode, read_u32_params, span_params, u32_params};
52use crate::field::partial::{PartialDescriptor, PartialLoad};
53use crate::field::{Field, FieldId, FieldStore, SeedSubstrate};
54use crate::limits::Limits;
55use crate::store::{Id, IoSnapshot, NodeId, SeedStore};
56
57use super::provenance::{AnswerValue, Basis, FieldAnswer, IntegrityScope, json_escape};
58
59/// Upper bound on pages a single `TextMatch` scan will visit.
60const MAX_TEXTMATCH_PAGES: u32 = 1 << 20;
61
62/// A typed observation selector.
63#[derive(Debug, Clone, PartialEq, Eq)]
64pub enum Selector {
65    /// The whole document.
66    Document,
67    /// A page, by 1-based page number (as recovered by ingest).
68    Page(u32),
69    /// An indirect object, by object number.
70    Object(u32),
71    /// An encoded stream, by owning object number.
72    Stream(u32),
73    /// A physical revision, by 0-based index.
74    Revision(u32),
75    /// A package (ZIP/OCF/OPC) member, by central-directory ordinal. The ordinal is
76    /// the physical identity; duplicate names stay distinct (Phase 12.2).
77    Member(u32),
78    /// A generic OPC package part, by absolute part name (Phase 12.3). Part-name
79    /// equivalence is case-insensitive. Resolution is by the OPC relationship graph,
80    /// never by a hardcoded path.
81    PackagePart(String),
82    /// A generic OPC relationship, by id (Phase 12.3). Ids are only unique within one
83    /// `.rels` part, so a duplicated id across owners is a typed ambiguity decline.
84    Relationship(String),
85    /// A half-open exact source byte range.
86    ByteRange {
87        /// Start offset.
88        offset: u64,
89        /// Length in bytes.
90        len: u64,
91    },
92    /// Every text line containing a pattern (case-sensitive).
93    TextMatch(String),
94    /// **Common** document-level metadata projection (format-neutral). Only the
95    /// detected format's native metadata is projected; the answer names the format
96    /// and its native provenance (Phase 12.7, ADR-0031).
97    Metadata,
98    /// **Common** whole-document reading-text projection.
99    Text,
100    /// **Common** the `n`-th heading in reading order (0-based).
101    Heading(u32),
102    /// **Common** the `n`-th block (paragraph or table) in reading order (0-based).
103    Block(u32),
104    /// **Common** the `n`-th top-level table in reading order (0-based).
105    Table(u32),
106    /// **Common** a table cell by 0-based `table`, `row`, and physical grid `col`.
107    Cell {
108        /// 0-based table index in reading order.
109        table: u32,
110        /// 0-based row index.
111        row: u32,
112        /// 0-based physical column index (a DOCX grid column; an EPUB cell position).
113        col: u32,
114    },
115    /// **Common** the `n`-th embedded resource (image/embedded object) in reading
116    /// order (0-based).
117    Resource(u32),
118    /// **Common** the `n`-th hyperlink in reading order (0-based).
119    Link(u32),
120    /// **Common** a deterministic, case-sensitive lexical search over the
121    /// document's reading text. Never an embedding or a model call.
122    SearchMatch(String),
123    /// A DOCX story, scoped to exactly one story and one extraction profile
124    /// (Phase 12.4). A story is never silently mixed with another.
125    #[cfg(feature = "docx")]
126    DocxStory {
127        /// The story to observe.
128        story: DocxStory,
129        /// The extraction profile identity.
130        profile: DocxExtractProfile,
131    },
132    /// A body-level paragraph of a DOCX story, by 0-based document-order index.
133    #[cfg(feature = "docx")]
134    DocxParagraph {
135        /// The owning story.
136        story: DocxStory,
137        /// The paragraph index.
138        index: u32,
139        /// The extraction profile identity.
140        profile: DocxExtractProfile,
141    },
142    /// A top-level DOCX table, by 0-based index.
143    #[cfg(feature = "docx")]
144    DocxTable {
145        /// The owning story.
146        story: DocxStory,
147        /// The table index.
148        index: u32,
149        /// The extraction profile identity.
150        profile: DocxExtractProfile,
151    },
152    /// A DOCX table cell, addressed by an A1-style reference (e.g. `B7`).
153    #[cfg(feature = "docx")]
154    DocxCell {
155        /// The owning story.
156        story: DocxStory,
157        /// The table index.
158        table: u32,
159        /// The cell reference (`B7`: column `B`, 1-based row `7`).
160        cell: String,
161        /// The extraction profile identity.
162        profile: DocxExtractProfile,
163    },
164    /// A story-scoped text search over paragraphs.
165    #[cfg(feature = "docx")]
166    DocxFind {
167        /// The owning story.
168        story: DocxStory,
169        /// The pattern (case-sensitive substring).
170        pattern: String,
171        /// The extraction profile identity.
172        profile: DocxExtractProfile,
173    },
174    /// The EPUB (OCF) container + Package Document as a whole (Phase 12.5).
175    #[cfg(feature = "epub")]
176    EpubPackage,
177    /// A Package Document manifest item, by 0-based document-order index. `ExactBytes`
178    /// and `DecodedBytes` resolve to the item's container member; an external target
179    /// is an inert identifier and is a typed decline, never a fetch.
180    #[cfg(feature = "epub")]
181    EpubManifestItem {
182        /// The manifest index.
183        index: u32,
184    },
185    /// A spine item as the reading-order coordinate (Phase 12.5). The index is into
186    /// the reading order selected by `profile` (`linear-only` by default). This is
187    /// **not** `Page(n)`: reflowable EPUB has no intrinsic pages.
188    #[cfg(feature = "epub")]
189    EpubSpineItem {
190        /// The reading-order index.
191        index: u32,
192        /// The reading profile identity.
193        profile: EpubExtractProfile,
194    },
195    /// The EPUB Navigation Document (its `toc`/`landmarks`/`page-list` sets).
196    #[cfg(feature = "epub")]
197    EpubNav,
198    /// One flattened navigation entry, by 0-based index.
199    #[cfg(feature = "epub")]
200    EpubNavNode {
201        /// The entry index.
202        index: u32,
203    },
204    /// A container resource by member name (e.g. `OEBPS/text/ch1.xhtml`), resolved
205    /// through the manifest; external targets are inert and never fetched.
206    #[cfg(feature = "epub")]
207    EpubResource(String),
208    /// One block of a spine item's content document (Phase 12.6), by 0-based
209    /// document-order index into that item's parsed [`crate::adapter::epub::Block`]
210    /// list (heading/paragraph/list/table).
211    #[cfg(feature = "epub")]
212    EpubBlock {
213        /// The reading-order spine index.
214        index: u32,
215        /// The block index.
216        block: u32,
217        /// The reading profile identity.
218        profile: EpubExtractProfile,
219    },
220    /// One table cell of a spine item, addressed by **physical** position: the
221    /// 0-based table index among the item's tables, the 0-based `tr` index, and the
222    /// 0-based cell index within that row (spans are reported, never projected).
223    #[cfg(feature = "epub")]
224    EpubCell {
225        /// The reading-order spine index.
226        index: u32,
227        /// The 0-based table index.
228        table: u32,
229        /// The 0-based row index.
230        row: u32,
231        /// The 0-based physical cell index within the row.
232        col: u32,
233        /// The reading profile identity.
234        profile: EpubExtractProfile,
235    },
236    /// One link of a spine item's content document, by 0-based index.
237    #[cfg(feature = "epub")]
238    EpubLink {
239        /// The reading-order spine index.
240        index: u32,
241        /// The link index.
242        link: u32,
243        /// The reading profile identity.
244        profile: EpubExtractProfile,
245    },
246    /// A text search over one spine item's blocks, scoped by the reading profile.
247    #[cfg(feature = "epub")]
248    EpubFind {
249        /// The reading-order spine index.
250        index: u32,
251        /// The pattern (case-sensitive substring).
252        pattern: String,
253        /// The reading profile identity.
254        profile: EpubExtractProfile,
255    },
256}
257
258impl Selector {
259    /// Canonical selector text, e.g. `page:1` or `byte-range:10:4`.
260    pub fn canonical(&self) -> String {
261        match self {
262            Selector::Document => "document".to_string(),
263            Selector::Page(n) => format!("page:{n}"),
264            Selector::Object(n) => format!("object:{n}"),
265            Selector::Stream(n) => format!("stream:{n}"),
266            Selector::Revision(n) => format!("revision:{n}"),
267            Selector::Member(n) => format!("member:{n}"),
268            Selector::PackagePart(name) => format!("package-part:{name}"),
269            Selector::Relationship(id) => format!("relationship:{id}"),
270            Selector::ByteRange { offset, len } => format!("byte-range:{offset}:{len}"),
271            Selector::TextMatch(p) => format!("text-match:{p}"),
272            Selector::Metadata => "metadata".to_string(),
273            Selector::Text => "text".to_string(),
274            Selector::Heading(n) => format!("heading:{n}"),
275            Selector::Block(n) => format!("block:{n}"),
276            Selector::Table(n) => format!("table:{n}"),
277            Selector::Cell { table, row, col } => format!("cell:{table}:{row}:{col}"),
278            Selector::Resource(n) => format!("resource:{n}"),
279            Selector::Link(n) => format!("link:{n}"),
280            Selector::SearchMatch(p) => format!("search-match:{p}"),
281            #[cfg(feature = "docx")]
282            Selector::DocxStory { story, profile } => {
283                format!(
284                    "docx-story:{};profile={}",
285                    story.name(),
286                    profile.fingerprint()
287                )
288            }
289            #[cfg(feature = "docx")]
290            Selector::DocxParagraph {
291                story,
292                index,
293                profile,
294            } => format!(
295                "docx-paragraph:{}:{};profile={}",
296                story.name(),
297                index,
298                profile.fingerprint()
299            ),
300            #[cfg(feature = "docx")]
301            Selector::DocxTable {
302                story,
303                index,
304                profile,
305            } => format!(
306                "docx-table:{}:{};profile={}",
307                story.name(),
308                index,
309                profile.fingerprint()
310            ),
311            #[cfg(feature = "docx")]
312            Selector::DocxCell {
313                story,
314                table,
315                cell,
316                profile,
317            } => format!(
318                "docx-cell:{}:{}:{};profile={}",
319                story.name(),
320                table,
321                cell,
322                profile.fingerprint()
323            ),
324            #[cfg(feature = "docx")]
325            Selector::DocxFind {
326                story,
327                pattern,
328                profile,
329            } => format!(
330                "docx-find:{}:{};profile={}",
331                story.name(),
332                pattern,
333                profile.fingerprint()
334            ),
335            #[cfg(feature = "epub")]
336            Selector::EpubPackage => "epub-package".to_string(),
337            #[cfg(feature = "epub")]
338            Selector::EpubManifestItem { index } => format!("epub-manifest-item:{index}"),
339            #[cfg(feature = "epub")]
340            Selector::EpubSpineItem { index, profile } => {
341                format!("epub-spine-item:{index};profile={}", profile.fingerprint())
342            }
343            #[cfg(feature = "epub")]
344            Selector::EpubNav => "epub-nav".to_string(),
345            #[cfg(feature = "epub")]
346            Selector::EpubNavNode { index } => format!("epub-nav-node:{index}"),
347            #[cfg(feature = "epub")]
348            Selector::EpubResource(name) => format!("epub-resource:{name}"),
349            #[cfg(feature = "epub")]
350            Selector::EpubBlock {
351                index,
352                block,
353                profile,
354            } => format!(
355                "epub-block:{index}:{block};profile={}",
356                profile.fingerprint()
357            ),
358            #[cfg(feature = "epub")]
359            Selector::EpubCell {
360                index,
361                table,
362                row,
363                col,
364                profile,
365            } => format!(
366                "epub-cell:{index}:{table}:{row}:{col};profile={}",
367                profile.fingerprint()
368            ),
369            #[cfg(feature = "epub")]
370            Selector::EpubLink {
371                index,
372                link,
373                profile,
374            } => format!("epub-link:{index}:{link};profile={}", profile.fingerprint()),
375            #[cfg(feature = "epub")]
376            Selector::EpubFind {
377                index,
378                pattern,
379                profile,
380            } => format!(
381                "epub-find:{index}:{pattern};profile={}",
382                profile.fingerprint()
383            ),
384        }
385    }
386
387    /// Whether this selector belongs to the format-neutral common vocabulary
388    /// (Phase 12.7). Common selectors dispatch through the detected format's
389    /// adapter; native selectors are first-class peers, never fallbacks.
390    pub fn is_common(&self) -> bool {
391        matches!(
392            self,
393            Selector::Metadata
394                | Selector::Text
395                | Selector::Heading(_)
396                | Selector::Block(_)
397                | Selector::Table(_)
398                | Selector::Cell { .. }
399                | Selector::Resource(_)
400                | Selector::Link(_)
401                | Selector::SearchMatch(_)
402        )
403    }
404}
405
406/// How the selected thing should be represented.
407#[derive(Debug, Clone, Copy, PartialEq, Eq)]
408pub enum Representation {
409    /// Document-level metadata.
410    Metadata,
411    /// Extracted text runs.
412    Text,
413    /// A structured page description.
414    Structure,
415    /// A decoded content operator stream.
416    Operators,
417    /// Exact encoded stream bytes.
418    EncodedBytes,
419    /// Decoded (inflated) stream bytes.
420    DecodedBytes,
421    /// Exact source bytes.
422    ExactBytes,
423    /// A deterministic structured page preview.
424    Preview,
425    /// The full source document.
426    FullDocument,
427}
428
429impl Representation {
430    /// Stable lower-case name.
431    pub const fn name(self) -> &'static str {
432        match self {
433            Representation::Metadata => "metadata",
434            Representation::Text => "text",
435            Representation::Structure => "structure",
436            Representation::Operators => "operators",
437            Representation::EncodedBytes => "encoded",
438            Representation::DecodedBytes => "decoded",
439            Representation::ExactBytes => "exact",
440            Representation::Preview => "preview",
441            Representation::FullDocument => "full",
442        }
443    }
444}
445
446/// Output bounds for one observation.
447#[derive(Debug, Clone, Copy)]
448pub struct ObserveBudget {
449    /// Maximum bytes the returned value may occupy.
450    pub max_output_bytes: u64,
451    /// Maximum seed nodes the observation may evaluate.
452    pub max_nodes: u64,
453}
454
455impl Default for ObserveBudget {
456    fn default() -> Self {
457        ObserveBudget {
458            max_output_bytes: 64 * 1024 * 1024,
459            max_nodes: 1 << 20,
460        }
461    }
462}
463
464/// One observation request.
465#[derive(Debug, Clone)]
466pub struct ObserveRequest {
467    /// What to observe.
468    pub selector: Selector,
469    /// How to represent it.
470    pub representation: Representation,
471    /// Output bounds.
472    pub budget: ObserveBudget,
473    /// Whether the disposable derived cache (11.8) may be consulted and filled.
474    /// `false` forces a cold, recompute-everything court.
475    pub use_cache: bool,
476}
477
478impl ObserveRequest {
479    /// A request with the default budget, caching enabled.
480    pub fn new(selector: Selector, representation: Representation) -> Self {
481        ObserveRequest {
482            selector,
483            representation,
484            budget: ObserveBudget::default(),
485            use_cache: true,
486        }
487    }
488}
489
490/// Which descriptor read path an observation took.
491#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
492pub enum DescriptorReadMode {
493    /// The whole `.voldoc` blob was read and parsed (the archival/full path).
494    #[default]
495    Full,
496    /// A seek-based partial read served only the record closure the query needs.
497    Partial,
498}
499
500impl DescriptorReadMode {
501    /// Stable lower-case name (used in EXPLAIN ANALYZE JSON).
502    pub const fn name(self) -> &'static str {
503        match self {
504            DescriptorReadMode::Full => "full",
505            DescriptorReadMode::Partial => "partial",
506        }
507    }
508}
509
510/// Statistics of one observation — the evidence surface (ADR-0027).
511///
512/// Peak RSS and CPU time are deliberately **not** claimed here: `std` exposes no
513/// portable CPU-time API, and peak RSS is a Linux-only `/proc` read that the court
514/// already measures externally (the court reads `Maximum resident set size` from
515/// `/usr/bin/time -v`). Claiming either in-process would add a platform-specific, easily-misread field for no gain.
516#[derive(Debug, Clone, Default, PartialEq, Eq)]
517pub struct ObserveStats {
518    /// Index entries consulted during the observation (the reference descent is
519    /// counted by the entries it returns).
520    pub index_nodes_read: u64,
521    /// Seed nodes fetched from the seed store.
522    pub seed_nodes_fetched: u64,
523    /// Seed nodes evaluated/materialized (including dependencies). With reuse
524    /// enabled this counts cache misses only, so it never exceeds
525    /// `seed_nodes_executed`.
526    pub seed_nodes_materialized: u64,
527    /// Seed nodes actually executed during this observation (cache misses).
528    pub seed_nodes_executed: u64,
529    /// Seed nodes served whole from the persisted derived cache (their subtrees
530    /// were not traversed).
531    pub seed_nodes_reused: u64,
532    /// Output bytes written to the derived cache during this observation.
533    pub cache_bytes_written: u64,
534    /// Content-shared representation fact (Phase 12.8): the number of seed nodes
535    /// this field's **ingest** found already present by content id, so it wrote
536    /// nothing for them. This is deliberately distinct from
537    /// [`Self::seed_nodes_reused`], which is a *work* fact about this
538    /// observation. Read from the manifest provenance; `0` for an older field.
539    pub nodes_id_shared: u64,
540    /// Resource blobs this field shares with an earlier document (Phase 12.8);
541    /// `0` when the field shares none. Read from the manifest provenance.
542    pub shared_resource_ids: u64,
543    /// Descriptor bytes physically fetched to open this observation's field.
544    /// For the full path this is the whole `.voldoc` blob; for the seek-based
545    /// partial path it is only the record closure the query needed (see
546    /// [`Self::descriptor_read_mode`]). It is **not** hidden behind `bytes_read`.
547    pub descriptor_bytes_read: u64,
548    /// Which descriptor read path produced [`Self::descriptor_bytes_read`]:
549    /// `full` for the whole-blob parse, `partial` for a seek-based closure read.
550    pub descriptor_read_mode: DescriptorReadMode,
551    /// Field-manifest bytes physically fetched.
552    pub manifest_bytes_read: u64,
553    /// Hierarchical-index-node bytes physically fetched.
554    pub index_bytes_read: u64,
555    /// Seed-node bytes physically fetched (`get_node` + `get_node_range`).
556    pub seed_bytes_read: u64,
557    /// Total **physical** bytes fetched by this observation:
558    /// `descriptor_bytes_read + manifest_bytes_read + index_bytes_read +
559    /// seed_bytes_read`. Unrelated to `bytes_returned` (the output size): a
560    /// narrow observation can return fewer bytes than it reads.
561    pub bytes_read: u64,
562    /// Bytes returned to the caller.
563    pub bytes_returned: u64,
564    /// Whether a Stage-C promotion (deepening) happened during this observation.
565    pub deepened: bool,
566    /// Decoded package members this observation required (Phase 12.7), counted
567    /// where the adapter resolves them. A member served whole from the persisted
568    /// cache still counts as required; [`Self::seed_nodes_reused`] tells you it was
569    /// not re-executed, so a warm observation can report the requirement without
570    /// claiming fresh work.
571    pub member_decodes: u64,
572    /// Materialization requests for **XML-derived model nodes**
573    /// (`PackageOpcModel`/`DocxModel`/`DocxStory`/`EpubModel`/`EpubContent`) at the
574    /// observation boundary (Phase 12.7). Same honest scope as [`Self::member_decodes`].
575    pub xml_parses: u64,
576    /// Wall-clock duration in microseconds.
577    pub wall_micros: u64,
578}
579
580/// A seed-store wrapper that counts node *fetches*. All physical bytes are
581/// accounted by the underlying store's [`crate::store::IoCounters`]; this wrapper
582/// only exposes the fetch count for `seed_nodes_fetched`. Generic over the seed
583/// substrate so a test can substitute a store that forbids enumeration.
584struct CountingSeedStore<S: SeedStore> {
585    inner: S,
586    gets: Cell<u64>,
587}
588
589impl<S: SeedStore> CountingSeedStore<S> {
590    fn new(inner: S) -> Self {
591        CountingSeedStore {
592            inner,
593            gets: Cell::new(0),
594        }
595    }
596
597    fn gets(&self) -> u64 {
598        self.gets.get()
599    }
600
601    fn note(&self) {
602        self.gets.set(self.gets.get() + 1);
603    }
604}
605
606impl<S: SeedStore> SeedStore for CountingSeedStore<S> {
607    fn put_node(&mut self, canonical: &[u8]) -> Result<NodeId> {
608        self.inner.put_node(canonical)
609    }
610
611    fn get_node(&self, id: &NodeId) -> Result<Vec<u8>> {
612        self.note();
613        self.inner.get_node(id)
614    }
615
616    fn get_node_range(&self, id: &NodeId, offset: u64, len: u64) -> Result<Vec<u8>> {
617        self.note();
618        self.inner.get_node_range(id, offset, len)
619    }
620
621    fn contains_node(&self, id: &NodeId) -> Result<bool> {
622        self.inner.contains_node(id)
623    }
624
625    fn list_nodes(&self) -> Result<Vec<(NodeId, u64)>> {
626        self.inner.list_nodes()
627    }
628}
629
630/// Observe one selector/representation pair.
631///
632/// Returns the answer, its [`ObserveStats`], and the **current/promoted** field
633/// id: the promoted id when a Stage-C deepen happened during this call, else the
634/// input id. A caller can chain the returned id to observe again without
635/// re-deepening.
636pub fn observe(
637    store: &mut FieldStore,
638    id: &FieldId,
639    req: &ObserveRequest,
640    limits: Limits,
641) -> Result<(FieldAnswer, ObserveStats, FieldId)> {
642    let started = Instant::now();
643    match narrow_probe(store, id, req)? {
644        // The target is served wholly from the disposable derived cache: the
645        // descriptor is never opened. The ordinary evaluation core still runs,
646        // against a trip-wire source, so the answer and every work counter are
647        // those of the normal path while `descriptor_bytes_read` stays zero.
648        NarrowProbe::Probed {
649            hit: true,
650            manifest,
651            carry,
652        } => {
653            let view = FieldView {
654                manifest: manifest.as_ref(),
655                id: *id,
656                open_io: IoSnapshot::default(),
657                source: &NO_SOURCE,
658                loader: None,
659                object_count: 0,
660                graph_ops: 0,
661                read_mode: DescriptorReadMode::Partial,
662            };
663            let (seeds, istore) = open_sub_stores(store)?;
664            observe_with_stores_pre(store, view, req, limits, started, seeds, istore, carry)
665        }
666        // The selector resolved from the manifest + hierarchical index, but the
667        // target is not cached: fall through to the normal path, reusing the
668        // manifest, the probe's physical bytes, and its resolved index entries
669        // so nothing is read a second time.
670        NarrowProbe::Probed {
671            hit: false,
672            manifest,
673            carry,
674        } => {
675            let opened = OpenedField::open_with_manifest(store, req, *manifest, limits)?;
676            observe_view_pre(store, opened.view(), req, limits, started, carry)
677        }
678        NarrowProbe::NotEligible => {
679            let opened = OpenedField::open(store, id, req, limits)?;
680            observe_view(store, opened.view(), req, limits, started)
681        }
682    }
683}
684
685/// A [`SourceServer`] that serves nothing. A fully cache-served observation must
686/// never call it; reaching it means the short-circuit admitted a request it
687/// could not answer from the cache, which is a hard internal invariant failure
688/// rather than a silent descriptor read.
689struct NoSource;
690
691static NO_SOURCE: NoSource = NoSource;
692
693impl SourceServer for NoSource {
694    fn serve_range(&self, _offset: u64, _len: u64, _limits: Limits) -> Result<Vec<u8>> {
695        Err(Error::internal_invariant(
696            "a cache-served observation attempted a descriptor range read",
697        ))
698    }
699
700    fn serve_document(&self, _limits: Limits) -> Result<Vec<u8>> {
701        Err(Error::internal_invariant(
702            "a cache-served observation attempted a descriptor document read",
703        ))
704    }
705}
706
707/// Hierarchical-index entries the cache-first probe already resolved, so the
708/// evaluation that follows never reads the same index nodes a second time.
709#[derive(Default)]
710struct PrefetchedIndex {
711    entries: Vec<(SelectorKey, Vec<IndexEntry>)>,
712}
713
714impl PrefetchedIndex {
715    fn insert(&mut self, key: SelectorKey, entries: Vec<IndexEntry>) {
716        self.entries.push((key, entries));
717    }
718
719    fn get(&self, key: &SelectorKey) -> Option<&Vec<IndexEntry>> {
720        self.entries.iter().find(|(k, _)| k == key).map(|(_, v)| v)
721    }
722}
723
724/// State the cache-first probe already produced, carried into the path that
725/// follows so nothing it fetched, resolved, or read is done twice.
726#[derive(Default)]
727struct ProbeCarry {
728    /// Physical bytes the probe fetched before the field was opened.
729    base_io: IoSnapshot,
730    /// Hierarchical-index entries the probe resolved.
731    prefetched: PrefetchedIndex,
732    /// The target's cache bytes, already integrity-checked by the probe, so the
733    /// evaluation core serves them without re-reading the cache file.
734    output: Option<(NodeId, Vec<u8>)>,
735}
736
737/// The outcome of the cache-first narrow probe.
738enum NarrowProbe {
739    /// The request is not one the short-circuit serves.
740    NotEligible,
741    /// The selector resolved without reading the descriptor. `hit` is whether
742    /// the target derived node is served by the disposable cache.
743    Probed {
744        hit: bool,
745        manifest: Box<FieldRoot>,
746        carry: ProbeCarry,
747    },
748}
749
750/// Resolve a narrow observation's target node from the field manifest and the
751/// hierarchical index **only** — never the descriptor — and report whether the
752/// disposable cache can serve it whole.
753///
754/// The target ids are computed with the same constructors `ingest`/`deepen` use
755/// ([`derived_nodes`], the `ContentOperators`/`PdfStreamDecoded` builders), so a
756/// hit means the *identical* node the normal path would materialize. A miss
757/// carries the manifest, the physical bytes the probe fetched, and the resolved
758/// index entries back to the normal path so a cold observation pays nothing
759/// extra.
760fn narrow_probe(store: &FieldStore, id: &FieldId, req: &ObserveRequest) -> Result<NarrowProbe> {
761    use Representation as R;
762    if !req.use_cache {
763        return Ok(NarrowProbe::NotEligible);
764    }
765    // The short-circuit is a further step of the seek-based *partial* lane: on a
766    // backend with no partial descriptor (EntropyFS) the honest label would be
767    // `full`, so leave that path unchanged.
768    if !store.supports_partial_descriptor() {
769        return Ok(NarrowProbe::NotEligible);
770    }
771    let cacheable = matches!(
772        (&req.selector, req.representation),
773        (Selector::Page(_), R::Text | R::Preview | R::Structure)
774            | (Selector::Stream(_), R::DecodedBytes | R::Operators)
775    );
776    if !cacheable {
777        return Ok(NarrowProbe::NotEligible);
778    }
779
780    let io_before = store.io().snapshot();
781    let manifest = store.get_field(id)?;
782    let mut prefetched = PrefetchedIndex::default();
783    if !manifest.has_index() {
784        // Nothing to resolve from; let the normal path produce its typed error.
785        let base_io = io_before.delta(&store.io().snapshot());
786        return Ok(NarrowProbe::Probed {
787            hit: false,
788            manifest: Box::new(manifest),
789            carry: ProbeCarry {
790                base_io,
791                prefetched,
792                output: None,
793            },
794        });
795    }
796
797    let istore = FsIndexStore::open_with_io(store.root(), store.io().handle())?;
798    let root = NodeId::from_bytes(manifest.index_root);
799    let seeds = store.seed_substrate();
800
801    // Compute the deterministic target `(id, max_output_bytes)`.
802    let target: Option<(NodeId, u64)> = match (&req.selector, req.representation) {
803        (Selector::Page(page), R::Text | R::Preview | R::Structure) => {
804            let key = SelectorKey::new(SEL_PAGE, *page);
805            let entries = lookup(&istore, &root, &key)?;
806            prefetched.insert(key, entries.clone());
807            match entries.first() {
808                Some(entry) => {
809                    let (ops, text, preview) = derived_nodes(*page, entry.node_id);
810                    // The short-circuit only applies once the whole derived chain
811                    // already exists, so the normal path cannot promote (deepen)
812                    // and the answer is a pure cache read.
813                    if seeds.contains_node(&ops.content_id())?
814                        && seeds.contains_node(&text.content_id())?
815                        && seeds.contains_node(&preview.content_id())?
816                    {
817                        let node = match req.representation {
818                            R::Preview | R::Structure => preview,
819                            _ => text,
820                        };
821                        Some((node.content_id(), node.limits.max_output_bytes))
822                    } else {
823                        None
824                    }
825                }
826                None => None,
827            }
828        }
829        (Selector::Stream(object), R::DecodedBytes | R::Operators) => {
830            let enc_key = SelectorKey::new(SEL_STREAM, *object);
831            let enc = lookup(&istore, &root, &enc_key)?;
832            prefetched.insert(enc_key, enc.clone());
833            let dec_key = SelectorKey::new(SEL_STREAM_DECODED, *object);
834            let dec = lookup(&istore, &root, &dec_key)?;
835            prefetched.insert(dec_key, dec.clone());
836            // The normal path requires a `SEL_STREAM` entry and, for a pure cache
837            // hit, an already-registered decoded node; otherwise it would deepen
838            // from the descriptor.
839            if enc.is_empty() {
840                None
841            } else {
842                match dec.first() {
843                    // The index entry's id *is* the decoded node's content id.
844                    // Every decoded node is built with `NodeLimits::DEFAULT`.
845                    Some(entry) if req.representation == R::DecodedBytes => Some((
846                        entry.node_id,
847                        crate::field::node::NodeLimits::DEFAULT.max_output_bytes,
848                    )),
849                    Some(entry) => {
850                        let node = SeedNode::new(
851                            NodeKind::ContentOperators,
852                            0,
853                            Vec::new(),
854                            vec![entry.node_id],
855                            "pdf:content-operators",
856                        );
857                        Some((node.content_id(), node.limits.max_output_bytes))
858                    }
859                    None => None,
860                }
861            }
862        }
863        _ => None,
864    };
865
866    // Read the target's cached bytes at most once here: a hit is served from
867    // this buffer, so neither the cache nor the descriptor is read again.
868    let (hit, output) = match target {
869        // Mirror `dag::materialize_inner`'s hit guard exactly: a cache error or
870        // an oversized entry is a miss, never a wrong answer.
871        Some((target_id, max_output_bytes)) => {
872            match DerivedCache::open(store.root().join("cache"))?.get(&target_id) {
873                Ok(Some(bytes)) if bytes.len() as u64 <= max_output_bytes => {
874                    (true, Some((target_id, bytes)))
875                }
876                _ => (false, None),
877            }
878        }
879        None => (false, None),
880    };
881    let base_io = io_before.delta(&store.io().snapshot());
882    Ok(NarrowProbe::Probed {
883        hit,
884        manifest: Box::new(manifest),
885        carry: ProbeCarry {
886            base_io,
887            prefetched,
888            output,
889        },
890    })
891}
892
893/// Observe against an already-opened [`OpenedField`], for callers that open once
894/// and both plan and evaluate (e.g. EXPLAIN ANALYZE).
895pub(crate) fn observe_opened(
896    store: &mut FieldStore,
897    opened: &OpenedField,
898    req: &ObserveRequest,
899    limits: Limits,
900) -> Result<(FieldAnswer, ObserveStats, FieldId)> {
901    let started = Instant::now();
902    observe_view(store, opened.view(), req, limits, started)
903}
904
905/// Observe against an **already-open** field, opening nothing extra.
906///
907/// This is the single-open entry point (review fix #2): [`crate::field::explain::explain_analyze`]
908/// opens the field once, plans against it, and then evaluates the observation
909/// here, so the descriptor blob is read exactly once per analysis instead of
910/// twice. Physical bytes are attributed from the store's I/O counters, so the
911/// bytes read to open `field` are still reported honestly.
912pub fn observe_with_field(
913    store: &mut FieldStore,
914    field: &Field,
915    req: &ObserveRequest,
916    limits: Limits,
917) -> Result<(FieldAnswer, ObserveStats, FieldId)> {
918    let started = Instant::now();
919    observe_view(store, FieldView::from_field(field), req, limits, started)
920}
921
922/// A descriptor opened for one observation: the full parse, or a seek-based
923/// partial loader when the request is narrow and the descriptor carries an op
924/// table. Both expose a [`FieldView`] over the same evaluation core.
925pub(crate) enum OpenedField {
926    /// The whole `.voldoc` blob was read and parsed.
927    Full(Box<Field>),
928    /// Only the record closure the observation needs will be read.
929    Partial(Box<PartialField>),
930}
931
932impl OpenedField {
933    /// Open the cheapest descriptor path admissible for `req`.
934    pub(crate) fn open(
935        store: &FieldStore,
936        id: &FieldId,
937        req: &ObserveRequest,
938        limits: Limits,
939    ) -> Result<OpenedField> {
940        if store.supports_partial_descriptor()
941            && partial_eligible(req)
942            && let Some(pf) = PartialField::try_open(store, id, limits)?
943        {
944            return Ok(OpenedField::Partial(Box::new(pf)));
945        }
946        Ok(OpenedField::Full(Box::new(Field::open(store, id, limits)?)))
947    }
948
949    /// Open the cheapest admissible path from an **already-read** manifest. The
950    /// manifest bytes are charged by the caller (the narrow probe counts them in
951    /// its `base_io`), so `open_io` here never re-reads them.
952    pub(crate) fn open_with_manifest(
953        store: &FieldStore,
954        req: &ObserveRequest,
955        manifest: FieldRoot,
956        limits: Limits,
957    ) -> Result<OpenedField> {
958        if store.supports_partial_descriptor()
959            && partial_eligible(req)
960            && let Some(pf) =
961                PartialField::finish_open(store, manifest.clone(), store.io().snapshot(), limits)?
962        {
963            return Ok(OpenedField::Partial(Box::new(pf)));
964        }
965        Ok(OpenedField::Full(Box::new(Field::open_after_manifest(
966            store,
967            manifest,
968            store.io().snapshot(),
969            limits,
970        )?)))
971    }
972
973    /// A view over this opened field for the evaluation core.
974    pub(crate) fn view(&self) -> FieldView<'_> {
975        match self {
976            OpenedField::Full(f) => FieldView::from_field(f),
977            OpenedField::Partial(p) => p.view(),
978        }
979    }
980
981    /// The field manifest.
982    pub(crate) fn manifest(&self) -> &FieldRoot {
983        match self {
984            OpenedField::Full(f) => f.manifest(),
985            OpenedField::Partial(p) => &p.manifest,
986        }
987    }
988}
989
990/// The metadata and source server one observation needs, independent of whether
991/// the descriptor was fully parsed or partially loaded.
992pub(crate) struct FieldView<'a> {
993    pub manifest: &'a FieldRoot,
994    pub id: FieldId,
995    pub open_io: IoSnapshot,
996    pub source: &'a dyn SourceServer,
997    /// The partial loader, when this view came from one, so the evaluation can
998    /// charge the bytes its record reads fetched.
999    pub loader: Option<&'a PartialDescriptor>,
1000    pub object_count: usize,
1001    pub graph_ops: usize,
1002    pub read_mode: DescriptorReadMode,
1003}
1004
1005impl<'a> FieldView<'a> {
1006    pub(crate) fn from_field(field: &'a Field) -> FieldView<'a> {
1007        let parsed = field.parsed();
1008        FieldView {
1009            manifest: field.manifest(),
1010            id: field.id(),
1011            open_io: field.open_io(),
1012            source: parsed,
1013            loader: None,
1014            object_count: parsed.descriptor.objects.len(),
1015            graph_ops: parsed.descriptor.program.ops.len(),
1016            read_mode: DescriptorReadMode::Full,
1017        }
1018    }
1019}
1020
1021/// A field opened through the seek-based partial descriptor loader.
1022pub(crate) struct PartialField {
1023    pub(crate) manifest: FieldRoot,
1024    pub(crate) id: FieldId,
1025    pub(crate) open_io: IoSnapshot,
1026    loader: PartialDescriptor,
1027}
1028
1029impl PartialField {
1030    /// Try to open `id` lazily. `Ok(None)` means the descriptor is ineligible
1031    /// (no op table, external objects, or a framing fault) and the caller must
1032    /// fall back to the full path.
1033    pub(crate) fn try_open(
1034        store: &FieldStore,
1035        id: &FieldId,
1036        limits: Limits,
1037    ) -> Result<Option<PartialField>> {
1038        let io_before = store.io().snapshot();
1039        let manifest = store.get_field(id)?;
1040        PartialField::finish_open(store, manifest, io_before, limits)
1041    }
1042
1043    /// The body of [`PartialField::try_open`] from an already-read manifest.
1044    ///
1045    /// `io_before` is the snapshot `open_io` is measured from: pass one taken
1046    /// *before* the manifest read to charge it to this open (the ordinary
1047    /// path), or one taken after it to charge it elsewhere (the narrow probe,
1048    /// which already counted the manifest in its `base_io`).
1049    pub(crate) fn finish_open(
1050        store: &FieldStore,
1051        manifest: FieldRoot,
1052        io_before: IoSnapshot,
1053        limits: Limits,
1054    ) -> Result<Option<PartialField>> {
1055        let descriptor_id = Id::from_bytes(manifest.descriptor_id);
1056        let Some(path) = store.descriptor_path(&descriptor_id) else {
1057            // The descriptor is not a filesystem file (EntropyFS backend): the
1058            // seek-based partial lane is unavailable, so fall back to the full
1059            // descriptor path.
1060            return Ok(None);
1061        };
1062        let loader = match PartialDescriptor::open(&path, limits)? {
1063            PartialLoad::Ready(l) => l,
1064            PartialLoad::Ineligible { bytes_read } => {
1065                // Charge the bytes the inspection did fetch before declining, so
1066                // the honest fallback is not under-counted.
1067                store.io().add_descriptor(bytes_read);
1068                return Ok(None);
1069            }
1070        };
1071        if loader.source_len() != manifest.source_len
1072            || loader.source_sha256() != manifest.source_sha256
1073        {
1074            return Err(Error::integrity_mismatch(
1075                "partial descriptor does not match its field manifest's declared source",
1076            ));
1077        }
1078        // The loader's physical bytes are charged to the descriptor class after
1079        // the observation completes (once, so the read *count* stays one), so the
1080        // open snapshot carries only the manifest read here.
1081        let open_io = io_before.delta(&store.io().snapshot());
1082        let id = manifest.content_id();
1083        Ok(Some(PartialField {
1084            id,
1085            manifest,
1086            open_io,
1087            loader: *loader,
1088        }))
1089    }
1090
1091    pub(crate) fn view(&self) -> FieldView<'_> {
1092        FieldView {
1093            manifest: &self.manifest,
1094            id: self.id,
1095            open_io: self.open_io,
1096            source: &self.loader,
1097            loader: Some(&self.loader),
1098            object_count: self.loader.object_count(),
1099            graph_ops: self.loader.graph_ops(),
1100            read_mode: DescriptorReadMode::Partial,
1101        }
1102    }
1103}
1104
1105/// Whether a request is served by the seek-based partial lane when available.
1106fn partial_eligible(req: &ObserveRequest) -> bool {
1107    use Representation as R;
1108    matches!(
1109        (&req.selector, req.representation),
1110        (Selector::ByteRange { .. }, R::ExactBytes)
1111            | (Selector::Object(_), R::ExactBytes | R::EncodedBytes)
1112            | (Selector::Revision(_), R::ExactBytes)
1113            | (Selector::Stream(_), R::EncodedBytes)
1114            | (Selector::Member(_), R::EncodedBytes | R::DecodedBytes)
1115            | (Selector::Page(_), R::Text | R::Preview | R::Structure)
1116    )
1117}
1118
1119/// Build the seed and index sub-stores, sharing the field store's I/O counters.
1120fn open_sub_stores(store: &FieldStore) -> Result<(CountingSeedStore<SeedSubstrate>, FsIndexStore)> {
1121    let io = store.io();
1122    let seeds = CountingSeedStore::new(store.seed_substrate());
1123    let istore = FsIndexStore::open_with_io(store.root(), io.handle())?;
1124    Ok((seeds, istore))
1125}
1126
1127fn observe_view<'a>(
1128    store: &'a mut FieldStore,
1129    view: FieldView<'a>,
1130    req: &ObserveRequest,
1131    limits: Limits,
1132    started: Instant,
1133) -> Result<(FieldAnswer, ObserveStats, FieldId)> {
1134    observe_view_pre(store, view, req, limits, started, ProbeCarry::default())
1135}
1136
1137/// [`observe_view`] with the cache-first probe's carried state: physical bytes it
1138/// already fetched, index entries it resolved, and integrity-checked cache bytes
1139/// — so the evaluation core never reads any of them a second time.
1140fn observe_view_pre<'a>(
1141    store: &'a mut FieldStore,
1142    view: FieldView<'a>,
1143    req: &ObserveRequest,
1144    limits: Limits,
1145    started: Instant,
1146    carry: ProbeCarry,
1147) -> Result<(FieldAnswer, ObserveStats, FieldId)> {
1148    let (seeds, istore) = open_sub_stores(store)?;
1149    observe_with_stores_pre(store, view, req, limits, started, seeds, istore, carry)
1150}
1151
1152/// Test-only wrapper over [`observe_with_stores_pre`] with no probe state, so a
1153/// test can supply a seed store wrapper that forbids enumeration.
1154#[cfg(test)]
1155fn observe_with_stores<'a, S: SeedStore>(
1156    store: &'a mut FieldStore,
1157    view: FieldView<'a>,
1158    req: &ObserveRequest,
1159    limits: Limits,
1160    started: Instant,
1161    seeds: CountingSeedStore<S>,
1162    istore: FsIndexStore,
1163) -> Result<(FieldAnswer, ObserveStats, FieldId)> {
1164    observe_with_stores_pre(
1165        store,
1166        view,
1167        req,
1168        limits,
1169        started,
1170        seeds,
1171        istore,
1172        ProbeCarry::default(),
1173    )
1174}
1175
1176/// The evaluation core. Takes explicit sub-stores so a test can supply a seed
1177/// store wrapper that forbids enumeration; `carry` is the cache-first probe's
1178/// already-counted bytes, resolved index entries, and cached target bytes.
1179#[allow(clippy::too_many_arguments)]
1180fn observe_with_stores_pre<'a, S: SeedStore>(
1181    store: &'a mut FieldStore,
1182    view: FieldView<'a>,
1183    req: &ObserveRequest,
1184    limits: Limits,
1185    started: Instant,
1186    seeds: CountingSeedStore<S>,
1187    istore: FsIndexStore,
1188    carry: ProbeCarry,
1189) -> Result<(FieldAnswer, ObserveStats, FieldId)> {
1190    let ProbeCarry {
1191        base_io,
1192        prefetched,
1193        output,
1194    } = carry;
1195    // Snapshot after the field is open: only the reads this observation performs
1196    // during evaluation are counted as deltas; the field-open bytes come from
1197    // `view.open_io` below so they cannot be dropped on the floor.
1198    let io_base = store.io().snapshot();
1199    let budget = EvalBudget {
1200        max_nodes: req.budget.max_nodes,
1201        ..EvalBudget::default()
1202    };
1203    let cache = DerivedCache::open(store.root().join("cache"))?;
1204    let field_id = view.id;
1205    let mut ctx = Ctx {
1206        store,
1207        manifest: view.manifest,
1208        source: view.source,
1209        loader: view.loader,
1210        open_io: view.open_io,
1211        object_count: view.object_count,
1212        graph_ops: view.graph_ops,
1213        read_mode: view.read_mode,
1214        seeds,
1215        istore,
1216        prefetched,
1217        prefetched_output: output,
1218        limits,
1219        budget,
1220        stats: ObserveStats::default(),
1221        use_cache: req.use_cache,
1222        cache,
1223        reuse: ReuseStats::default(),
1224        current_id: field_id,
1225    };
1226
1227    let answer = ctx.dispatch(req)?;
1228    let produced = answer.value.byte_len();
1229    if produced > req.budget.max_output_bytes {
1230        return Err(Error::resource_limit(format!(
1231            "observation produced {produced} bytes, exceeding the {}-byte budget",
1232            req.budget.max_output_bytes
1233        )));
1234    }
1235
1236    let mut stats = ctx.stats;
1237    // A partial loader reads its records lazily, so its physical bytes accrue
1238    // during dispatch; charge them once (one descriptor read *count*) before
1239    // closing the interval.
1240    if let Some(loader) = ctx.loader {
1241        ctx.store.io().add_descriptor(loader.bytes_read());
1242    }
1243    // Every physical byte fetched by this observation: the probe's `base_io`, the
1244    // field-open bytes, plus any additional reads (e.g. a Stage-C promotion)
1245    // performed during dispatch.
1246    let open = ctx.open_io;
1247    let extra = io_base.delta(&ctx.store.io().snapshot());
1248    stats.descriptor_bytes_read = base_io
1249        .descriptor_bytes
1250        .saturating_add(open.descriptor_bytes)
1251        .saturating_add(extra.descriptor_bytes);
1252    stats.descriptor_read_mode = ctx.read_mode;
1253    stats.manifest_bytes_read = base_io
1254        .manifest_bytes
1255        .saturating_add(open.manifest_bytes)
1256        .saturating_add(extra.manifest_bytes);
1257    stats.index_bytes_read = base_io.index_bytes.saturating_add(extra.index_bytes);
1258    stats.seed_bytes_read = base_io.seed_bytes.saturating_add(extra.seed_bytes);
1259    stats.bytes_read = stats
1260        .descriptor_bytes_read
1261        .saturating_add(stats.manifest_bytes_read)
1262        .saturating_add(stats.index_bytes_read)
1263        .saturating_add(stats.seed_bytes_read);
1264    stats.seed_nodes_fetched = ctx.seeds.gets();
1265    stats.seed_nodes_materialized = ctx.budget.nodes;
1266    stats.seed_nodes_executed = ctx.reuse.nodes_executed;
1267    stats.seed_nodes_reused = ctx.reuse.nodes_reused;
1268    stats.cache_bytes_written = ctx.reuse.cache_bytes_written;
1269    // Representation facts recorded at ingest (Phase 12.8): a same-id node is
1270    // *not* work reuse, so these are reported separately from `seed_nodes_reused`.
1271    stats.nodes_id_shared =
1272        crate::field::manifest::provenance_counter(view.manifest.provenance.as_str(), "id_shared");
1273    stats.shared_resource_ids =
1274        crate::field::manifest::provenance_counter(view.manifest.provenance.as_str(), "res_shared");
1275    stats.bytes_returned = produced;
1276    stats.wall_micros = started.elapsed().as_micros().min(u128::from(u64::MAX)) as u64;
1277    Ok((answer, stats, ctx.current_id))
1278}
1279
1280/// Observation execution context.
1281struct Ctx<'a, S: SeedStore> {
1282    store: &'a mut FieldStore,
1283    manifest: &'a FieldRoot,
1284    source: &'a dyn SourceServer,
1285    loader: Option<&'a PartialDescriptor>,
1286    open_io: IoSnapshot,
1287    object_count: usize,
1288    graph_ops: usize,
1289    read_mode: DescriptorReadMode,
1290    seeds: CountingSeedStore<S>,
1291    istore: FsIndexStore,
1292    /// Index entries the cache-first probe already resolved, keyed by selector.
1293    prefetched: PrefetchedIndex,
1294    /// The target's cache bytes, already read and integrity-checked by the probe.
1295    prefetched_output: Option<(NodeId, Vec<u8>)>,
1296    limits: Limits,
1297    budget: EvalBudget,
1298    stats: ObserveStats,
1299    use_cache: bool,
1300    cache: DerivedCache,
1301    reuse: ReuseStats,
1302    current_id: FieldId,
1303}
1304
1305/// A resolved DOCX story view: the parsed story model plus the provenance it is
1306/// bound to (backing part, dependency ids, and the exact compressed member span).
1307#[cfg(feature = "docx")]
1308struct DocxStoryView {
1309    model: StoryModel,
1310    part: DocxPartRef,
1311    deps: Vec<NodeId>,
1312    span: Option<(u64, u64)>,
1313}
1314
1315#[cfg(feature = "docx")]
1316fn opt_u8_json(v: Option<u8>) -> String {
1317    match v {
1318        Some(n) => n.to_string(),
1319        None => "null".to_string(),
1320    }
1321}
1322
1323#[cfg(feature = "docx")]
1324fn opt_str_json(v: Option<&str>) -> String {
1325    match v {
1326        Some(s) => format!("\"{}\"", json_escape(s)),
1327        None => "null".to_string(),
1328    }
1329}
1330
1331/// Parse an A1-style cell reference (`B7`) into a 0-based grid column and a
1332/// 0-based row index. Column letters are case-insensitive; row numbers are
1333/// 1-based and must be non-zero.
1334#[cfg(feature = "docx")]
1335fn parse_cell_ref(s: &str) -> Option<(u32, u32)> {
1336    let letters: String = s.chars().take_while(|c| c.is_ascii_alphabetic()).collect();
1337    let digits: String = s.chars().skip(letters.len()).collect();
1338    if letters.is_empty() || digits.is_empty() || digits.len() != s.len() - letters.len() {
1339        return None;
1340    }
1341    if !digits.chars().all(|c| c.is_ascii_digit()) {
1342        return None;
1343    }
1344    let mut col: u32 = 0;
1345    for c in letters.chars() {
1346        let v = c.to_ascii_uppercase() as u32 - 'A' as u32 + 1;
1347        col = col.checked_mul(26)?.checked_add(v)?;
1348    }
1349    let col = col.checked_sub(1)?;
1350    let row: u32 = digits.parse().ok()?;
1351    if row == 0 {
1352        return None;
1353    }
1354    Some((col, row - 1))
1355}
1356
1357impl<S: SeedStore> Ctx<'_, S> {
1358    fn materialize(&mut self, node: &SeedNode) -> Result<Vec<u8>> {
1359        // Observation-boundary accounting (Phase 12.7): a requested XML-derived
1360        // model node is charged its class. This counts *requests* (a cache-served
1361        // request is still a request); `seed_nodes_reused` reports whether the
1362        // underlying work was reused rather than re-executed. Decoded-member
1363        // requests are counted where the adapter resolves them.
1364        match node.kind {
1365            NodeKind::PackageOpcModel
1366            | NodeKind::DocxModel
1367            | NodeKind::DocxStory
1368            | NodeKind::EpubModel
1369            | NodeKind::EpubContent => {
1370                self.stats.xml_parses = self.stats.xml_parses.saturating_add(1);
1371            }
1372            _ => {}
1373        }
1374        let depth = node.limits.max_depth;
1375        if self.use_cache {
1376            // The cache-first probe may have already read and integrity-checked
1377            // this exact node's output. Serving it here is byte-identical to a
1378            // cache hit and avoids reading the entry a second time.
1379            if let Some((id, bytes)) = self.prefetched_output.take() {
1380                if id == node.content_id() {
1381                    self.reuse.nodes_reused = self.reuse.nodes_reused.saturating_add(1);
1382                    self.budget.charge_bytes(bytes.len() as u64)?;
1383                    return Ok(bytes);
1384                }
1385                self.prefetched_output = Some((id, bytes));
1386            }
1387            dag::materialize_node_cached_with(
1388                self.source,
1389                &self.seeds,
1390                &mut self.cache,
1391                node,
1392                self.limits,
1393                &mut self.budget,
1394                depth,
1395                &mut self.reuse,
1396            )
1397        } else {
1398            let mut cache = dag::NoCache;
1399            dag::materialize_node_cached_with(
1400                self.source,
1401                &self.seeds,
1402                &mut cache,
1403                node,
1404                self.limits,
1405                &mut self.budget,
1406                depth,
1407                &mut self.reuse,
1408            )
1409        }
1410    }
1411
1412    fn load(&self, id: &NodeId) -> Result<SeedNode> {
1413        dag::load_node(&self.seeds, id)
1414    }
1415
1416    fn lookup(&mut self, key: SelectorKey) -> Result<Vec<IndexEntry>> {
1417        if !self.manifest.has_index() {
1418            return Ok(Vec::new());
1419        }
1420        // A probe-resolved key is served from memory: the index nodes were read
1421        // (and charged) before the field opened, so reading them again would both
1422        // double the bytes and lie about the work. Counting the entries keeps
1423        // `index_nodes_read` identical to the normal path.
1424        let prefetched = self.prefetched.get(&key).cloned();
1425        let entries = match prefetched {
1426            Some(entries) => entries,
1427            None => {
1428                let root = NodeId::from_bytes(self.manifest.index_root);
1429                lookup(&self.istore, &root, &key)?
1430            }
1431        };
1432        self.stats.index_nodes_read += entries.len() as u64;
1433        Ok(entries)
1434    }
1435
1436    fn require_entry(&mut self, key: SelectorKey, what: &str) -> Result<IndexEntry> {
1437        let entries = self.lookup(key)?;
1438        entries.into_iter().next().ok_or_else(|| {
1439            Error::unsupported_feature(format!(
1440                "no {what} matching selector number {} in the observation index",
1441                key.number
1442            ))
1443        })
1444    }
1445
1446    fn dispatch(&mut self, req: &ObserveRequest) -> Result<FieldAnswer> {
1447        // The common vocabulary dispatches through the detected format's adapter
1448        // (Phase 12.7). Native selectors fall through to the format-specific match.
1449        if req.selector.is_common() {
1450            return self.common_dispatch(req);
1451        }
1452        use Representation as R;
1453        match (&req.selector, req.representation) {
1454            (Selector::Document, R::FullDocument | R::ExactBytes) => self.document_full(req),
1455            (Selector::Document, R::Metadata) => self.document_metadata(req),
1456            (Selector::ByteRange { offset, len }, R::ExactBytes) => {
1457                self.byte_range(req, *offset, *len)
1458            }
1459            (Selector::Object(n), R::ExactBytes | R::EncodedBytes) => {
1460                self.indexed_exact(req, SelectorKey::new(SEL_OBJECT, *n), "object")
1461            }
1462            (Selector::Revision(n), R::ExactBytes) => {
1463                self.indexed_exact(req, SelectorKey::new(SEL_REVISION, *n), "revision")
1464            }
1465            (Selector::Member(n), R::EncodedBytes) => self.indexed_exact(
1466                req,
1467                SelectorKey::new(SEL_PACKAGE_MEMBER_RAW, *n),
1468                "package member",
1469            ),
1470            (Selector::Member(n), R::DecodedBytes) => self.member_decoded(req, *n),
1471            (Selector::PackagePart(_), R::Metadata | R::ExactBytes | R::DecodedBytes) => {
1472                self.package_part_opc(req)
1473            }
1474            (Selector::Relationship(_), R::Metadata | R::ExactBytes | R::DecodedBytes) => {
1475                self.relationship_opc(req)
1476            }
1477            (Selector::Stream(n), R::EncodedBytes) => {
1478                self.indexed_exact(req, SelectorKey::new(SEL_STREAM, *n), "stream")
1479            }
1480            (Selector::Stream(n), R::DecodedBytes) => self.stream_decoded(req, *n),
1481            (Selector::Stream(n), R::Operators) => self.stream_operators(req, *n),
1482            (Selector::Page(n), R::Text) => self.page_text(req, *n),
1483            (Selector::Page(n), R::Preview) => self.page_preview(req, *n),
1484            (Selector::Page(n), R::Structure) => self.page_structure(req, *n),
1485            (Selector::TextMatch(p), R::Text) => self.text_match(req, p),
1486            #[cfg(feature = "docx")]
1487            (Selector::DocxStory { story, profile }, R::Text) => {
1488                self.docx_story_text(req, *story, profile)
1489            }
1490            #[cfg(feature = "docx")]
1491            (Selector::DocxStory { story, profile }, R::Structure) => {
1492                self.docx_story_structure(req, *story, profile)
1493            }
1494            #[cfg(feature = "docx")]
1495            (Selector::DocxStory { story, profile }, R::Metadata) => {
1496                self.docx_story_metadata(req, *story, profile)
1497            }
1498            #[cfg(feature = "docx")]
1499            (
1500                Selector::DocxParagraph {
1501                    story,
1502                    index,
1503                    profile,
1504                },
1505                R::Text | R::Metadata,
1506            ) => self.docx_paragraph(req, *story, *index, profile),
1507            #[cfg(feature = "docx")]
1508            (
1509                Selector::DocxTable {
1510                    story,
1511                    index,
1512                    profile,
1513                },
1514                R::Text | R::Metadata,
1515            ) => self.docx_table(req, *story, *index, profile),
1516            #[cfg(feature = "docx")]
1517            (
1518                Selector::DocxCell {
1519                    story,
1520                    table,
1521                    cell,
1522                    profile,
1523                },
1524                R::Text | R::Metadata,
1525            ) => self.docx_cell(req, *story, *table, cell, profile),
1526            #[cfg(feature = "docx")]
1527            (
1528                Selector::DocxFind {
1529                    story,
1530                    pattern,
1531                    profile,
1532                },
1533                R::Text,
1534            ) => self.docx_find(req, *story, pattern, profile),
1535            #[cfg(feature = "epub")]
1536            (Selector::EpubPackage, R::Metadata | R::Structure) => self.epub_package(req),
1537            #[cfg(feature = "epub")]
1538            (Selector::EpubManifestItem { index }, R::Metadata) => {
1539                self.epub_manifest_item_meta(req, *index)
1540            }
1541            #[cfg(feature = "epub")]
1542            (Selector::EpubManifestItem { index }, R::ExactBytes | R::DecodedBytes) => {
1543                self.epub_manifest_item_bytes(req, *index)
1544            }
1545            #[cfg(feature = "epub")]
1546            (Selector::EpubSpineItem { index, profile }, R::Metadata) => {
1547                self.epub_spine_item_meta(req, *index, profile)
1548            }
1549            #[cfg(feature = "epub")]
1550            (Selector::EpubSpineItem { index, profile }, R::Text) => {
1551                self.epub_spine_item_text(req, *index, profile)
1552            }
1553            #[cfg(feature = "epub")]
1554            (Selector::EpubSpineItem { index, profile }, R::Structure) => {
1555                self.epub_spine_item_structure(req, *index, profile)
1556            }
1557            #[cfg(feature = "epub")]
1558            (Selector::EpubSpineItem { index, profile }, R::Preview) => {
1559                self.epub_spine_item_preview(req, *index, profile)
1560            }
1561            #[cfg(feature = "epub")]
1562            (Selector::EpubSpineItem { index, profile }, R::ExactBytes | R::DecodedBytes) => {
1563                self.epub_spine_item_bytes(req, *index, profile)
1564            }
1565            #[cfg(feature = "epub")]
1566            (Selector::EpubNav, R::Metadata | R::Structure) => self.epub_nav(req),
1567            #[cfg(feature = "epub")]
1568            (Selector::EpubNavNode { index }, R::Metadata) => self.epub_nav_node(req, *index),
1569            #[cfg(feature = "epub")]
1570            (Selector::EpubResource(name), R::Metadata) => self.epub_resource_meta(req, name),
1571            #[cfg(feature = "epub")]
1572            (Selector::EpubResource(name), R::ExactBytes | R::DecodedBytes) => {
1573                self.epub_resource_bytes(req, name)
1574            }
1575            #[cfg(feature = "epub")]
1576            (
1577                Selector::EpubBlock {
1578                    index,
1579                    block,
1580                    profile,
1581                },
1582                R::Text | R::Metadata | R::Structure,
1583            ) => self.epub_block(req, *index, *block, profile),
1584            #[cfg(feature = "epub")]
1585            (
1586                Selector::EpubCell {
1587                    index,
1588                    table,
1589                    row,
1590                    col,
1591                    profile,
1592                },
1593                R::Text | R::Metadata,
1594            ) => self.epub_cell(req, *index, *table, *row, *col, profile),
1595            #[cfg(feature = "epub")]
1596            (
1597                Selector::EpubLink {
1598                    index,
1599                    link,
1600                    profile,
1601                },
1602                R::Metadata,
1603            ) => self.epub_link(req, *index, *link, profile),
1604            #[cfg(feature = "epub")]
1605            (
1606                Selector::EpubFind {
1607                    index,
1608                    pattern,
1609                    profile,
1610                },
1611                R::Text,
1612            ) => self.epub_find(req, *index, pattern, profile),
1613            _ => Err(Error::unsupported_feature(format!(
1614                "unsupported observation: selector {} with representation {}",
1615                req.selector.canonical(),
1616                req.representation.name()
1617            ))),
1618        }
1619    }
1620
1621    fn document_full(&mut self, req: &ObserveRequest) -> Result<FieldAnswer> {
1622        let bytes = self.source.serve_document(self.limits)?;
1623        Ok(FieldAnswer {
1624            value: AnswerValue::Bytes(bytes),
1625            basis: Basis::DirectlyObserved,
1626            selector: req.selector.canonical(),
1627            representation: req.representation.name().to_string(),
1628            source_span: Some((0, self.manifest.source_len)),
1629            provenance: String::new(),
1630            dependency_ids: vec![self.manifest.root_node],
1631            integrity_scope: IntegrityScope::WholeSource,
1632            exact: true,
1633        })
1634    }
1635
1636    fn document_metadata(&mut self, req: &ObserveRequest) -> Result<FieldAnswer> {
1637        let json = format!(
1638            concat!(
1639                "{{",
1640                "\"source_len\":{},",
1641                "\"source_sha256\":\"{}\",",
1642                "\"object_count\":{},",
1643                "\"graph_ops\":{},",
1644                "\"node_count\":{}",
1645                "}}"
1646            ),
1647            self.manifest.source_len,
1648            crate::integrity::to_hex(&self.manifest.source_sha256),
1649            self.object_count,
1650            self.graph_ops,
1651            self.manifest.node_count,
1652        );
1653        Ok(FieldAnswer {
1654            value: AnswerValue::Json(json),
1655            basis: Basis::DeterministicallyDerived,
1656            selector: req.selector.canonical(),
1657            representation: req.representation.name().to_string(),
1658            source_span: None,
1659            provenance: String::new(),
1660            dependency_ids: Vec::new(),
1661            integrity_scope: IntegrityScope::None,
1662            exact: false,
1663        })
1664    }
1665
1666    fn byte_range(&mut self, req: &ObserveRequest, offset: u64, len: u64) -> Result<FieldAnswer> {
1667        let end = offset
1668            .checked_add(len)
1669            .ok_or_else(|| Error::usage("byte-range end overflows"))?;
1670        let node = SeedNode::new(
1671            NodeKind::SourceSlice,
1672            len,
1673            span_params(offset, len),
1674            Vec::new(),
1675            "field:observe;source-slice",
1676        );
1677        let bytes = self.materialize(&node)?;
1678        Ok(FieldAnswer {
1679            value: AnswerValue::Bytes(bytes),
1680            basis: Basis::DirectlyObserved,
1681            selector: req.selector.canonical(),
1682            representation: req.representation.name().to_string(),
1683            source_span: Some((offset, end)),
1684            provenance: String::new(),
1685            dependency_ids: Vec::new(),
1686            integrity_scope: IntegrityScope::Node,
1687            exact: true,
1688        })
1689    }
1690
1691    fn indexed_exact(
1692        &mut self,
1693        req: &ObserveRequest,
1694        key: SelectorKey,
1695        what: &str,
1696    ) -> Result<FieldAnswer> {
1697        let entry = self.require_entry(key, what)?;
1698        let node = self.load(&entry.node_id)?;
1699        let bytes = self.materialize(&node)?;
1700        let end = entry.out_off.saturating_add(entry.out_len);
1701        Ok(FieldAnswer {
1702            value: AnswerValue::Bytes(bytes),
1703            basis: Basis::DirectlyObserved,
1704            selector: req.selector.canonical(),
1705            representation: req.representation.name().to_string(),
1706            source_span: Some((entry.out_off, end)),
1707            provenance: String::new(),
1708            dependency_ids: vec![entry.node_id],
1709            integrity_scope: IntegrityScope::Node,
1710            exact: true,
1711        })
1712    }
1713
1714    fn stream_decoded(&mut self, req: &ObserveRequest, object: u32) -> Result<FieldAnswer> {
1715        let entry = self.require_entry(SelectorKey::new(SEL_STREAM, object), "stream")?;
1716        let node = self.decoded_node(object, &entry.node_id)?;
1717        let id = node.content_id();
1718        let bytes = self.materialize(&node)?;
1719        Ok(FieldAnswer {
1720            value: AnswerValue::Bytes(bytes),
1721            basis: Basis::DeterministicallyDerived,
1722            selector: req.selector.canonical(),
1723            representation: req.representation.name().to_string(),
1724            source_span: None,
1725            provenance: String::new(),
1726            dependency_ids: vec![id],
1727            integrity_scope: IntegrityScope::None,
1728            exact: false,
1729        })
1730    }
1731
1732    /// A package member's decoded bytes (Phase 12.2).
1733    ///
1734    /// Resolved through the index to the `PackageMemberDecoded` node, which is a
1735    /// deterministic function of its raw node, so the observation never enumerates
1736    /// the seed store. The answer is `DeterministicallyDerived`, never exact: it is
1737    /// not a byte-identical observation of the source.
1738    fn member_decoded(&mut self, req: &ObserveRequest, ordinal: u32) -> Result<FieldAnswer> {
1739        let entry = self.require_entry(
1740            SelectorKey::new(SEL_PACKAGE_MEMBER_DECODED, ordinal),
1741            "decoded package member",
1742        )?;
1743        let node = self.load(&entry.node_id)?;
1744        self.stats.member_decodes = self.stats.member_decodes.saturating_add(1);
1745        let id = node.content_id();
1746        let raw_deps = node.deps.clone();
1747        let bytes = self.materialize(&node)?;
1748        let mut dependency_ids = vec![id];
1749        dependency_ids.extend(raw_deps);
1750        Ok(FieldAnswer {
1751            value: AnswerValue::Bytes(bytes),
1752            basis: Basis::DeterministicallyDerived,
1753            selector: req.selector.canonical(),
1754            representation: req.representation.name().to_string(),
1755            source_span: None,
1756            provenance: String::new(),
1757            dependency_ids,
1758            integrity_scope: IntegrityScope::None,
1759            exact: false,
1760        })
1761    }
1762
1763    /// Materialize and decode the generic OPC model (derived, `Q_gen`).
1764    #[cfg(feature = "opc")]
1765    fn opc_model(&mut self) -> Result<crate::adapter::package::opc::OpcModel> {
1766        let entry = self.require_entry(SelectorKey::new(SEL_OPC_MODEL, 0), "OPC model")?;
1767        let node = self.load(&entry.node_id)?;
1768        let bytes = self.materialize(&node)?;
1769        crate::adapter::package::opc::OpcModel::decode(&bytes)
1770    }
1771
1772    /// A generic OPC part observation (Phase 12.3): exact/decoded bytes resolve
1773    /// through the OPC part's physical member ordinal, so the exact leaf stays the
1774    /// 12.2 raw member span. Metadata is derived (`Q_gen`).
1775    #[cfg(feature = "opc")]
1776    fn package_part_opc(&mut self, req: &ObserveRequest) -> Result<FieldAnswer> {
1777        use Representation as R;
1778        let Selector::PackagePart(name) = &req.selector else {
1779            return Err(Error::internal_invariant(
1780                "package_part_opc needs PackagePart",
1781            ));
1782        };
1783        let name = name.clone();
1784        let model = self.opc_model()?;
1785        let part = model.part_by_name(&name).ok_or_else(|| {
1786            Error::invalid_package_structure(format!("no package part named {name:?}"))
1787        })?;
1788        let ordinal = part.ordinal;
1789        match req.representation {
1790            R::ExactBytes => self.indexed_exact(
1791                req,
1792                SelectorKey::new(SEL_PACKAGE_MEMBER_RAW, ordinal),
1793                "package part",
1794            ),
1795            R::DecodedBytes => self.member_decoded(req, ordinal),
1796            R::Metadata => {
1797                let rel_count = model
1798                    .part_rels
1799                    .iter()
1800                    .find(|(o, _)| *o == ordinal)
1801                    .map_or(0, |(_, r)| r.len());
1802                let ct = match &part.content_type {
1803                    Some(c) => format!("\"{}\"", json_escape(c)),
1804                    None => "null".to_string(),
1805                };
1806                let json = format!(
1807                    "{{\"name\":\"{}\",\"ordinal\":{},\"content_type\":{},\"relationships\":{}}}",
1808                    json_escape(&part.name),
1809                    ordinal,
1810                    ct,
1811                    rel_count
1812                );
1813                Ok(FieldAnswer {
1814                    value: AnswerValue::Json(json),
1815                    basis: Basis::DeterministicallyDerived,
1816                    selector: req.selector.canonical(),
1817                    representation: req.representation.name().to_string(),
1818                    source_span: None,
1819                    provenance: String::new(),
1820                    dependency_ids: Vec::new(),
1821                    integrity_scope: IntegrityScope::None,
1822                    exact: false,
1823                })
1824            }
1825            _ => Err(Error::unsupported_feature(format!(
1826                "unsupported observation: selector {} with representation {}",
1827                req.selector.canonical(),
1828                req.representation.name()
1829            ))),
1830        }
1831    }
1832
1833    /// A generic OPC relationship observation (Phase 12.3). For an internal
1834    /// relationship, `ExactBytes`/`DecodedBytes` resolve to the target part's member
1835    /// bytes. An external relationship is an inert identifier: asking for its bytes
1836    /// is a typed decline, never a fetch.
1837    #[cfg(feature = "opc")]
1838    fn relationship_opc(&mut self, req: &ObserveRequest) -> Result<FieldAnswer> {
1839        use Representation as R;
1840        let Selector::Relationship(id) = &req.selector else {
1841            return Err(Error::internal_invariant(
1842                "relationship_opc needs Relationship",
1843            ));
1844        };
1845        let id = id.clone();
1846        let model = self.opc_model()?;
1847        let (rel, owner) = model.relationship_by_id(&id)?.ok_or_else(|| {
1848            Error::invalid_package_structure(format!("no package relationship with id {id:?}"))
1849        })?;
1850        match req.representation {
1851            R::Metadata => {
1852                let resolved = match &rel.resolved {
1853                    Some(r) => format!("\"{}\"", json_escape(r)),
1854                    None => "null".to_string(),
1855                };
1856                let owner_json = match owner {
1857                    Some(o) => o.to_string(),
1858                    None => "null".to_string(),
1859                };
1860                let json = format!(
1861                    concat!(
1862                        "{{\"id\":\"{}\",\"type\":\"{}\",\"target\":\"{}\",",
1863                        "\"target_mode\":\"{}\",\"resolved\":{},\"owner\":{}}}"
1864                    ),
1865                    json_escape(&rel.id),
1866                    json_escape(&rel.rel_type),
1867                    json_escape(&rel.target),
1868                    rel.mode.name(),
1869                    resolved,
1870                    owner_json
1871                );
1872                Ok(FieldAnswer {
1873                    value: AnswerValue::Json(json),
1874                    basis: Basis::DeterministicallyDerived,
1875                    selector: req.selector.canonical(),
1876                    representation: req.representation.name().to_string(),
1877                    source_span: None,
1878                    provenance: String::new(),
1879                    dependency_ids: Vec::new(),
1880                    integrity_scope: IntegrityScope::None,
1881                    exact: false,
1882                })
1883            }
1884            R::ExactBytes | R::DecodedBytes => {
1885                let resolved = rel.resolved.clone().ok_or_else(|| {
1886                    Error::invalid_package_structure(format!(
1887                        "relationship {id:?} is external: its target is an inert identifier, never fetched"
1888                    ))
1889                })?;
1890                let part = model.part_by_name(&resolved).ok_or_else(|| {
1891                    Error::invalid_package_structure(format!(
1892                        "relationship {id:?} target {resolved:?} is not a package part"
1893                    ))
1894                })?;
1895                let ordinal = part.ordinal;
1896                if req.representation == R::ExactBytes {
1897                    self.indexed_exact(
1898                        req,
1899                        SelectorKey::new(SEL_PACKAGE_MEMBER_RAW, ordinal),
1900                        "relationship target part",
1901                    )
1902                } else {
1903                    self.member_decoded(req, ordinal)
1904                }
1905            }
1906            _ => Err(Error::unsupported_feature(format!(
1907                "unsupported observation: selector {} with representation {}",
1908                req.selector.canonical(),
1909                req.representation.name()
1910            ))),
1911        }
1912    }
1913
1914    /// Non-OPC builds keep the selector surface stable but fail closed.
1915    #[cfg(not(feature = "opc"))]
1916    fn package_part_opc(&mut self, _req: &ObserveRequest) -> Result<FieldAnswer> {
1917        Err(Error::unsupported_feature(
1918            "OPC support is not compiled in (feature `opc`)",
1919        ))
1920    }
1921
1922    /// Non-OPC builds keep the selector surface stable but fail closed.
1923    #[cfg(not(feature = "opc"))]
1924    fn relationship_opc(&mut self, _req: &ObserveRequest) -> Result<FieldAnswer> {
1925        Err(Error::unsupported_feature(
1926            "OPC support is not compiled in (feature `opc`)",
1927        ))
1928    }
1929
1930    fn stream_operators(&mut self, req: &ObserveRequest, object: u32) -> Result<FieldAnswer> {
1931        let entry = self.require_entry(SelectorKey::new(SEL_STREAM, object), "stream")?;
1932        let decoded = self.decoded_node(object, &entry.node_id)?;
1933        let decoded_id = decoded.content_id();
1934        let node = SeedNode::new(
1935            NodeKind::ContentOperators,
1936            0,
1937            Vec::new(),
1938            vec![decoded_id],
1939            "pdf:content-operators",
1940        );
1941        let id = node.content_id();
1942        let bytes = self.materialize(&node)?;
1943        Ok(FieldAnswer {
1944            value: AnswerValue::Bytes(bytes),
1945            basis: Basis::DeterministicallyDerived,
1946            selector: req.selector.canonical(),
1947            representation: req.representation.name().to_string(),
1948            source_span: None,
1949            provenance: String::new(),
1950            dependency_ids: vec![id, decoded_id],
1951            integrity_scope: IntegrityScope::None,
1952            exact: false,
1953        })
1954    }
1955
1956    fn page_content_id(&mut self, what_number: u32) -> Result<NodeId> {
1957        Ok(self
1958            .require_entry(SelectorKey::new(SEL_PAGE, what_number), "page")?
1959            .node_id)
1960    }
1961
1962    /// Ensure the page's derived chain exists, deepening once if needed.
1963    ///
1964    /// Idempotent by content id: the three derived nodes have deterministic ids,
1965    /// so this **computes** them and does an O(1) `contains_node` check per id —
1966    /// it never enumerates the seed store. If they are already present the
1967    /// promotion is skipped entirely and no new field id is needed, which is what
1968    /// makes a repeated observation of the same page cheap.
1969    fn ensure_page_derived(
1970        &mut self,
1971        page: u32,
1972        page_content: NodeId,
1973    ) -> Result<(SeedNode, SeedNode, SeedNode)> {
1974        let (ops, text, preview) = derived_nodes(page, page_content);
1975        let present = self.seeds.contains_node(&ops.content_id())?
1976            && self.seeds.contains_node(&text.content_id())?
1977            && self.seeds.contains_node(&preview.content_id())?;
1978        if !present {
1979            // Promote against the manifest we already hold, so the descriptor
1980            // blob is not re-read just to learn the current manifest (fix #2).
1981            let promoted = ingest::deepen_page_with_manifest(self.store, self.manifest, page)?;
1982            self.stats.deepened = true;
1983            self.current_id = promoted;
1984        }
1985        Ok((ops, text, preview))
1986    }
1987
1988    fn page_text(&mut self, req: &ObserveRequest, page: u32) -> Result<FieldAnswer> {
1989        let pc = self.page_content_id(page)?;
1990        let (ops, text, _preview) = self.ensure_page_derived(page, pc)?;
1991        let text_id = text.content_id();
1992        let bytes = self.materialize(&text)?;
1993        let value = AnswerValue::Text(String::from_utf8_lossy(&bytes).into_owned());
1994        Ok(FieldAnswer {
1995            value,
1996            basis: Basis::Heuristic,
1997            selector: req.selector.canonical(),
1998            representation: req.representation.name().to_string(),
1999            source_span: None,
2000            provenance: String::new(),
2001            dependency_ids: vec![text_id, ops.content_id(), pc],
2002            integrity_scope: IntegrityScope::None,
2003            exact: false,
2004        })
2005    }
2006
2007    fn page_preview(&mut self, req: &ObserveRequest, page: u32) -> Result<FieldAnswer> {
2008        let pc = self.page_content_id(page)?;
2009        let (_ops, _text, preview) = self.ensure_page_derived(page, pc)?;
2010        let preview_id = preview.content_id();
2011        let bytes = self.materialize(&preview)?;
2012        Ok(FieldAnswer {
2013            value: AnswerValue::Bytes(bytes),
2014            basis: Basis::Heuristic,
2015            selector: req.selector.canonical(),
2016            representation: req.representation.name().to_string(),
2017            source_span: None,
2018            provenance: String::new(),
2019            dependency_ids: vec![preview_id, pc],
2020            integrity_scope: IntegrityScope::None,
2021            exact: false,
2022        })
2023    }
2024
2025    /// Selective late materialization: read only the page's content streams and
2026    /// preview node. Image/XObject bytes and the full document are never touched.
2027    fn page_structure(&mut self, req: &ObserveRequest, page: u32) -> Result<FieldAnswer> {
2028        let pc = self.page_content_id(page)?;
2029        let (_ops, _text, preview) = self.ensure_page_derived(page, pc)?;
2030        let preview_id = preview.content_id();
2031        let preview_bytes = self.materialize(&preview)?;
2032        let (text_bytes, draw_ops, path_ops) = preview_stats(&preview_bytes);
2033
2034        // The page's content streams are the direct dependencies of its
2035        // `PageContent` node; their `u32` params are the content object numbers.
2036        let pc_node = self.load(&pc)?;
2037        let mut streams: Vec<u32> = Vec::new();
2038        for dep in &pc_node.deps {
2039            let dep_node = self.load(dep)?;
2040            // Only a stream node's params are a content object number. An edited
2041            // page's `PageContent` may depend on a raw `Literal` whose params *are*
2042            // the content bytes, which must never be read as an object number.
2043            if !matches!(
2044                dep_node.kind,
2045                NodeKind::PdfStreamDecoded | NodeKind::PdfStreamEncoded
2046            ) {
2047                continue;
2048            }
2049            if let Ok(object) = read_u32_params(&dep_node.params) {
2050                streams.push(object);
2051            }
2052        }
2053        let streams_json = streams
2054            .iter()
2055            .map(u32::to_string)
2056            .collect::<Vec<_>>()
2057            .join(",");
2058
2059        let json = format!(
2060            "{{\"page\":{page},\"text_bytes\":{text_bytes},\"draw_ops\":{draw_ops},\"path_ops\":{path_ops},\"content_streams\":[{streams_json}]}}"
2061        );
2062        Ok(FieldAnswer {
2063            value: AnswerValue::Json(json),
2064            basis: Basis::DeterministicallyDerived,
2065            selector: req.selector.canonical(),
2066            representation: req.representation.name().to_string(),
2067            source_span: None,
2068            provenance: String::new(),
2069            dependency_ids: vec![preview_id, pc],
2070            integrity_scope: IntegrityScope::None,
2071            exact: false,
2072        })
2073    }
2074
2075    fn text_match(&mut self, req: &ObserveRequest, pattern: &str) -> Result<FieldAnswer> {
2076        let mut items: Vec<(u32, String)> = Vec::new();
2077        let mut estimated: u64 = 0;
2078        let mut page: u32 = 1;
2079        while page <= MAX_TEXTMATCH_PAGES {
2080            let entries = self.lookup(SelectorKey::new(SEL_PAGE, page))?;
2081            let Some(entry) = entries.into_iter().next() else {
2082                break;
2083            };
2084            let pc = entry.node_id;
2085            let (_ops, text, _preview) = self.ensure_page_derived(page, pc)?;
2086            let bytes = self.materialize(&text)?;
2087            let rendered = String::from_utf8_lossy(&bytes).into_owned();
2088            for line in rendered.split('\n') {
2089                if line.contains(pattern) {
2090                    estimated = estimated.saturating_add(line.len() as u64 + 32);
2091                    if estimated > req.budget.max_output_bytes {
2092                        return Err(Error::resource_limit(format!(
2093                            "text match exceeded the {}-byte budget",
2094                            req.budget.max_output_bytes
2095                        )));
2096                    }
2097                    items.push((page, line.to_string()));
2098                }
2099            }
2100            page += 1;
2101        }
2102        let body = items
2103            .iter()
2104            .map(|(p, line)| format!("{{\"page\":{p},\"line\":\"{}\"}}", json_escape(line)))
2105            .collect::<Vec<_>>()
2106            .join(",");
2107        Ok(FieldAnswer {
2108            value: AnswerValue::Json(format!("[{body}]")),
2109            basis: Basis::Heuristic,
2110            selector: req.selector.canonical(),
2111            representation: req.representation.name().to_string(),
2112            source_span: None,
2113            provenance: String::new(),
2114            dependency_ids: Vec::new(),
2115            integrity_scope: IntegrityScope::None,
2116            exact: false,
2117        })
2118    }
2119
2120    // -- DOCX (Phase 12.4) --------------------------------------------------
2121
2122    /// Materialize and decode the DOCX discovery model (derived, `Q_gen`).
2123    #[cfg(feature = "docx")]
2124    fn docx_model(&mut self) -> Result<DocxModel> {
2125        let entry = self.require_entry(SelectorKey::new(SEL_DOCX_MODEL, 0), "DOCX model")?;
2126        let node = self.load(&entry.node_id)?;
2127        let bytes = self.materialize(&node)?;
2128        DocxModel::decode(&bytes)
2129    }
2130
2131    /// Resolve one story to its parsed [`StoryModel`], parsing **only** that
2132    /// story's part (plus the shared styles part) and persisting the canonical
2133    /// result in the derived cache. A story is never silently mixed with another.
2134    #[cfg(feature = "docx")]
2135    fn docx_story_view(
2136        &mut self,
2137        story: DocxStory,
2138        profile: &DocxExtractProfile,
2139    ) -> Result<DocxStoryView> {
2140        if story.kind_index().is_none() {
2141            return Err(Error::unsupported_feature(format!(
2142                "DOCX story {} is declared but not part-backed; preserved exactly, not interpreted",
2143                story.name()
2144            )));
2145        }
2146        let model = self.docx_model()?;
2147        let part = model.story_part(story).cloned().ok_or_else(|| {
2148            Error::unsupported_feature(format!(
2149                "DOCX package has no part for story {}",
2150                story.name()
2151            ))
2152        })?;
2153        let dec = self.require_entry(
2154            SelectorKey::new(SEL_PACKAGE_MEMBER_DECODED, part.ordinal),
2155            "DOCX story part decoded bytes",
2156        )?;
2157        let mut deps = vec![dec.node_id];
2158        if let Some(styles) = &model.styles
2159            && let Ok(e) = self.require_entry(
2160                SelectorKey::new(SEL_PACKAGE_MEMBER_DECODED, styles.ordinal),
2161                "DOCX styles decoded bytes",
2162            )
2163        {
2164            deps.push(e.node_id);
2165        }
2166        // The observation requires these decoded members; a cache-served decode is
2167        // still a required decoded member, and `seed_nodes_reused` reports reuse.
2168        self.stats.member_decodes = self.stats.member_decodes.saturating_add(deps.len() as u64);
2169        let span = self
2170            .lookup(SelectorKey::new(SEL_PACKAGE_MEMBER_RAW, part.ordinal))?
2171            .into_iter()
2172            .next()
2173            .map(|e| (e.out_off, e.out_off.saturating_add(e.out_len)));
2174        let mut node = SeedNode::new(
2175            NodeKind::DocxStory,
2176            self.limits.max_output_bytes,
2177            story_params(story, &part.name, profile),
2178            deps.clone(),
2179            "docx:story",
2180        );
2181        node.limits.max_output_bytes = self.limits.max_output_bytes;
2182        let id = node.content_id();
2183        let bytes = self.materialize(&node)?;
2184        let sm = StoryModel::decode(&bytes)?;
2185        let mut ids = vec![id];
2186        ids.extend(deps);
2187        Ok(DocxStoryView {
2188            model: sm,
2189            part,
2190            deps: ids,
2191            span,
2192        })
2193    }
2194
2195    #[cfg(feature = "docx")]
2196    fn docx_answer(
2197        &self,
2198        req: &ObserveRequest,
2199        value: AnswerValue,
2200        provenance: String,
2201        span: Option<(u64, u64)>,
2202        deps: Vec<NodeId>,
2203    ) -> FieldAnswer {
2204        FieldAnswer {
2205            value,
2206            basis: Basis::DeterministicallyDerived,
2207            selector: req.selector.canonical(),
2208            representation: req.representation.name().to_string(),
2209            source_span: span,
2210            provenance,
2211            dependency_ids: deps,
2212            integrity_scope: IntegrityScope::None,
2213            exact: false,
2214        }
2215    }
2216
2217    #[cfg(feature = "docx")]
2218    fn docx_story_text(
2219        &mut self,
2220        req: &ObserveRequest,
2221        story: DocxStory,
2222        profile: &DocxExtractProfile,
2223    ) -> Result<FieldAnswer> {
2224        let v = self.docx_story_view(story, profile)?;
2225        let text = v.model.text();
2226        let provenance = format!(
2227            "docx;story={};part={};profile={}",
2228            story.name(),
2229            v.part.name,
2230            profile.fingerprint()
2231        );
2232        Ok(self.docx_answer(req, AnswerValue::Text(text), provenance, v.span, v.deps))
2233    }
2234
2235    #[cfg(feature = "docx")]
2236    fn docx_story_metadata(
2237        &mut self,
2238        req: &ObserveRequest,
2239        story: DocxStory,
2240        profile: &DocxExtractProfile,
2241    ) -> Result<FieldAnswer> {
2242        let v = self.docx_story_view(story, profile)?;
2243        let json = format!(
2244            concat!(
2245                "{{\"story\":\"{}\",\"part\":\"{}\",\"ordinal\":{},\"root\":\"{}\",",
2246                "\"paragraphs\":{},\"tables\":{},\"hyperlinks\":{},\"bookmarks\":{},",
2247                "\"resources\":{},\"sections\":{},\"profile\":\"{}\"}}"
2248            ),
2249            json_escape(&story.name()),
2250            json_escape(&v.part.name),
2251            v.part.ordinal,
2252            json_escape(&v.model.root_local),
2253            v.model.paragraphs().count(),
2254            v.model.tables().count(),
2255            v.model.hyperlinks.len(),
2256            v.model.bookmarks.len(),
2257            v.model.resources.len(),
2258            v.model.section_count,
2259            profile.fingerprint(),
2260        );
2261        let provenance = format!("docx;story={};part={}", story.name(), v.part.name);
2262        Ok(self.docx_answer(req, AnswerValue::Json(json), provenance, v.span, v.deps))
2263    }
2264
2265    #[cfg(feature = "docx")]
2266    fn docx_story_structure(
2267        &mut self,
2268        req: &ObserveRequest,
2269        story: DocxStory,
2270        profile: &DocxExtractProfile,
2271    ) -> Result<FieldAnswer> {
2272        let v = self.docx_story_view(story, profile)?;
2273        let paras = v
2274            .model
2275            .paragraphs()
2276            .map(|p| {
2277                format!(
2278                    "{{\"index\":{},\"heading\":{},\"style\":{},\"text_len\":{}}}",
2279                    p.index,
2280                    opt_u8_json(p.heading_level),
2281                    opt_str_json(p.style_id.as_deref()),
2282                    p.text.len()
2283                )
2284            })
2285            .collect::<Vec<_>>()
2286            .join(",");
2287        let tables = v
2288            .model
2289            .tables()
2290            .map(|t| {
2291                format!(
2292                    "{{\"index\":{},\"rows\":{},\"cols_row0\":{}}}",
2293                    t.index,
2294                    t.rows.len(),
2295                    t.rows.first().map_or(0, |r| r.cells.len())
2296                )
2297            })
2298            .collect::<Vec<_>>()
2299            .join(",");
2300        let json = format!(
2301            concat!(
2302                "{{\"story\":\"{}\",\"part\":\"{}\",\"blocks\":{},",
2303                "\"paragraphs\":[{}],\"tables\":[{}],\"profile\":\"{}\"}}"
2304            ),
2305            json_escape(&story.name()),
2306            json_escape(&v.part.name),
2307            v.model.blocks.len(),
2308            paras,
2309            tables,
2310            profile.fingerprint(),
2311        );
2312        Ok(self.docx_answer(
2313            req,
2314            AnswerValue::Json(json),
2315            format!("docx;story={};part={}", story.name(), v.part.name),
2316            v.span,
2317            v.deps,
2318        ))
2319    }
2320
2321    #[cfg(feature = "docx")]
2322    fn docx_paragraph(
2323        &mut self,
2324        req: &ObserveRequest,
2325        story: DocxStory,
2326        index: u32,
2327        profile: &DocxExtractProfile,
2328    ) -> Result<FieldAnswer> {
2329        let v = self.docx_story_view(story, profile)?;
2330        let p = v
2331            .model
2332            .paragraphs()
2333            .find(|p| p.index == index)
2334            .ok_or_else(|| {
2335                Error::unsupported_feature(format!(
2336                    "DOCX story {} has no body paragraph {index}",
2337                    story.name()
2338                ))
2339            })?;
2340        let text = p.text.clone();
2341        let style = p.style_id.clone();
2342        let heading = p.heading_level;
2343        let run_count = p.runs.len();
2344        let provenance = format!(
2345            "docx;story={};part={};paragraph={};profile={}",
2346            story.name(),
2347            v.part.name,
2348            index,
2349            profile.fingerprint()
2350        );
2351        let value = match req.representation {
2352            Representation::Text => AnswerValue::Text(text),
2353            Representation::Metadata => AnswerValue::Json(format!(
2354                concat!(
2355                    "{{\"story\":\"{}\",\"part\":\"{}\",\"paragraph\":{},",
2356                    "\"style\":{},\"heading\":{},\"runs\":{},\"text_len\":{}}}"
2357                ),
2358                json_escape(&story.name()),
2359                json_escape(&v.part.name),
2360                index,
2361                opt_str_json(style.as_deref()),
2362                opt_u8_json(heading),
2363                run_count,
2364                text.len(),
2365            )),
2366            _ => {
2367                return Err(Error::unsupported_feature(format!(
2368                    "unsupported observation: selector {} with representation {}",
2369                    req.selector.canonical(),
2370                    req.representation.name()
2371                )));
2372            }
2373        };
2374        Ok(self.docx_answer(req, value, provenance, v.span, v.deps))
2375    }
2376
2377    #[cfg(feature = "docx")]
2378    fn docx_table(
2379        &mut self,
2380        req: &ObserveRequest,
2381        story: DocxStory,
2382        index: u32,
2383        profile: &DocxExtractProfile,
2384    ) -> Result<FieldAnswer> {
2385        let v = self.docx_story_view(story, profile)?;
2386        let t = v.model.tables().find(|t| t.index == index).ok_or_else(|| {
2387            Error::unsupported_feature(format!("DOCX story {} has no table {index}", story.name()))
2388        })?;
2389        let text = t.text();
2390        let rows = t.rows.len();
2391        let cells: Vec<usize> = t.rows.iter().map(|r| r.cells.len()).collect();
2392        let provenance = format!(
2393            "docx;story={};part={};table={};profile={}",
2394            story.name(),
2395            v.part.name,
2396            index,
2397            profile.fingerprint()
2398        );
2399        let value = match req.representation {
2400            Representation::Text => AnswerValue::Text(text),
2401            Representation::Metadata => {
2402                let dims = cells
2403                    .iter()
2404                    .map(|c| c.to_string())
2405                    .collect::<Vec<_>>()
2406                    .join(",");
2407                AnswerValue::Json(format!(
2408                    concat!(
2409                        "{{\"story\":\"{}\",\"part\":\"{}\",\"table\":{},",
2410                        "\"rows\":{},\"cells_per_row\":[{}],\"profile\":\"{}\"}}"
2411                    ),
2412                    json_escape(&story.name()),
2413                    json_escape(&v.part.name),
2414                    index,
2415                    rows,
2416                    dims,
2417                    profile.fingerprint(),
2418                ))
2419            }
2420            _ => {
2421                return Err(Error::unsupported_feature(format!(
2422                    "unsupported observation: selector {} with representation {}",
2423                    req.selector.canonical(),
2424                    req.representation.name()
2425                )));
2426            }
2427        };
2428        Ok(self.docx_answer(req, value, provenance, v.span, v.deps))
2429    }
2430
2431    #[cfg(feature = "docx")]
2432    fn docx_cell(
2433        &mut self,
2434        req: &ObserveRequest,
2435        story: DocxStory,
2436        table: u32,
2437        cell: &str,
2438        profile: &DocxExtractProfile,
2439    ) -> Result<FieldAnswer> {
2440        let (col, row_idx) = parse_cell_ref(cell).ok_or_else(|| {
2441            Error::usage(format!("cell reference {cell:?} is not A1-style (e.g. B7)"))
2442        })?;
2443        let v = self.docx_story_view(story, profile)?;
2444        let t = v.model.tables().find(|t| t.index == table).ok_or_else(|| {
2445            Error::unsupported_feature(format!("DOCX story {} has no table {table}", story.name()))
2446        })?;
2447        let r = t.rows.get(row_idx as usize).ok_or_else(|| {
2448            Error::unsupported_feature(format!("DOCX table {table} has no row {}", row_idx + 1))
2449        })?;
2450        let found = r
2451            .cells
2452            .iter()
2453            .find(|c| col >= c.grid_col && col < c.grid_col.saturating_add(c.grid_span))
2454            .ok_or_else(|| {
2455                Error::unsupported_feature(format!(
2456                    "DOCX table {table} row {} has no cell {cell}",
2457                    row_idx + 1
2458                ))
2459            })?;
2460        let text = found.text.clone();
2461        let grid_col = found.grid_col;
2462        let grid_span = found.grid_span;
2463        let vmerge = found.vmerge_continue;
2464        let provenance = format!(
2465            "docx;story={};part={};table={};row={};cell={};profile={}",
2466            story.name(),
2467            v.part.name,
2468            table,
2469            row_idx + 1,
2470            cell,
2471            profile.fingerprint()
2472        );
2473        let value = match req.representation {
2474            Representation::Text => AnswerValue::Text(text),
2475            Representation::Metadata => AnswerValue::Json(format!(
2476                concat!(
2477                    "{{\"story\":\"{}\",\"part\":\"{}\",\"table\":{},",
2478                    "\"row\":{},\"cell\":\"{}\",\"grid_col\":{},\"grid_span\":{},",
2479                    "\"vmerge_continue\":{},\"text_len\":{},\"profile\":\"{}\"}}"
2480                ),
2481                json_escape(&story.name()),
2482                json_escape(&v.part.name),
2483                table,
2484                row_idx + 1,
2485                json_escape(cell),
2486                grid_col,
2487                grid_span,
2488                vmerge,
2489                text.len(),
2490                profile.fingerprint(),
2491            )),
2492            _ => {
2493                return Err(Error::unsupported_feature(format!(
2494                    "unsupported observation: selector {} with representation {}",
2495                    req.selector.canonical(),
2496                    req.representation.name()
2497                )));
2498            }
2499        };
2500        Ok(self.docx_answer(req, value, provenance, v.span, v.deps))
2501    }
2502
2503    #[cfg(feature = "docx")]
2504    fn docx_find(
2505        &mut self,
2506        req: &ObserveRequest,
2507        story: DocxStory,
2508        pattern: &str,
2509        profile: &DocxExtractProfile,
2510    ) -> Result<FieldAnswer> {
2511        let v = self.docx_story_view(story, profile)?;
2512        let mut items: Vec<String> = Vec::new();
2513        let mut estimated: u64 = 0;
2514        for p in v.model.paragraphs() {
2515            if p.text.contains(pattern) {
2516                estimated = estimated.saturating_add(p.text.len() as u64 + 48);
2517                if estimated > req.budget.max_output_bytes {
2518                    return Err(Error::resource_limit(format!(
2519                        "DOCX find exceeded the {}-byte budget",
2520                        req.budget.max_output_bytes
2521                    )));
2522                }
2523                items.push(format!(
2524                    "{{\"paragraph\":{},\"text\":\"{}\"}}",
2525                    p.index,
2526                    json_escape(&p.text)
2527                ));
2528            }
2529        }
2530        let provenance = format!(
2531            "docx;story={};part={};profile={}",
2532            story.name(),
2533            v.part.name,
2534            profile.fingerprint()
2535        );
2536        Ok(self.docx_answer(
2537            req,
2538            AnswerValue::Json(format!("[{}]", items.join(","))),
2539            provenance,
2540            v.span,
2541            v.deps,
2542        ))
2543    }
2544
2545    /// Resolve the `PdfStreamDecoded` node for `object`.
2546    ///
2547    /// A decoded node is a deterministic function of its encoded node, the
2548    /// materializer, and the decoded length, so it is registered under
2549    /// [`SEL_STREAM_DECODED`] at ingest and found here in `O(depth)` index reads.
2550    /// Only when that entry is absent (ingest declined the eager decode) do we
2551    /// fall back to [`Ctx::deepen_stream`], which recomputes just this one node.
2552    /// Either path never enumerates the seed store (fix #3).
2553    fn decoded_node(&mut self, object: u32, encoded_id: &NodeId) -> Result<SeedNode> {
2554        let entries = self.lookup(SelectorKey::new(SEL_STREAM_DECODED, object))?;
2555        if let Some(entry) = entries.into_iter().next() {
2556            return self.load(&entry.node_id);
2557        }
2558        self.deepen_stream(object, encoded_id)
2559    }
2560
2561    /// Persist a decoded-stream node for `object` if it is recoverable, then
2562    /// return it. A stream with no exact decoded representation is unsupported.
2563    fn deepen_stream(&mut self, object: u32, encoded_id: &NodeId) -> Result<SeedNode> {
2564        let encoded_node = self.load(encoded_id)?;
2565        let encoded = self.materialize(&encoded_node)?;
2566        let cap = usize::try_from(self.limits.max_output_bytes).unwrap_or(usize::MAX);
2567        let decoded = miniz_oxide::inflate::decompress_to_vec_zlib_with_limit(&encoded, cap)
2568            .map_err(|e| {
2569                Error::unsupported_feature(format!(
2570                    "stream {object} has no recovered decoded representation: {:?}",
2571                    e.status
2572                ))
2573            })?;
2574        let node = SeedNode::new(
2575            NodeKind::PdfStreamDecoded,
2576            decoded.len() as u64,
2577            u32_params(object),
2578            vec![*encoded_id],
2579            "pdf:stream-decoded",
2580        );
2581        self.store.seeds_mut().put_node(&node.encode_canonical())?;
2582        self.stats.deepened = true;
2583        Ok(node)
2584    }
2585}
2586
2587#[cfg(feature = "epub")]
2588fn epub_opt_str(v: Option<&str>) -> String {
2589    match v {
2590        Some(s) => format!("\"{}\"", json_escape(s)),
2591        None => "null".to_string(),
2592    }
2593}
2594
2595#[cfg(feature = "epub")]
2596fn epub_opt_u32(v: Option<u32>) -> String {
2597    match v {
2598        Some(n) => n.to_string(),
2599        None => "null".to_string(),
2600    }
2601}
2602
2603#[cfg(feature = "epub")]
2604fn epub_str_array(items: &[String]) -> String {
2605    format!(
2606        "[{}]",
2607        items
2608            .iter()
2609            .map(|s| format!("\"{}\"", json_escape(s)))
2610            .collect::<Vec<_>>()
2611            .join(",")
2612    )
2613}
2614
2615#[cfg(feature = "epub")]
2616fn epub_dir_of(name: &str) -> String {
2617    match name.rfind('/') {
2618        Some(i) => name[..=i].to_string(),
2619        None => String::new(),
2620    }
2621}
2622
2623#[cfg(feature = "epub")]
2624fn epub_mimetype_json(m: &crate::adapter::epub::MimetypeFacts) -> String {
2625    format!(
2626        "{{\"present\":{},\"first\":{},\"stored\":{},\"no_extra\":{},\"exact_bytes\":{},\"conformant\":{}}}",
2627        m.present, m.first, m.stored, m.no_extra, m.exact_bytes, m.conformant
2628    )
2629}
2630
2631#[cfg(feature = "epub")]
2632fn epub_rootfile_json(r: &crate::adapter::epub::RootFile) -> String {
2633    let ord = if r.ordinal == u32::MAX {
2634        None
2635    } else {
2636        Some(r.ordinal)
2637    };
2638    format!(
2639        "{{\"full_path\":\"{}\",\"member\":\"{}\",\"media_type\":\"{}\",\"ordinal\":{}}}",
2640        json_escape(&r.full_path),
2641        json_escape(&r.member),
2642        json_escape(&r.media_type),
2643        epub_opt_u32(ord)
2644    )
2645}
2646
2647#[cfg(feature = "epub")]
2648fn epub_meta_json(e: &crate::adapter::epub::MetadataEntry) -> String {
2649    format!(
2650        "{{\"name\":\"{}\",\"property\":{},\"refines\":{},\"id\":{},\"scheme\":{},\"value\":\"{}\"}}",
2651        json_escape(&e.name),
2652        epub_opt_str(e.property.as_deref()),
2653        epub_opt_str(e.refines.as_deref()),
2654        epub_opt_str(e.id.as_deref()),
2655        epub_opt_str(e.scheme.as_deref()),
2656        json_escape(&e.value)
2657    )
2658}
2659
2660#[cfg(feature = "epub")]
2661fn epub_manifest_json(it: &crate::adapter::epub::ManifestItem) -> String {
2662    format!(
2663        "{{\"id\":\"{}\",\"href\":\"{}\",\"media_type\":\"{}\",\"properties\":{},\"fallback\":{},\"resolved\":{},\"ordinal\":{},\"external\":{}}}",
2664        json_escape(&it.id),
2665        json_escape(&it.href),
2666        json_escape(&it.media_type),
2667        epub_str_array(&it.properties),
2668        epub_opt_str(it.fallback.as_deref()),
2669        epub_opt_str(it.resolved.as_deref()),
2670        epub_opt_u32(it.resolved_ordinal()),
2671        it.external
2672    )
2673}
2674
2675#[cfg(feature = "epub")]
2676fn epub_spine_json(s: &crate::adapter::epub::SpineItemRef) -> String {
2677    let index = if s.item_index == u32::MAX {
2678        None
2679    } else {
2680        Some(s.item_index)
2681    };
2682    let ord = if s.ordinal == u32::MAX {
2683        None
2684    } else {
2685        Some(s.ordinal)
2686    };
2687    format!(
2688        "{{\"idref\":\"{}\",\"linear\":{},\"properties\":{},\"item_index\":{},\"ordinal\":{}}}",
2689        json_escape(&s.idref),
2690        s.linear,
2691        epub_str_array(&s.properties),
2692        epub_opt_u32(index),
2693        epub_opt_u32(ord)
2694    )
2695}
2696
2697#[cfg(feature = "epub")]
2698fn epub_nav_json(index: u32, e: &crate::adapter::epub::NavEntry) -> String {
2699    format!(
2700        "{{\"index\":{},\"depth\":{},\"nav\":\"{}\",\"label\":\"{}\",\"href\":\"{}\",\"member\":{},\"fragment\":{},\"external\":{}}}",
2701        index,
2702        e.depth,
2703        json_escape(&e.nav_type),
2704        json_escape(&e.label),
2705        json_escape(&e.href),
2706        epub_opt_str(e.member.as_deref()),
2707        epub_opt_str(e.fragment.as_deref()),
2708        e.external
2709    )
2710}
2711
2712#[cfg(feature = "epub")]
2713fn epub_block_json(index: u32, b: &crate::adapter::epub::Block) -> String {
2714    use crate::adapter::epub::Block;
2715    match b {
2716        Block::Heading {
2717            level,
2718            id,
2719            epub_type,
2720            text,
2721        } => format!(
2722            "{{\"index\":{index},\"kind\":\"heading\",\"level\":{level},\"id\":{},\"epub_type\":{},\"text\":\"{}\"}}",
2723            epub_opt_str(id.as_deref()),
2724            epub_opt_str(epub_type.as_deref()),
2725            json_escape(text)
2726        ),
2727        Block::Paragraph { text } => format!(
2728            "{{\"index\":{index},\"kind\":\"paragraph\",\"text\":\"{}\"}}",
2729            json_escape(text)
2730        ),
2731        Block::List { ordered, items } => format!(
2732            "{{\"index\":{index},\"kind\":\"list\",\"ordered\":{ordered},\"items\":{}}}",
2733            epub_str_array(items)
2734        ),
2735        Block::Table { rows } => format!(
2736            "{{\"index\":{index},\"kind\":\"table\",\"rows\":{},\"cols\":{}}}",
2737            rows.len(),
2738            rows.first().map_or(0, |r| r.cells.len())
2739        ),
2740    }
2741}
2742
2743#[cfg(feature = "epub")]
2744fn epub_cell_json(table: u32, row: u32, col: u32, c: &crate::adapter::epub::Cell) -> String {
2745    format!(
2746        "{{\"table\":{table},\"row\":{row},\"col\":{col},\"header\":{},\"colspan\":{},\"rowspan\":{},\"text_len\":{}}}",
2747        c.header,
2748        c.colspan,
2749        c.rowspan,
2750        c.text.len()
2751    )
2752}
2753
2754#[cfg(feature = "epub")]
2755fn epub_link_json(index: u32, l: &crate::adapter::epub::Link) -> String {
2756    format!(
2757        "{{\"index\":{index},\"href\":\"{}\",\"text\":\"{}\",\"fragment\":{},\"member\":{},\"external\":{},\"epub_type\":{}}}",
2758        json_escape(&l.href),
2759        json_escape(&l.text),
2760        epub_opt_str(l.fragment.as_deref()),
2761        epub_opt_str(l.member.as_deref()),
2762        l.external,
2763        epub_opt_str(l.epub_type.as_deref())
2764    )
2765}
2766
2767#[cfg(feature = "epub")]
2768fn epub_resource_json(index: usize, r: &crate::adapter::epub::Resource) -> String {
2769    format!(
2770        "{{\"index\":{index},\"kind\":\"{}\",\"attr\":\"{}\",\"value\":\"{}\",\"member\":{},\"external\":{}}}",
2771        json_escape(&r.kind),
2772        json_escape(&r.attr),
2773        json_escape(&r.value),
2774        epub_opt_str(r.member.as_deref()),
2775        r.external
2776    )
2777}
2778
2779#[cfg(feature = "epub")]
2780fn epub_section_json(index: usize, s: &crate::adapter::epub::Section) -> String {
2781    format!(
2782        "{{\"index\":{index},\"local\":\"{}\",\"epub_type\":{},\"depth\":{}}}",
2783        json_escape(&s.local),
2784        epub_opt_str(s.epub_type.as_deref()),
2785        s.depth
2786    )
2787}
2788
2789#[cfg(feature = "epub")]
2790fn epub_content_structure_json(
2791    index: u32,
2792    model: &crate::adapter::epub::ContentModel,
2793    item: &ManifestItem,
2794    profile: &EpubExtractProfile,
2795) -> String {
2796    let blocks = model
2797        .blocks
2798        .iter()
2799        .enumerate()
2800        .map(|(i, b)| epub_block_json(i as u32, b))
2801        .collect::<Vec<_>>()
2802        .join(",");
2803    let headings = model
2804        .blocks
2805        .iter()
2806        .enumerate()
2807        .filter(|(_, b)| matches!(b, crate::adapter::epub::Block::Heading { .. }))
2808        .map(|(i, b)| epub_block_json(i as u32, b))
2809        .collect::<Vec<_>>()
2810        .join(",");
2811    let links = model
2812        .links
2813        .iter()
2814        .enumerate()
2815        .map(|(i, l)| epub_link_json(i as u32, l))
2816        .collect::<Vec<_>>()
2817        .join(",");
2818    let resources = model
2819        .resources
2820        .iter()
2821        .enumerate()
2822        .map(|(i, r)| epub_resource_json(i, r))
2823        .collect::<Vec<_>>()
2824        .join(",");
2825    let sections = model
2826        .sections
2827        .iter()
2828        .enumerate()
2829        .map(|(i, s)| epub_section_json(i, s))
2830        .collect::<Vec<_>>()
2831        .join(",");
2832    format!(
2833        concat!(
2834            "{{\"spine\":{},\"part\":\"{}\",\"profile\":\"{}\",\"root\":\"{}\",",
2835            "\"body\":{},\"scripted\":{},\"xhtml_nodes\":{},",
2836            "\"blocks\":[{}],\"headings\":[{}],\"links\":[{}],",
2837            "\"resources\":[{}],\"fragments\":{},\"sections\":[{}]}}"
2838        ),
2839        index,
2840        json_escape(item.resolved.as_deref().unwrap_or("")),
2841        profile.fingerprint(),
2842        json_escape(&model.root_local),
2843        model.body_seen,
2844        model.scripted,
2845        model.xhtml_nodes,
2846        blocks,
2847        headings,
2848        links,
2849        resources,
2850        epub_str_array(&model.fragments),
2851        sections
2852    )
2853}
2854
2855#[cfg(feature = "epub")]
2856type EpubContentView = (
2857    crate::adapter::epub::ContentModel,
2858    ManifestItem,
2859    Option<(u64, u64)>,
2860    Vec<NodeId>,
2861);
2862
2863#[cfg(feature = "epub")]
2864impl<S: SeedStore> Ctx<'_, S> {
2865    fn epub_model(&mut self) -> Result<EpubModel> {
2866        let entry = self.require_entry(SelectorKey::new(SEL_EPUB_MODEL, 0), "EPUB model")?;
2867        let node = self.load(&entry.node_id)?;
2868        let bytes = self.materialize(&node)?;
2869        EpubModel::decode(&bytes)
2870    }
2871
2872    fn epub_package_doc(&mut self) -> Result<PackageDoc> {
2873        let model = self.epub_model()?;
2874        model.package.ok_or_else(|| {
2875            Error::invalid_package_structure("EPUB container has no resolvable package document")
2876        })
2877    }
2878
2879    fn epub_member_decoded_bytes(&mut self, ordinal: u32) -> Result<Vec<u8>> {
2880        let entry = self.require_entry(
2881            SelectorKey::new(SEL_PACKAGE_MEMBER_DECODED, ordinal),
2882            "EPUB resource decoded bytes",
2883        )?;
2884        let node = self.load(&entry.node_id)?;
2885        self.stats.member_decodes = self.stats.member_decodes.saturating_add(1);
2886        self.materialize(&node)
2887    }
2888
2889    fn epub_member_span(&mut self, ordinal: Option<u32>) -> Option<(u64, u64)> {
2890        let o = ordinal?;
2891        self.lookup(SelectorKey::new(SEL_PACKAGE_MEMBER_RAW, o))
2892            .ok()?
2893            .into_iter()
2894            .next()
2895            .map(|e| (e.out_off, e.out_off.saturating_add(e.out_len)))
2896    }
2897
2898    fn epub_answer(
2899        &self,
2900        req: &ObserveRequest,
2901        value: AnswerValue,
2902        provenance: String,
2903        span: Option<(u64, u64)>,
2904        deps: Vec<NodeId>,
2905    ) -> FieldAnswer {
2906        FieldAnswer {
2907            value,
2908            basis: Basis::DeterministicallyDerived,
2909            selector: req.selector.canonical(),
2910            representation: req.representation.name().to_string(),
2911            source_span: span,
2912            provenance,
2913            dependency_ids: deps,
2914            integrity_scope: IntegrityScope::None,
2915            exact: false,
2916        }
2917    }
2918
2919    fn epub_package(&mut self, req: &ObserveRequest) -> Result<FieldAnswer> {
2920        let model = self.epub_model()?;
2921        let doc = model.package.as_ref().ok_or_else(|| {
2922            Error::invalid_package_structure("EPUB container has no resolvable package document")
2923        })?;
2924        let rootfiles = model
2925            .rootfiles
2926            .iter()
2927            .map(epub_rootfile_json)
2928            .collect::<Vec<_>>()
2929            .join(",");
2930        let mut s = String::new();
2931        s.push_str("{\"package\":\"");
2932        s.push_str(&json_escape(&doc.member));
2933        s.push_str("\",\"version\":");
2934        s.push_str(&epub_opt_str(doc.version.as_deref()));
2935        s.push_str(",\"unique_identifier\":");
2936        s.push_str(&epub_opt_str(doc.unique_identifier.as_deref()));
2937        s.push_str(",\"page_progression_direction\":");
2938        s.push_str(&epub_opt_str(doc.page_progression.as_deref()));
2939        s.push_str(",\"rendition_layout\":");
2940        s.push_str(&epub_opt_str(doc.layout.as_deref()));
2941        s.push_str(",\"cover_id\":");
2942        s.push_str(&epub_opt_str(doc.cover_id.as_deref()));
2943        s.push_str(",\"nav_item\":");
2944        s.push_str(&epub_opt_u32(doc.nav_item));
2945        s.push_str(",\"ncx_item\":");
2946        s.push_str(&epub_opt_u32(doc.ncx_item));
2947        s.push_str(&format!(
2948            ",\"manifest_items\":{},\"spine_items\":{},\"metadata_entries\":{}",
2949            doc.manifest.len(),
2950            doc.spine.len(),
2951            doc.metadata.len()
2952        ));
2953        s.push_str(",\"rootfiles\":[");
2954        s.push_str(&rootfiles);
2955        s.push_str("],\"mimetype\":");
2956        s.push_str(&epub_mimetype_json(&model.mimetype));
2957        if req.representation == Representation::Structure {
2958            let man = doc
2959                .manifest
2960                .iter()
2961                .map(epub_manifest_json)
2962                .collect::<Vec<_>>()
2963                .join(",");
2964            let sp = doc
2965                .spine
2966                .iter()
2967                .map(epub_spine_json)
2968                .collect::<Vec<_>>()
2969                .join(",");
2970            let md = doc
2971                .metadata
2972                .iter()
2973                .map(epub_meta_json)
2974                .collect::<Vec<_>>()
2975                .join(",");
2976            s.push_str(",\"manifest\":[");
2977            s.push_str(&man);
2978            s.push_str("],\"spine\":[");
2979            s.push_str(&sp);
2980            s.push_str("],\"metadata\":[");
2981            s.push_str(&md);
2982            s.push(']');
2983        }
2984        s.push_str(",\"issues\":");
2985        s.push_str(&epub_str_array(&doc.issues));
2986        s.push('}');
2987        let provenance = format!("epub;package={}", doc.member);
2988        Ok(self.epub_answer(req, AnswerValue::Json(s), provenance, None, Vec::new()))
2989    }
2990
2991    fn epub_manifest_item_meta(&mut self, req: &ObserveRequest, index: u32) -> Result<FieldAnswer> {
2992        let doc = self.epub_package_doc()?;
2993        let item = doc
2994            .manifest
2995            .get(index as usize)
2996            .ok_or_else(|| Error::unsupported_feature(format!("no EPUB manifest item {index}")))?;
2997        let json = epub_manifest_json(item);
2998        let provenance = format!("epub;manifest={index};id={}", item.id);
2999        let span = self.epub_member_span(item.resolved_ordinal());
3000        Ok(self.epub_answer(req, AnswerValue::Json(json), provenance, span, Vec::new()))
3001    }
3002
3003    fn epub_manifest_item_bytes(
3004        &mut self,
3005        req: &ObserveRequest,
3006        index: u32,
3007    ) -> Result<FieldAnswer> {
3008        let doc = self.epub_package_doc()?;
3009        let item =
3010            doc.manifest.get(index as usize).cloned().ok_or_else(|| {
3011                Error::unsupported_feature(format!("no EPUB manifest item {index}"))
3012            })?;
3013        self.epub_item_bytes(req, &item)
3014    }
3015
3016    fn epub_item_bytes(
3017        &mut self,
3018        req: &ObserveRequest,
3019        item: &ManifestItem,
3020    ) -> Result<FieldAnswer> {
3021        if item.external {
3022            return Err(Error::invalid_package_structure(format!(
3023                "EPUB manifest item {:?} is an external target: inert, never fetched",
3024                item.id
3025            )));
3026        }
3027        let ordinal = item.resolved_ordinal().ok_or_else(|| {
3028            Error::invalid_package_structure(format!(
3029                "EPUB manifest item {:?} has no resolvable container member",
3030                item.id
3031            ))
3032        })?;
3033        if req.representation == Representation::ExactBytes {
3034            self.indexed_exact(
3035                req,
3036                SelectorKey::new(SEL_PACKAGE_MEMBER_RAW, ordinal),
3037                "EPUB resource",
3038            )
3039        } else {
3040            self.member_decoded(req, ordinal)
3041        }
3042    }
3043
3044    fn epub_spine_item_meta(
3045        &mut self,
3046        req: &ObserveRequest,
3047        index: u32,
3048        profile: &EpubExtractProfile,
3049    ) -> Result<FieldAnswer> {
3050        let doc = self.epub_package_doc()?;
3051        let order = doc.reading_order(profile);
3052        let mi = *order.get(index as usize).ok_or_else(|| {
3053            Error::unsupported_feature(format!(
3054                "no EPUB spine item {index} under profile {}",
3055                profile.fingerprint()
3056            ))
3057        })?;
3058        if mi == u32::MAX {
3059            return Err(Error::invalid_package_structure(
3060                "EPUB spine item does not resolve to a manifest item",
3061            ));
3062        }
3063        let item = doc.manifest.get(mi as usize).ok_or_else(|| {
3064            Error::invalid_package_structure("EPUB spine item manifest index is out of range")
3065        })?;
3066        let spine_ref = doc.spine.iter().find(|s| s.item_index == mi);
3067        let json = match spine_ref {
3068            Some(s) => format!(
3069                "{{\"index\":{},\"profile\":\"{}\",\"idref\":\"{}\",\"linear\":{},\"properties\":{},\"item\":{}}}",
3070                index,
3071                profile.fingerprint(),
3072                json_escape(&s.idref),
3073                s.linear,
3074                epub_str_array(&s.properties),
3075                epub_manifest_json(item)
3076            ),
3077            None => format!(
3078                "{{\"index\":{},\"profile\":\"{}\",\"idref\":null,\"item\":{}}}",
3079                index,
3080                profile.fingerprint(),
3081                epub_manifest_json(item)
3082            ),
3083        };
3084        let provenance = format!(
3085            "epub;spine={index};profile={};id={};part={}",
3086            profile.fingerprint(),
3087            item.id,
3088            item.resolved.as_deref().unwrap_or("")
3089        );
3090        let span = self.epub_member_span(item.resolved_ordinal());
3091        Ok(self.epub_answer(req, AnswerValue::Json(json), provenance, span, Vec::new()))
3092    }
3093
3094    fn epub_spine_item_bytes(
3095        &mut self,
3096        req: &ObserveRequest,
3097        index: u32,
3098        profile: &EpubExtractProfile,
3099    ) -> Result<FieldAnswer> {
3100        let doc = self.epub_package_doc()?;
3101        let order = doc.reading_order(profile);
3102        let mi = *order.get(index as usize).ok_or_else(|| {
3103            Error::unsupported_feature(format!(
3104                "no EPUB spine item {index} under profile {}",
3105                profile.fingerprint()
3106            ))
3107        })?;
3108        let item = doc.manifest.get(mi as usize).cloned().ok_or_else(|| {
3109            Error::invalid_package_structure("EPUB spine item manifest index is out of range")
3110        })?;
3111        self.epub_item_bytes(req, &item)
3112    }
3113
3114    /// Resolve a spine item to its parsed content model, parsing **only** that
3115    /// item's XHTML member (plus the shared model/decoded member) and persisting
3116    /// the derived content node in the disposable cache so later queries reuse it.
3117    /// Nothing here parses any *other* spine item.
3118    fn epub_content_view(
3119        &mut self,
3120        index: u32,
3121        profile: &EpubExtractProfile,
3122    ) -> Result<EpubContentView> {
3123        let doc = self.epub_package_doc()?;
3124        let order = doc.reading_order(profile);
3125        let mi = *order.get(index as usize).ok_or_else(|| {
3126            Error::unsupported_feature(format!(
3127                "no EPUB spine item {index} under profile {}",
3128                profile.fingerprint()
3129            ))
3130        })?;
3131        let item = doc.manifest.get(mi as usize).cloned().ok_or_else(|| {
3132            Error::invalid_package_structure("EPUB spine item manifest index is out of range")
3133        })?;
3134        let ordinal = item.resolved_ordinal().ok_or_else(|| {
3135            Error::invalid_package_structure(format!(
3136                "EPUB spine item {:?} has no resolvable container member",
3137                item.id
3138            ))
3139        })?;
3140        let dec = self.require_entry(
3141            SelectorKey::new(SEL_PACKAGE_MEMBER_DECODED, ordinal),
3142            "EPUB spine content decoded bytes",
3143        )?;
3144        self.stats.member_decodes = self.stats.member_decodes.saturating_add(1);
3145        let base_dir = epub_dir_of(item.resolved.as_deref().unwrap_or(""));
3146        let mut node = SeedNode::new(
3147            NodeKind::EpubContent,
3148            self.limits.max_output_bytes,
3149            crate::adapter::epub::content_params(index, ordinal, &base_dir, profile),
3150            vec![dec.node_id],
3151            "epub:content",
3152        );
3153        node.limits.max_output_bytes = self.limits.max_output_bytes;
3154        let id = node.content_id();
3155        let bytes = self.materialize(&node)?;
3156        let model = crate::adapter::epub::ContentModel::decode(&bytes)?;
3157        let span = self.epub_member_span(Some(ordinal));
3158        Ok((model, item, span, vec![id, dec.node_id]))
3159    }
3160
3161    fn epub_spine_item_text(
3162        &mut self,
3163        req: &ObserveRequest,
3164        index: u32,
3165        profile: &EpubExtractProfile,
3166    ) -> Result<FieldAnswer> {
3167        let (model, item, span, deps) = self.epub_content_view(index, profile)?;
3168        let provenance = format!(
3169            "epub;spine={index};part={};profile={};content",
3170            item.resolved.as_deref().unwrap_or(""),
3171            profile.fingerprint()
3172        );
3173        Ok(self.epub_answer(req, AnswerValue::Text(model.text()), provenance, span, deps))
3174    }
3175
3176    fn epub_spine_item_structure(
3177        &mut self,
3178        req: &ObserveRequest,
3179        index: u32,
3180        profile: &EpubExtractProfile,
3181    ) -> Result<FieldAnswer> {
3182        let (model, item, span, deps) = self.epub_content_view(index, profile)?;
3183        let json = epub_content_structure_json(index, &model, &item, profile);
3184        let provenance = format!(
3185            "epub;spine={index};part={};profile={};structure",
3186            item.resolved.as_deref().unwrap_or(""),
3187            profile.fingerprint()
3188        );
3189        Ok(self.epub_answer(req, AnswerValue::Json(json), provenance, span, deps))
3190    }
3191
3192    fn epub_spine_item_preview(
3193        &mut self,
3194        req: &ObserveRequest,
3195        index: u32,
3196        profile: &EpubExtractProfile,
3197    ) -> Result<FieldAnswer> {
3198        let (model, item, span, deps) = self.epub_content_view(index, profile)?;
3199        let text = model.preview_text(
3200            index,
3201            item.resolved.as_deref().unwrap_or(""),
3202            &profile.fingerprint(),
3203        );
3204        let provenance = format!(
3205            "epub;spine={index};part={};profile={};preview",
3206            item.resolved.as_deref().unwrap_or(""),
3207            profile.fingerprint()
3208        );
3209        Ok(self.epub_answer(req, AnswerValue::Text(text), provenance, span, deps))
3210    }
3211
3212    fn epub_block(
3213        &mut self,
3214        req: &ObserveRequest,
3215        index: u32,
3216        block: u32,
3217        profile: &EpubExtractProfile,
3218    ) -> Result<FieldAnswer> {
3219        let (model, item, span, deps) = self.epub_content_view(index, profile)?;
3220        let b = model.blocks.get(block as usize).ok_or_else(|| {
3221            Error::unsupported_feature(format!("EPUB spine item {index} has no block {block}"))
3222        })?;
3223        let provenance = format!(
3224            "epub;spine={index};part={};block={block};profile={}",
3225            item.resolved.as_deref().unwrap_or(""),
3226            profile.fingerprint()
3227        );
3228        let value = match req.representation {
3229            Representation::Text => AnswerValue::Text(b.text()),
3230            Representation::Metadata | Representation::Structure => {
3231                AnswerValue::Json(epub_block_json(block, b))
3232            }
3233            _ => {
3234                return Err(Error::unsupported_feature(format!(
3235                    "unsupported observation: selector {} with representation {}",
3236                    req.selector.canonical(),
3237                    req.representation.name()
3238                )));
3239            }
3240        };
3241        Ok(self.epub_answer(req, value, provenance, span, deps))
3242    }
3243
3244    fn epub_cell(
3245        &mut self,
3246        req: &ObserveRequest,
3247        index: u32,
3248        table: u32,
3249        row: u32,
3250        col: u32,
3251        profile: &EpubExtractProfile,
3252    ) -> Result<FieldAnswer> {
3253        let (model, item, span, deps) = self.epub_content_view(index, profile)?;
3254        let t = model.table(table).ok_or_else(|| {
3255            Error::unsupported_feature(format!("EPUB spine item {index} has no table {table}"))
3256        })?;
3257        let crate::adapter::epub::Block::Table { rows } = t else {
3258            return Err(Error::internal_invariant(
3259                "table selector resolved a non-table",
3260            ));
3261        };
3262        let r = rows.get(row as usize).ok_or_else(|| {
3263            Error::unsupported_feature(format!("EPUB table {table} has no row {row}"))
3264        })?;
3265        let c = r.cells.get(col as usize).ok_or_else(|| {
3266            Error::unsupported_feature(format!("EPUB table {table} row {row} has no cell {col}"))
3267        })?;
3268        let provenance = format!(
3269            "epub;spine={index};part={};table={table};row={row};col={col};profile={}",
3270            item.resolved.as_deref().unwrap_or(""),
3271            profile.fingerprint()
3272        );
3273        let value = match req.representation {
3274            Representation::Text => AnswerValue::Text(c.text.clone()),
3275            Representation::Metadata => AnswerValue::Json(epub_cell_json(table, row, col, c)),
3276            _ => {
3277                return Err(Error::unsupported_feature(format!(
3278                    "unsupported observation: selector {} with representation {}",
3279                    req.selector.canonical(),
3280                    req.representation.name()
3281                )));
3282            }
3283        };
3284        Ok(self.epub_answer(req, value, provenance, span, deps))
3285    }
3286
3287    fn epub_link(
3288        &mut self,
3289        req: &ObserveRequest,
3290        index: u32,
3291        link: u32,
3292        profile: &EpubExtractProfile,
3293    ) -> Result<FieldAnswer> {
3294        let (model, item, span, deps) = self.epub_content_view(index, profile)?;
3295        let l = model.links.get(link as usize).ok_or_else(|| {
3296            Error::unsupported_feature(format!("EPUB spine item {index} has no link {link}"))
3297        })?;
3298        let provenance = format!(
3299            "epub;spine={index};part={};link={link};profile={}",
3300            item.resolved.as_deref().unwrap_or(""),
3301            profile.fingerprint()
3302        );
3303        Ok(self.epub_answer(
3304            req,
3305            AnswerValue::Json(epub_link_json(link, l)),
3306            provenance,
3307            span,
3308            deps,
3309        ))
3310    }
3311
3312    fn epub_find(
3313        &mut self,
3314        req: &ObserveRequest,
3315        index: u32,
3316        pattern: &str,
3317        profile: &EpubExtractProfile,
3318    ) -> Result<FieldAnswer> {
3319        let (model, item, span, deps) = self.epub_content_view(index, profile)?;
3320        let mut items: Vec<String> = Vec::new();
3321        let mut estimated: u64 = 0;
3322        for (i, b) in model.blocks.iter().enumerate() {
3323            let t = b.text();
3324            if t.contains(pattern) {
3325                estimated = estimated.saturating_add(t.len() as u64 + 48);
3326                if estimated > req.budget.max_output_bytes {
3327                    return Err(Error::resource_limit(format!(
3328                        "EPUB find exceeded the {}-byte budget",
3329                        req.budget.max_output_bytes
3330                    )));
3331                }
3332                items.push(format!(
3333                    "{{\"block\":{i},\"kind\":\"{}\",\"text\":\"{}\"}}",
3334                    b.kind(),
3335                    json_escape(&t)
3336                ));
3337            }
3338        }
3339        let provenance = format!(
3340            "epub;spine={index};part={};profile={};find",
3341            item.resolved.as_deref().unwrap_or(""),
3342            profile.fingerprint()
3343        );
3344        Ok(self.epub_answer(
3345            req,
3346            AnswerValue::Json(format!("[{}]", items.join(","))),
3347            provenance,
3348            span,
3349            deps,
3350        ))
3351    }
3352
3353    fn epub_resource_meta(&mut self, req: &ObserveRequest, name: &str) -> Result<FieldAnswer> {
3354        let doc = self.epub_package_doc()?;
3355        let item = doc
3356            .manifest
3357            .iter()
3358            .find(|m| m.resolved.as_deref() == Some(name))
3359            .ok_or_else(|| {
3360                Error::invalid_package_structure(format!("no EPUB resource named {name:?}"))
3361            })?;
3362        let json = epub_manifest_json(item);
3363        let span = self.epub_member_span(item.resolved_ordinal());
3364        Ok(self.epub_answer(
3365            req,
3366            AnswerValue::Json(json),
3367            format!("epub;resource={name}"),
3368            span,
3369            Vec::new(),
3370        ))
3371    }
3372
3373    fn epub_resource_bytes(&mut self, req: &ObserveRequest, name: &str) -> Result<FieldAnswer> {
3374        let doc = self.epub_package_doc()?;
3375        let item = doc
3376            .manifest
3377            .iter()
3378            .find(|m| m.resolved.as_deref() == Some(name))
3379            .cloned()
3380            .ok_or_else(|| {
3381                Error::invalid_package_structure(format!("no EPUB resource named {name:?}"))
3382            })?;
3383        self.epub_item_bytes(req, &item)
3384    }
3385
3386    fn epub_nav(&mut self, req: &ObserveRequest) -> Result<FieldAnswer> {
3387        let doc = self.epub_package_doc()?;
3388        let idx = doc.nav_item.ok_or_else(|| {
3389            Error::invalid_package_structure(
3390                "EPUB package has no navigation document (properties nav)",
3391            )
3392        })?;
3393        let item = doc.manifest.get(idx as usize).cloned().ok_or_else(|| {
3394            Error::invalid_package_structure("EPUB nav manifest index is out of range")
3395        })?;
3396        let ordinal = item.resolved_ordinal().ok_or_else(|| {
3397            Error::invalid_package_structure("EPUB navigation document has no resolvable member")
3398        })?;
3399        let bytes = self.epub_member_decoded_bytes(ordinal)?;
3400        let base = epub_dir_of(item.resolved.as_deref().unwrap_or(""));
3401        let entries = crate::adapter::epub::parse_nav_document(&bytes, &base, self.limits)?;
3402        let body = entries
3403            .iter()
3404            .enumerate()
3405            .map(|(i, e)| epub_nav_json(i as u32, e))
3406            .collect::<Vec<_>>()
3407            .join(",");
3408        let json = format!(
3409            "{{\"nav_item\":\"{}\",\"entries\":[{}]}}",
3410            json_escape(&item.id),
3411            body
3412        );
3413        let provenance = format!(
3414            "epub;nav={};part={}",
3415            item.id,
3416            item.resolved.as_deref().unwrap_or("")
3417        );
3418        let span = self.epub_member_span(Some(ordinal));
3419        Ok(self.epub_answer(req, AnswerValue::Json(json), provenance, span, Vec::new()))
3420    }
3421
3422    fn epub_nav_node(&mut self, req: &ObserveRequest, index: u32) -> Result<FieldAnswer> {
3423        let doc = self.epub_package_doc()?;
3424        let idx = doc.nav_item.ok_or_else(|| {
3425            Error::invalid_package_structure(
3426                "EPUB package has no navigation document (properties nav)",
3427            )
3428        })?;
3429        let item = doc.manifest.get(idx as usize).cloned().ok_or_else(|| {
3430            Error::invalid_package_structure("EPUB nav manifest index is out of range")
3431        })?;
3432        let ordinal = item.resolved_ordinal().ok_or_else(|| {
3433            Error::invalid_package_structure("EPUB navigation document has no resolvable member")
3434        })?;
3435        let bytes = self.epub_member_decoded_bytes(ordinal)?;
3436        let base = epub_dir_of(item.resolved.as_deref().unwrap_or(""));
3437        let entries = crate::adapter::epub::parse_nav_document(&bytes, &base, self.limits)?;
3438        let e = entries
3439            .get(index as usize)
3440            .ok_or_else(|| Error::unsupported_feature(format!("no EPUB nav entry {index}")))?;
3441        let json = epub_nav_json(index, e);
3442        Ok(self.epub_answer(
3443            req,
3444            AnswerValue::Json(json),
3445            format!("epub;nav-node={index}"),
3446            None,
3447            Vec::new(),
3448        ))
3449    }
3450}
3451
3452// ---------------------------------------------------------------------------
3453// Common (format-neutral) observations (Phase 12.7)
3454// ---------------------------------------------------------------------------
3455
3456impl<S: SeedStore> Ctx<'_, S> {
3457    /// The detected document format recorded in the manifest provenance.
3458    fn document_format(&self) -> Option<DocumentFormat> {
3459        DocumentFormat::from_provenance(&self.manifest.provenance)
3460    }
3461
3462    /// Tag a native answer with the common layer's format + native provenance.
3463    fn tag_common(&self, fmt: DocumentFormat, mut answer: FieldAnswer) -> FieldAnswer {
3464        answer.provenance = format!("format={};common;{}", fmt.name(), answer.provenance);
3465        answer
3466    }
3467
3468    /// Dispatch a common selector through the detected format's adapter.
3469    fn common_dispatch(&mut self, req: &ObserveRequest) -> Result<FieldAnswer> {
3470        use crate::field::capabilities;
3471        let fmt = self.document_format().ok_or_else(|| {
3472            Error::unsupported_feature(
3473                "field manifest does not record a document format; common observations are unavailable",
3474            )
3475        })?;
3476        if !capabilities::common_supported(fmt, &req.selector, req.representation) {
3477            return Err(Error::unsupported_feature(format!(
3478                "unsupported common observation: format {} does not support selector {} with representation {}",
3479                fmt.name(),
3480                req.selector.canonical(),
3481                req.representation.name()
3482            )));
3483        }
3484        let answer = match fmt {
3485            DocumentFormat::Pdf => self.common_pdf(req)?,
3486            DocumentFormat::Docx => self.common_docx(req)?,
3487            DocumentFormat::Epub => self.common_epub(req)?,
3488            DocumentFormat::Opaque => {
3489                return Err(Error::unsupported_feature(
3490                    "opaque fields have no common observations",
3491                ));
3492            }
3493        };
3494        Ok(self.tag_common(fmt, answer))
3495    }
3496
3497    // -- PDF ---------------------------------------------------------------
3498
3499    fn common_pdf(&mut self, req: &ObserveRequest) -> Result<FieldAnswer> {
3500        match &req.selector {
3501            Selector::Metadata => {
3502                let mut a = self.document_metadata(req)?;
3503                a.provenance = "pdf;document-metadata".to_string();
3504                Ok(a)
3505            }
3506            Selector::Text => self.pdf_document_text(req),
3507            Selector::SearchMatch(p) => self.text_match(req, p),
3508            other => Err(Error::unsupported_feature(format!(
3509                "PDF does not support common selector {}",
3510                other.canonical()
3511            ))),
3512        }
3513    }
3514
3515    /// The whole-document reading text of a PDF: every recovered page's text in
3516    /// page order. Bounded by the output budget and the page-scan cap.
3517    fn pdf_document_text(&mut self, req: &ObserveRequest) -> Result<FieldAnswer> {
3518        let mut out = String::new();
3519        let mut pages: u64 = 0;
3520        let mut page: u32 = 1;
3521        while page <= MAX_TEXTMATCH_PAGES {
3522            let entries = self.lookup(SelectorKey::new(SEL_PAGE, page))?;
3523            let Some(entry) = entries.into_iter().next() else {
3524                break;
3525            };
3526            let pc = entry.node_id;
3527            let (_ops, text, _preview) = self.ensure_page_derived(page, pc)?;
3528            let bytes = self.materialize(&text)?;
3529            out.push_str(&String::from_utf8_lossy(&bytes));
3530            if !out.ends_with('\n') {
3531                out.push('\n');
3532            }
3533            if out.len() as u64 > req.budget.max_output_bytes {
3534                return Err(Error::resource_limit(format!(
3535                    "whole-document text exceeded the {}-byte budget",
3536                    req.budget.max_output_bytes
3537                )));
3538            }
3539            pages += 1;
3540            page += 1;
3541        }
3542        Ok(FieldAnswer {
3543            value: AnswerValue::Text(out),
3544            basis: Basis::Heuristic,
3545            selector: req.selector.canonical(),
3546            representation: req.representation.name().to_string(),
3547            source_span: None,
3548            provenance: format!("pdf;pages={pages}"),
3549            dependency_ids: Vec::new(),
3550            integrity_scope: IntegrityScope::None,
3551            exact: false,
3552        })
3553    }
3554
3555    // -- DOCX --------------------------------------------------------------
3556
3557    #[cfg(feature = "docx")]
3558    fn common_docx(&mut self, req: &ObserveRequest) -> Result<FieldAnswer> {
3559        let profile = DocxExtractProfile::DEFAULT;
3560        match &req.selector {
3561            Selector::Metadata => self.docx_common_metadata(req, &profile),
3562            Selector::Text => self.docx_story_text(req, DocxStory::Main, &profile),
3563            Selector::Heading(i) => self.docx_common_heading(req, *i, &profile),
3564            Selector::Block(i) => self.docx_common_block(req, *i, &profile),
3565            Selector::Table(i) => self.docx_table(req, DocxStory::Main, *i, &profile),
3566            Selector::Cell { table, row, col } => {
3567                let cell = a1_ref(*col, *row);
3568                self.docx_cell(req, DocxStory::Main, *table, &cell, &profile)
3569            }
3570            Selector::Resource(i) => self.docx_common_resource(req, *i, &profile),
3571            Selector::Link(i) => self.docx_common_link(req, *i, &profile),
3572            Selector::SearchMatch(p) => self.docx_find(req, DocxStory::Main, p, &profile),
3573            other => Err(Error::unsupported_feature(format!(
3574                "DOCX does not support common selector {}",
3575                other.canonical()
3576            ))),
3577        }
3578    }
3579
3580    #[cfg(not(feature = "docx"))]
3581    fn common_docx(&mut self, _req: &ObserveRequest) -> Result<FieldAnswer> {
3582        Err(Error::unsupported_feature(
3583            "DOCX observations require a build with the docx feature",
3584        ))
3585    }
3586
3587    #[cfg(feature = "docx")]
3588    fn docx_common_metadata(
3589        &mut self,
3590        req: &ObserveRequest,
3591        profile: &DocxExtractProfile,
3592    ) -> Result<FieldAnswer> {
3593        let v = self.docx_story_view(DocxStory::Main, profile)?;
3594        let json = format!(
3595            concat!(
3596                "{{\"format\":\"docx\",\"story\":\"main\",\"part\":\"{}\",\"ordinal\":{},",
3597                "\"root\":\"{}\",\"blocks\":{},\"paragraphs\":{},\"tables\":{},",
3598                "\"hyperlinks\":{},\"bookmarks\":{},\"resources\":{},\"sections\":{},",
3599                "\"profile\":\"{}\"}}"
3600            ),
3601            json_escape(&v.part.name),
3602            v.part.ordinal,
3603            json_escape(&v.model.root_local),
3604            v.model.blocks.len(),
3605            v.model.paragraphs().count(),
3606            v.model.tables().count(),
3607            v.model.hyperlinks.len(),
3608            v.model.bookmarks.len(),
3609            v.model.resources.len(),
3610            v.model.section_count,
3611            profile.fingerprint(),
3612        );
3613        let provenance = format!("docx;story=main;part={}", v.part.name);
3614        Ok(self.docx_answer(req, AnswerValue::Json(json), provenance, v.span, v.deps))
3615    }
3616
3617    #[cfg(feature = "docx")]
3618    fn docx_common_heading(
3619        &mut self,
3620        req: &ObserveRequest,
3621        ordinal: u32,
3622        profile: &DocxExtractProfile,
3623    ) -> Result<FieldAnswer> {
3624        let v = self.docx_story_view(DocxStory::Main, profile)?;
3625        let p = v
3626            .model
3627            .paragraphs()
3628            .filter(|p| p.heading_level.is_some())
3629            .nth(ordinal as usize)
3630            .ok_or_else(|| {
3631                Error::unsupported_feature(format!("DOCX main story has no heading {ordinal}"))
3632            })?;
3633        let index = p.index;
3634        let text = p.text.clone();
3635        let level = p.heading_level;
3636        let style = p.style_id.clone();
3637        let value = match req.representation {
3638            Representation::Text => AnswerValue::Text(text),
3639            Representation::Metadata => AnswerValue::Json(format!(
3640                "{{\"story\":\"main\",\"heading\":{ordinal},\"paragraph\":{index},\"level\":{},\"style\":{},\"text_len\":{}}}",
3641                opt_u8_json(level),
3642                opt_str_json(style.as_deref()),
3643                text.len()
3644            )),
3645            _ => return Err(unsupported_common(req)),
3646        };
3647        let provenance = format!(
3648            "docx;story=main;part={};heading={ordinal};paragraph={index};profile={}",
3649            v.part.name,
3650            profile.fingerprint()
3651        );
3652        Ok(self.docx_answer(req, value, provenance, v.span, v.deps))
3653    }
3654
3655    #[cfg(feature = "docx")]
3656    fn docx_common_block(
3657        &mut self,
3658        req: &ObserveRequest,
3659        ordinal: u32,
3660        profile: &DocxExtractProfile,
3661    ) -> Result<FieldAnswer> {
3662        use crate::adapter::docx::wml::Block as WmlBlock;
3663        let v = self.docx_story_view(DocxStory::Main, profile)?;
3664        let b = v.model.blocks.get(ordinal as usize).ok_or_else(|| {
3665            Error::unsupported_feature(format!("DOCX main story has no block {ordinal}"))
3666        })?;
3667        let (kind, text, detail) = match b {
3668            WmlBlock::Paragraph(p) => (
3669                "paragraph",
3670                p.text.clone(),
3671                format!(
3672                    "\"paragraph\":{},\"level\":{}",
3673                    p.index,
3674                    opt_u8_json(p.heading_level)
3675                ),
3676            ),
3677            WmlBlock::Table(t) => (
3678                "table",
3679                t.text(),
3680                format!("\"table\":{},\"rows\":{}", t.index, t.rows.len()),
3681            ),
3682        };
3683        let value = match req.representation {
3684            Representation::Text => AnswerValue::Text(text),
3685            Representation::Metadata => AnswerValue::Json(format!(
3686                "{{\"story\":\"main\",\"block\":{ordinal},\"kind\":\"{kind}\",{detail},\"text_len\":{}}}",
3687                text.len()
3688            )),
3689            _ => return Err(unsupported_common(req)),
3690        };
3691        let provenance = format!(
3692            "docx;story=main;part={};block={ordinal};profile={}",
3693            v.part.name,
3694            profile.fingerprint()
3695        );
3696        Ok(self.docx_answer(req, value, provenance, v.span, v.deps))
3697    }
3698
3699    #[cfg(feature = "docx")]
3700    fn docx_common_resource(
3701        &mut self,
3702        req: &ObserveRequest,
3703        ordinal: u32,
3704        profile: &DocxExtractProfile,
3705    ) -> Result<FieldAnswer> {
3706        let v = self.docx_story_view(DocxStory::Main, profile)?;
3707        let rel = v.model.resources.get(ordinal as usize).ok_or_else(|| {
3708            Error::unsupported_feature(format!("DOCX main story has no resource {ordinal}"))
3709        })?;
3710        let json = format!(
3711            "{{\"story\":\"main\",\"resource\":{ordinal},\"rel\":\"{}\"}}",
3712            json_escape(rel)
3713        );
3714        let provenance = format!(
3715            "docx;story=main;part={};resource={ordinal};profile={}",
3716            v.part.name,
3717            profile.fingerprint()
3718        );
3719        Ok(self.docx_answer(req, AnswerValue::Json(json), provenance, v.span, v.deps))
3720    }
3721
3722    #[cfg(feature = "docx")]
3723    fn docx_common_link(
3724        &mut self,
3725        req: &ObserveRequest,
3726        ordinal: u32,
3727        profile: &DocxExtractProfile,
3728    ) -> Result<FieldAnswer> {
3729        let v = self.docx_story_view(DocxStory::Main, profile)?;
3730        let l = v.model.hyperlinks.get(ordinal as usize).ok_or_else(|| {
3731            Error::unsupported_feature(format!("DOCX main story has no hyperlink {ordinal}"))
3732        })?;
3733        let json = format!(
3734            "{{\"story\":\"main\",\"link\":{ordinal},\"text\":\"{}\",\"rel_id\":{},\"anchor\":{}}}",
3735            json_escape(&l.text),
3736            opt_str_json(l.rel_id.as_deref()),
3737            opt_str_json(l.anchor.as_deref())
3738        );
3739        let provenance = format!(
3740            "docx;story=main;part={};link={ordinal};profile={}",
3741            v.part.name,
3742            profile.fingerprint()
3743        );
3744        Ok(self.docx_answer(req, AnswerValue::Json(json), provenance, v.span, v.deps))
3745    }
3746
3747    // -- EPUB --------------------------------------------------------------
3748
3749    #[cfg(feature = "epub")]
3750    fn common_epub(&mut self, req: &ObserveRequest) -> Result<FieldAnswer> {
3751        let profile = EpubExtractProfile::DEFAULT;
3752        match &req.selector {
3753            Selector::Metadata => self.epub_package(req),
3754            Selector::Text => self.epub_common_text(req, &profile),
3755            Selector::Heading(i) => self.epub_common_block(req, *i, true, &profile),
3756            Selector::Block(i) => self.epub_common_block(req, *i, false, &profile),
3757            Selector::Table(i) => self.epub_common_table(req, *i, &profile),
3758            Selector::Cell { table, row, col } => {
3759                self.epub_common_cell(req, *table, *row, *col, &profile)
3760            }
3761            Selector::Resource(i) => self.epub_common_resource(req, *i, &profile),
3762            Selector::Link(i) => self.epub_common_link(req, *i, &profile),
3763            Selector::SearchMatch(p) => self.epub_common_search(req, p, &profile),
3764            other => Err(Error::unsupported_feature(format!(
3765                "EPUB does not support common selector {}",
3766                other.canonical()
3767            ))),
3768        }
3769    }
3770
3771    #[cfg(not(feature = "epub"))]
3772    fn common_epub(&mut self, _req: &ObserveRequest) -> Result<FieldAnswer> {
3773        Err(Error::unsupported_feature(
3774            "EPUB observations require a build with the epub feature",
3775        ))
3776    }
3777
3778    #[cfg(feature = "epub")]
3779    fn epub_common_text(
3780        &mut self,
3781        req: &ObserveRequest,
3782        profile: &EpubExtractProfile,
3783    ) -> Result<FieldAnswer> {
3784        let doc = self.epub_package_doc()?;
3785        let order_len = doc.reading_order(profile).len() as u32;
3786        let mut out = String::new();
3787        let mut items: u64 = 0;
3788        for index in 0..order_len {
3789            let (model, _item, _span, _deps) = self.epub_content_view(index, profile)?;
3790            out.push_str(&model.text());
3791            if !out.ends_with('\n') {
3792                out.push('\n');
3793            }
3794            if out.len() as u64 > req.budget.max_output_bytes {
3795                return Err(Error::resource_limit(format!(
3796                    "whole-document text exceeded the {}-byte budget",
3797                    req.budget.max_output_bytes
3798                )));
3799            }
3800            items += 1;
3801        }
3802        let provenance = format!("epub;spine-items={items};profile={}", profile.fingerprint());
3803        Ok(self.epub_answer(req, AnswerValue::Text(out), provenance, None, Vec::new()))
3804    }
3805
3806    /// The `ordinal`-th block across the reading order; `headings_only` restricts
3807    /// the count to heading blocks.
3808    #[cfg(feature = "epub")]
3809    fn epub_common_block(
3810        &mut self,
3811        req: &ObserveRequest,
3812        ordinal: u32,
3813        headings_only: bool,
3814        profile: &EpubExtractProfile,
3815    ) -> Result<FieldAnswer> {
3816        let doc = self.epub_package_doc()?;
3817        let order_len = doc.reading_order(profile).len() as u32;
3818        let mut remaining = ordinal as usize;
3819        for index in 0..order_len {
3820            let (model, item, span, deps) = self.epub_content_view(index, profile)?;
3821            for (local, b) in model.blocks.iter().enumerate() {
3822                if headings_only && !matches!(b, crate::adapter::epub::Block::Heading { .. }) {
3823                    continue;
3824                }
3825                if remaining == 0 {
3826                    let text = b.text();
3827                    let value = match req.representation {
3828                        Representation::Text => AnswerValue::Text(text),
3829                        Representation::Metadata => {
3830                            AnswerValue::Json(epub_block_json(local as u32, b))
3831                        }
3832                        _ => return Err(unsupported_common(req)),
3833                    };
3834                    let provenance = format!(
3835                        "epub;spine={index};part={};block={local};ordinal={ordinal};profile={}",
3836                        item.resolved.as_deref().unwrap_or(""),
3837                        profile.fingerprint()
3838                    );
3839                    return Ok(self.epub_answer(req, value, provenance, span, deps));
3840                }
3841                remaining -= 1;
3842            }
3843        }
3844        let what = if headings_only { "heading" } else { "block" };
3845        Err(Error::unsupported_feature(format!(
3846            "EPUB reading order has no {what} {ordinal}"
3847        )))
3848    }
3849
3850    #[cfg(feature = "epub")]
3851    fn epub_common_table(
3852        &mut self,
3853        req: &ObserveRequest,
3854        ordinal: u32,
3855        profile: &EpubExtractProfile,
3856    ) -> Result<FieldAnswer> {
3857        let doc = self.epub_package_doc()?;
3858        let order_len = doc.reading_order(profile).len() as u32;
3859        let mut remaining = ordinal as usize;
3860        for index in 0..order_len {
3861            let (model, item, span, deps) = self.epub_content_view(index, profile)?;
3862            for (local, b) in model.blocks.iter().enumerate() {
3863                if !matches!(b, crate::adapter::epub::Block::Table { .. }) {
3864                    continue;
3865                }
3866                if remaining == 0 {
3867                    let value = match req.representation {
3868                        Representation::Text => AnswerValue::Text(b.text()),
3869                        Representation::Metadata => {
3870                            AnswerValue::Json(epub_block_json(local as u32, b))
3871                        }
3872                        _ => return Err(unsupported_common(req)),
3873                    };
3874                    let provenance = format!(
3875                        "epub;spine={index};part={};table={local};ordinal={ordinal};profile={}",
3876                        item.resolved.as_deref().unwrap_or(""),
3877                        profile.fingerprint()
3878                    );
3879                    return Ok(self.epub_answer(req, value, provenance, span, deps));
3880                }
3881                remaining -= 1;
3882            }
3883        }
3884        Err(Error::unsupported_feature(format!(
3885            "EPUB reading order has no table {ordinal}"
3886        )))
3887    }
3888
3889    #[cfg(feature = "epub")]
3890    fn epub_common_cell(
3891        &mut self,
3892        req: &ObserveRequest,
3893        table: u32,
3894        row: u32,
3895        col: u32,
3896        profile: &EpubExtractProfile,
3897    ) -> Result<FieldAnswer> {
3898        use crate::adapter::epub::Block as EBlock;
3899        let doc = self.epub_package_doc()?;
3900        let order_len = doc.reading_order(profile).len() as u32;
3901        let mut remaining = table as usize;
3902        for index in 0..order_len {
3903            let (model, item, span, deps) = self.epub_content_view(index, profile)?;
3904            for (local, b) in model.blocks.iter().enumerate() {
3905                let EBlock::Table { rows } = b else { continue };
3906                if remaining > 0 {
3907                    remaining -= 1;
3908                    continue;
3909                }
3910                let r = rows.get(row as usize).ok_or_else(|| {
3911                    Error::unsupported_feature(format!("EPUB table {table} has no row {row}"))
3912                })?;
3913                let c = r.cells.get(col as usize).ok_or_else(|| {
3914                    Error::unsupported_feature(format!(
3915                        "EPUB table {table} row {row} has no cell {col}"
3916                    ))
3917                })?;
3918                let value = match req.representation {
3919                    Representation::Text => AnswerValue::Text(c.text.clone()),
3920                    Representation::Metadata => {
3921                        AnswerValue::Json(epub_cell_json(table, row, col, c))
3922                    }
3923                    _ => return Err(unsupported_common(req)),
3924                };
3925                let provenance = format!(
3926                    "epub;spine={index};part={};table={local};row={row};col={col};profile={}",
3927                    item.resolved.as_deref().unwrap_or(""),
3928                    profile.fingerprint()
3929                );
3930                return Ok(self.epub_answer(req, value, provenance, span, deps));
3931            }
3932        }
3933        Err(Error::unsupported_feature(format!(
3934            "EPUB reading order has no table {table}"
3935        )))
3936    }
3937
3938    #[cfg(feature = "epub")]
3939    fn epub_common_resource(
3940        &mut self,
3941        req: &ObserveRequest,
3942        ordinal: u32,
3943        profile: &EpubExtractProfile,
3944    ) -> Result<FieldAnswer> {
3945        let doc = self.epub_package_doc()?;
3946        let resources: Vec<&ManifestItem> = doc
3947            .manifest
3948            .iter()
3949            .filter(|i| !is_document_media_type(&i.media_type))
3950            .collect();
3951        let item = resources
3952            .get(ordinal as usize)
3953            .copied()
3954            .ok_or_else(|| Error::unsupported_feature(format!("EPUB has no resource {ordinal}")))?;
3955        if req.representation == Representation::Metadata {
3956            let json = format!(
3957                concat!(
3958                    "{{\"resource\":{},\"id\":\"{}\",\"href\":\"{}\",",
3959                    "\"media_type\":\"{}\",\"member\":{},\"profile\":\"{}\"}}"
3960                ),
3961                ordinal,
3962                json_escape(&item.id),
3963                json_escape(&item.href),
3964                json_escape(&item.media_type),
3965                epub_opt_str(item.resolved.as_deref()),
3966                profile.fingerprint()
3967            );
3968            let provenance = format!(
3969                "epub;resource={ordinal};id={};profile={}",
3970                item.id,
3971                profile.fingerprint()
3972            );
3973            return Ok(self.epub_answer(
3974                req,
3975                AnswerValue::Json(json),
3976                provenance,
3977                None,
3978                Vec::new(),
3979            ));
3980        }
3981        // Exact/decoded bytes resolve to the container member.
3982        self.epub_item_bytes(req, item)
3983    }
3984
3985    #[cfg(feature = "epub")]
3986    fn epub_common_link(
3987        &mut self,
3988        req: &ObserveRequest,
3989        ordinal: u32,
3990        profile: &EpubExtractProfile,
3991    ) -> Result<FieldAnswer> {
3992        let doc = self.epub_package_doc()?;
3993        let order_len = doc.reading_order(profile).len() as u32;
3994        let mut remaining = ordinal as usize;
3995        for index in 0..order_len {
3996            let (model, item, span, deps) = self.epub_content_view(index, profile)?;
3997            for (local, l) in model.links.iter().enumerate() {
3998                if remaining == 0 {
3999                    let provenance = format!(
4000                        "epub;spine={index};part={};link={local};ordinal={ordinal};profile={}",
4001                        item.resolved.as_deref().unwrap_or(""),
4002                        profile.fingerprint()
4003                    );
4004                    return Ok(self.epub_answer(
4005                        req,
4006                        AnswerValue::Json(epub_link_json(local as u32, l)),
4007                        provenance,
4008                        span,
4009                        deps,
4010                    ));
4011                }
4012                remaining -= 1;
4013            }
4014        }
4015        Err(Error::unsupported_feature(format!(
4016            "EPUB reading order has no link {ordinal}"
4017        )))
4018    }
4019
4020    #[cfg(feature = "epub")]
4021    fn epub_common_search(
4022        &mut self,
4023        req: &ObserveRequest,
4024        pattern: &str,
4025        profile: &EpubExtractProfile,
4026    ) -> Result<FieldAnswer> {
4027        let doc = self.epub_package_doc()?;
4028        let order_len = doc.reading_order(profile).len() as u32;
4029        let mut items: Vec<String> = Vec::new();
4030        let mut estimated: u64 = 0;
4031        for index in 0..order_len {
4032            let (model, item, _span, _deps) = self.epub_content_view(index, profile)?;
4033            let _ = item;
4034            for (local, b) in model.blocks.iter().enumerate() {
4035                let t = b.text();
4036                if t.contains(pattern) {
4037                    estimated = estimated.saturating_add(t.len() as u64 + 64);
4038                    if estimated > req.budget.max_output_bytes {
4039                        return Err(Error::resource_limit(format!(
4040                            "EPUB search exceeded the {}-byte budget",
4041                            req.budget.max_output_bytes
4042                        )));
4043                    }
4044                    items.push(format!(
4045                        "{{\"spine\":{index},\"block\":{local},\"kind\":\"{}\",\"text\":\"{}\"}}",
4046                        b.kind(),
4047                        json_escape(&t)
4048                    ));
4049                }
4050            }
4051        }
4052        let provenance = format!("epub;search;profile={}", profile.fingerprint());
4053        Ok(self.epub_answer(
4054            req,
4055            AnswerValue::Json(format!("[{}]", items.join(","))),
4056            provenance,
4057            None,
4058            Vec::new(),
4059        ))
4060    }
4061}
4062
4063/// The standard `unsupported observation` error for a common pair that reached a
4064/// representation the capability guard admitted but the adapter does not serve.
4065#[cfg(any(feature = "docx", feature = "epub"))]
4066fn unsupported_common(req: &ObserveRequest) -> Error {
4067    Error::unsupported_feature(format!(
4068        "unsupported observation: selector {} with representation {}",
4069        req.selector.canonical(),
4070        req.representation.name()
4071    ))
4072}
4073
4074/// Convert a 0-based grid column and row into an A1-style reference (`B7`).
4075#[cfg(feature = "docx")]
4076fn a1_ref(col: u32, row: u32) -> String {
4077    let mut c = col + 1;
4078    let mut letters: Vec<char> = Vec::new();
4079    while c > 0 {
4080        let rem = ((c - 1) % 26) as u8;
4081        letters.push((b'A' + rem) as char);
4082        c = (c - 1) / 26;
4083    }
4084    letters.reverse();
4085    format!("{}{}", letters.into_iter().collect::<String>(), row + 1)
4086}
4087
4088/// Whether a manifest media type is a reading document (not a binary resource).
4089#[cfg(feature = "epub")]
4090fn is_document_media_type(media_type: &str) -> bool {
4091    media_type == "application/xhtml+xml"
4092        || media_type == "application/oebps-package+xml"
4093        || media_type == "application/x-dtbncx+xml"
4094}
4095
4096/// The deterministic Stage-C chain beneath a page's `PageContent` node. Must
4097/// match `ingest::derived_chain` byte-for-byte so node ids coincide.
4098pub(crate) fn derived_nodes(page: u32, page_content: NodeId) -> (SeedNode, SeedNode, SeedNode) {
4099    let ops = SeedNode::new(
4100        NodeKind::ContentOperators,
4101        0,
4102        u32_params(page),
4103        vec![page_content],
4104        "pdf:content-operators",
4105    );
4106    let text = SeedNode::new(
4107        NodeKind::TextRuns,
4108        0,
4109        Vec::new(),
4110        vec![ops.content_id()],
4111        "pdf:text-runs",
4112    );
4113    let preview = SeedNode::new(
4114        NodeKind::PagePreview,
4115        0,
4116        u32_params(page),
4117        vec![page_content],
4118        "pdf:page-preview",
4119    );
4120    (ops, text, preview)
4121}
4122
4123/// Parse the numeric fields of a `PagePreview` header.
4124fn preview_stats(bytes: &[u8]) -> (u64, u64, u64) {
4125    let rendered = String::from_utf8_lossy(bytes);
4126    let mut text_bytes = 0u64;
4127    let mut draw_ops = 0u64;
4128    let mut path_ops = 0u64;
4129    for line in rendered.lines() {
4130        if let Some(v) = line.strip_prefix("text-bytes ") {
4131            text_bytes = v.trim().parse().unwrap_or(0);
4132        } else if let Some(v) = line.strip_prefix("draw-ops ") {
4133            draw_ops = v.trim().parse().unwrap_or(0);
4134        } else if let Some(v) = line.strip_prefix("path-ops ") {
4135            path_ops = v.trim().parse().unwrap_or(0);
4136        }
4137    }
4138    (text_bytes, draw_ops, path_ops)
4139}
4140
4141#[cfg(test)]
4142mod tests {
4143    use super::*;
4144    use crate::container::{Descriptor, ObjectSource};
4145    use crate::dra::{Op, Program};
4146    use crate::field::plan;
4147    use crate::store::FsSeedStore;
4148    use std::fs;
4149    use std::path::PathBuf;
4150
4151    fn temp_root(label: &str) -> PathBuf {
4152        let mut p = std::env::temp_dir();
4153        p.push(format!(
4154            "vole-observe-{label}-{}-{}",
4155            std::process::id(),
4156            std::time::SystemTime::now()
4157                .duration_since(std::time::UNIX_EPOCH)
4158                .unwrap()
4159                .as_nanos()
4160        ));
4161        p
4162    }
4163
4164    fn opaque_descriptor(source: &[u8]) -> Vec<u8> {
4165        let d = Descriptor {
4166            universe: crate::container::UNIVERSE.to_string(),
4167            source_format: crate::SOURCE_FORMAT_PDF,
4168            format_basis: "pdf:observe-test".to_string(),
4169            models: vec![],
4170            channels: vec![],
4171            objects: vec![ObjectSource::Inline(source.to_vec())],
4172            program: Program::new(vec![Op::EmitObject { object_id: 0 }]),
4173            observation_index: None,
4174            seek_directory: false,
4175            source_sha256: crate::integrity::sha256(source),
4176            source_len: source.len() as u64,
4177        };
4178        d.serialize().unwrap().0
4179    }
4180
4181    fn adler32(data: &[u8]) -> u32 {
4182        let mut a: u32 = 1;
4183        let mut b: u32 = 0;
4184        for &byte in data {
4185            a = (a + u32::from(byte)) % 65521;
4186            b = (b + a) % 65521;
4187        }
4188        (b << 16) | a
4189    }
4190
4191    fn zlib_stored(data: &[u8]) -> Vec<u8> {
4192        assert!(!data.is_empty());
4193        let mut out = vec![0x78, 0x01];
4194        let chunks: Vec<&[u8]> = data.chunks(0xFFFF).collect();
4195        for (i, chunk) in chunks.iter().enumerate() {
4196            let final_block = u8::from(i + 1 == chunks.len());
4197            out.push(final_block);
4198            let len = chunk.len() as u16;
4199            out.extend_from_slice(&len.to_le_bytes());
4200            out.extend_from_slice(&(!len).to_le_bytes());
4201            out.extend_from_slice(chunk);
4202        }
4203        out.extend_from_slice(&adler32(data).to_be_bytes());
4204        out
4205    }
4206
4207    struct PdfBuilder {
4208        buf: Vec<u8>,
4209        offsets: Vec<(u64, u64)>,
4210    }
4211
4212    impl PdfBuilder {
4213        fn new() -> Self {
4214            PdfBuilder {
4215                buf: Vec::new(),
4216                offsets: Vec::new(),
4217            }
4218        }
4219        fn text(&mut self, s: &str) {
4220            self.buf.extend_from_slice(s.as_bytes());
4221        }
4222        fn raw(&mut self, b: &[u8]) {
4223            self.buf.extend_from_slice(b);
4224        }
4225        fn obj(&mut self, number: u64, body: &[u8]) {
4226            self.offsets.push((number, self.buf.len() as u64));
4227            self.text(&format!("{number} 0 obj\n"));
4228            self.raw(body);
4229            self.text("\nendobj\n");
4230        }
4231        fn stream_obj(&mut self, number: u64, extra: &str, data: &[u8]) {
4232            self.offsets.push((number, self.buf.len() as u64));
4233            self.text(&format!(
4234                "{number} 0 obj\n<< /Length {}{extra} >>\nstream\n",
4235                data.len()
4236            ));
4237            self.raw(data);
4238            self.text("\nendstream\nendobj\n");
4239        }
4240        fn offset_of(&self, number: u64) -> u64 {
4241            self.offsets
4242                .iter()
4243                .find(|&&(n, _)| n == number)
4244                .map(|&(_, off)| off)
4245                .unwrap()
4246        }
4247        fn classic_trailer(&mut self, size: u64, extra: &str) {
4248            let xref = self.buf.len() as u64;
4249            self.text(&format!("xref\n0 {size}\n"));
4250            self.raw(b"0000000000 65535 f \n");
4251            for number in 1..size {
4252                let off = self.offset_of(number);
4253                self.text(&format!("{off:010} 00000 n \n"));
4254            }
4255            self.text(&format!(
4256                "trailer\n<< /Size {size}{extra} >>\nstartxref\n{xref}\n%%EOF\n"
4257            ));
4258        }
4259    }
4260
4261    /// A classic-xref PDF with one page, a lone-Flate content stream containing
4262    /// `(Hello) Tj`, and (optionally) a large image XObject.
4263    fn fixture_pdf(with_image: bool) -> Vec<u8> {
4264        let content = b"BT /F1 12 Tf 72 720 Td (Hello) Tj ET\n";
4265        let encoded = zlib_stored(content);
4266        let mut w = PdfBuilder::new();
4267        w.text("%PDF-1.5\n");
4268        w.obj(1, b"<< /Type /Catalog /Pages 2 0 R >>");
4269        w.obj(2, b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
4270        if with_image {
4271            w.obj(
4272                3,
4273                b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 5 0 R >> /XObject << /Im0 6 0 R >> >> /Contents 4 0 R >>",
4274            );
4275        } else {
4276            w.obj(
4277                3,
4278                b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>",
4279            );
4280        }
4281        w.stream_obj(4, " /Filter /FlateDecode", &encoded);
4282        w.obj(5, b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>");
4283        if with_image {
4284            let image = vec![0x80u8; 256 * 256];
4285            let image_encoded = zlib_stored(&image);
4286            w.stream_obj(
4287                6,
4288                " /Type /XObject /Subtype /Image /Width 256 /Height 256 /ColorSpace /DeviceGray /BitsPerComponent 8 /Filter /FlateDecode",
4289                &image_encoded,
4290            );
4291            w.classic_trailer(7, " /Root 1 0 R");
4292        } else {
4293            w.classic_trailer(6, " /Root 1 0 R");
4294        }
4295        w.buf
4296    }
4297
4298    struct Fixture {
4299        root: PathBuf,
4300        store: FieldStore,
4301        field: FieldId,
4302        source: Vec<u8>,
4303    }
4304
4305    impl Fixture {
4306        fn new(label: &str, with_image: bool) -> Fixture {
4307            let root = temp_root(label);
4308            let mut store = FieldStore::open(&root).unwrap();
4309            let source = fixture_pdf(with_image);
4310            let descriptor = opaque_descriptor(&source);
4311            let report = ingest::ingest_pdf(&mut store, &descriptor, Limits::DEFAULT).unwrap();
4312            Fixture {
4313                root,
4314                store,
4315                field: report.field,
4316                source,
4317            }
4318        }
4319    }
4320
4321    impl Drop for Fixture {
4322        fn drop(&mut self) {
4323            fs::remove_dir_all(&self.root).ok();
4324        }
4325    }
4326
4327    fn observe_req(
4328        fx: &mut Fixture,
4329        selector: Selector,
4330        representation: Representation,
4331    ) -> (FieldAnswer, ObserveStats) {
4332        let req = ObserveRequest::new(selector, representation);
4333        let (answer, stats, _field) =
4334            observe(&mut fx.store, &fx.field, &req, Limits::DEFAULT).unwrap();
4335        (answer, stats)
4336    }
4337
4338    #[test]
4339    fn document_full_document_is_exact() {
4340        let mut fx = Fixture::new("full", false);
4341        let (answer, stats) =
4342            observe_req(&mut fx, Selector::Document, Representation::FullDocument);
4343        assert_eq!(answer.value, AnswerValue::Bytes(fx.source.clone()));
4344        assert_eq!(answer.basis, Basis::DirectlyObserved);
4345        assert_eq!(answer.integrity_scope, IntegrityScope::WholeSource);
4346        assert!(answer.exact);
4347        assert_eq!(answer.source_span, Some((0, fx.source.len() as u64)));
4348        assert_eq!(stats.bytes_returned, fx.source.len() as u64);
4349    }
4350
4351    #[test]
4352    fn byte_range_returns_exact_bytes() {
4353        let mut fx = Fixture::new("range", false);
4354        let (answer, _) = observe_req(
4355            &mut fx,
4356            Selector::ByteRange { offset: 9, len: 8 },
4357            Representation::ExactBytes,
4358        );
4359        assert_eq!(answer.value, AnswerValue::Bytes(fx.source[9..17].to_vec()));
4360        assert_eq!(answer.source_span, Some((9, 17)));
4361        assert!(answer.exact);
4362        assert_eq!(answer.integrity_scope, IntegrityScope::Node);
4363    }
4364
4365    #[test]
4366    fn page_text_is_heuristic_and_nonempty() {
4367        let mut fx = Fixture::new("text", false);
4368        let (answer, _) = observe_req(&mut fx, Selector::Page(1), Representation::Text);
4369        match &answer.value {
4370            AnswerValue::Text(t) => assert!(t.contains("Hello"), "got {t:?}"),
4371            other => panic!("expected text, got {other:?}"),
4372        }
4373        assert_eq!(answer.basis, Basis::Heuristic);
4374        assert!(!answer.exact);
4375    }
4376
4377    /// The *procedural/seed closure* of a page-structure observation excludes the
4378    /// image seed node. This is a statement about **seed reads only**: the
4379    /// descriptor blob is charged separately in `descriptor_bytes_read`, and
4380    /// `bytes_read` is the sum of all four classes, so this must never be read as
4381    /// "the OS avoided reading the whole document" (review fix #1/#2).
4382    #[test]
4383    fn page_structure_procedural_closure_excludes_image_seed_node() {
4384        let mut fx = Fixture::new("structure", true);
4385        let (answer, stats) = observe_req(&mut fx, Selector::Page(1), Representation::Structure);
4386        match &answer.value {
4387            AnswerValue::Json(j) => {
4388                assert!(j.contains("\"page\":1"), "got {j}");
4389                assert!(j.contains("\"content_streams\":[4]"), "got {j}");
4390            }
4391            other => panic!("expected json, got {other:?}"),
4392        }
4393        assert_eq!(answer.basis, Basis::DeterministicallyDerived);
4394        assert!(!answer.exact);
4395        // Seed closure: the 64 KiB image payload's seed node is never fetched.
4396        assert!(
4397            stats.seed_bytes_read < 16 * 1024,
4398            "structure observation read {} seed bytes (expected < 16384)",
4399            stats.seed_bytes_read
4400        );
4401        // The honest total *does* include the descriptor blob, which a narrow
4402        // observation necessarily read to open the field.
4403        assert!(
4404            stats.descriptor_bytes_read > 0,
4405            "the descriptor read must be accounted, not hidden"
4406        );
4407        assert!(
4408            stats.bytes_read >= stats.descriptor_bytes_read,
4409            "bytes_read {} must include descriptor_bytes_read {}",
4410            stats.bytes_read,
4411            stats.descriptor_bytes_read
4412        );
4413        assert_eq!(
4414            stats.bytes_read,
4415            stats
4416                .descriptor_bytes_read
4417                .saturating_add(stats.manifest_bytes_read)
4418                .saturating_add(stats.index_bytes_read)
4419                .saturating_add(stats.seed_bytes_read),
4420            "bytes_read must be the exact sum of the four physical classes"
4421        );
4422    }
4423
4424    #[test]
4425    fn plan_is_pure_and_deterministic() {
4426        let fx = Fixture::new("plan", false);
4427        let req = ObserveRequest::new(Selector::Page(1), Representation::Text);
4428        let field = Field::open(&fx.store, &fx.field, Limits::DEFAULT).unwrap();
4429        let before = fx.store.seeds().list_nodes().unwrap().len();
4430        let a = plan::plan(field.manifest(), &fx.store, &req).unwrap();
4431        let b = plan::plan(field.manifest(), &fx.store, &req).unwrap();
4432        assert_eq!(a, b);
4433        let after = fx.store.seeds().list_nodes().unwrap().len();
4434        assert_eq!(before, after, "plan must not add seed nodes");
4435    }
4436
4437    #[test]
4438    fn explain_json_keys_and_unsupported_pair() {
4439        use crate::field::explain;
4440        let mut fx = Fixture::new("explain", false);
4441        let req = ObserveRequest::new(Selector::Document, Representation::FullDocument);
4442        let (plan, actual) =
4443            explain::explain_analyze(&mut fx.store, &fx.field, &req, Limits::DEFAULT).unwrap();
4444        assert_eq!(
4445            plan.json,
4446            "{\"selector\":\"document\",\"representation\":\"full\",\"format\":\"pdf\",\"adapter\":\"pdf\",\"capability\":\"native\",\"index_route\":\"hier-index\",\"shape\":\"full_materialize\",\"index_reads\":0,\"required_nodes\":1,\"will_materialize\":[\"DocumentExact\"],\"will_not_materialize\":[]}"
4447        );
4448        let actual_json = actual.to_json();
4449        let mut keys = top_level_keys(&actual_json);
4450        keys.sort();
4451        let mut expected = vec![
4452            "adapter",
4453            "basis",
4454            "bytes_read",
4455            "bytes_returned",
4456            "deepened",
4457            "descriptor_bytes_read",
4458            "descriptor_read_mode",
4459            "exact",
4460            "format",
4461            "index_bytes_read",
4462            "index_nodes_read",
4463            "inverse_work_units",
4464            "manifest_bytes_read",
4465            "member_decodes",
4466            "nodes_id_shared",
4467            "seed_bytes_read",
4468            "seed_nodes_fetched",
4469            "seed_nodes_materialized",
4470            "shared_resource_ids",
4471            "wall_micros",
4472            "whole_source_materialized",
4473            "xml_parses",
4474        ];
4475        expected.sort_unstable();
4476        assert_eq!(keys, expected, "actual json keys: {actual_json}");
4477
4478        // An unsupported selector/representation pair is typed, never guessed.
4479        let bad = ObserveRequest::new(Selector::Document, Representation::Text);
4480        let err = observe(&mut fx.store, &fx.field, &bad, Limits::DEFAULT).unwrap_err();
4481        assert_eq!(err.class(), crate::ErrorClass::UnsupportedFeature);
4482        let field = Field::open(&fx.store, &fx.field, Limits::DEFAULT).unwrap();
4483        let perr = plan::plan(field.manifest(), &fx.store, &bad).unwrap_err();
4484        assert_eq!(perr.class(), crate::ErrorClass::UnsupportedFeature);
4485    }
4486
4487    #[test]
4488    fn budget_yields_resource_limit_not_truncation() {
4489        let mut fx = Fixture::new("budget", false);
4490        let req = ObserveRequest {
4491            selector: Selector::Document,
4492            representation: Representation::FullDocument,
4493            budget: ObserveBudget {
4494                max_output_bytes: 4,
4495                max_nodes: 1 << 20,
4496            },
4497            use_cache: true,
4498        };
4499        let err = observe(&mut fx.store, &fx.field, &req, Limits::DEFAULT).unwrap_err();
4500        assert_eq!(err.class(), crate::ErrorClass::ResourceLimit);
4501    }
4502
4503    #[test]
4504    fn find_returns_matching_line() {
4505        let mut fx = Fixture::new("find", false);
4506        let (answer, _) = observe_req(
4507            &mut fx,
4508            Selector::TextMatch("Hello".to_string()),
4509            Representation::Text,
4510        );
4511        match &answer.value {
4512            AnswerValue::Json(j) => {
4513                assert!(j.contains("\"page\":1"), "got {j}");
4514                assert!(j.contains("Hello"), "got {j}");
4515            }
4516            other => panic!("expected json, got {other:?}"),
4517        }
4518        assert_eq!(answer.basis, Basis::Heuristic);
4519    }
4520
4521    #[test]
4522    fn preview_is_deterministic() {
4523        let mut fx = Fixture::new("preview", false);
4524        let (a, _) = observe_req(&mut fx, Selector::Page(1), Representation::Preview);
4525        let (b, _) = observe_req(&mut fx, Selector::Page(1), Representation::Preview);
4526        assert_eq!(a.value, b.value);
4527        match &a.value {
4528            AnswerValue::Bytes(bytes) => {
4529                assert!(String::from_utf8_lossy(bytes).contains("VOLE-PREVIEW v1"))
4530            }
4531            other => panic!("expected preview bytes, got {other:?}"),
4532        }
4533    }
4534
4535    #[test]
4536    fn decoded_and_operators_streams_resolve() {
4537        let mut fx = Fixture::new("decoded", false);
4538        let (decoded, _) = observe_req(&mut fx, Selector::Stream(4), Representation::DecodedBytes);
4539        match &decoded.value {
4540            AnswerValue::Bytes(b) => {
4541                assert_eq!(b, b"BT /F1 12 Tf 72 720 Td (Hello) Tj ET\n");
4542            }
4543            other => panic!("expected bytes, got {other:?}"),
4544        }
4545        assert_eq!(decoded.basis, Basis::DeterministicallyDerived);
4546        let (ops, _) = observe_req(&mut fx, Selector::Stream(4), Representation::Operators);
4547        assert!(matches!(ops.value, AnswerValue::Bytes(ref b) if !b.is_empty()));
4548    }
4549
4550    /// Every physical byte class is charged, and `bytes_read` is their exact sum.
4551    #[test]
4552    fn observation_byte_classes_sum_and_are_all_charged() {
4553        let mut fx = Fixture::new("io-sum", false);
4554        let (_, stats) = observe_req(&mut fx, Selector::Page(1), Representation::Structure);
4555        assert!(
4556            stats.descriptor_bytes_read > 0,
4557            "descriptor bytes: {stats:?}"
4558        );
4559        assert!(stats.manifest_bytes_read > 0, "manifest bytes: {stats:?}");
4560        assert!(stats.index_bytes_read > 0, "index bytes: {stats:?}");
4561        assert!(stats.seed_bytes_read > 0, "seed bytes: {stats:?}");
4562        assert_eq!(
4563            stats.bytes_read,
4564            stats
4565                .descriptor_bytes_read
4566                .saturating_add(stats.manifest_bytes_read)
4567                .saturating_add(stats.index_bytes_read)
4568                .saturating_add(stats.seed_bytes_read),
4569            "bytes_read must equal the class sum: {stats:?}"
4570        );
4571    }
4572
4573    /// `explain_analyze` must open the descriptor exactly once: the plan and the
4574    /// evaluation share one `Field`, and a Stage-C promotion no longer re-reads it
4575    /// (review fix #2).
4576    #[test]
4577    fn explain_analyze_opens_the_descriptor_once() {
4578        use crate::field::explain;
4579        let mut fx = Fixture::new("opens-once", false);
4580
4581        // A metadata observation does no promotion; one open.
4582        let req = ObserveRequest::new(Selector::Document, Representation::Metadata);
4583        let before = fx.store.io().descriptor_reads();
4584        let (_, actual) =
4585            explain::explain_analyze(&mut fx.store, &fx.field, &req, Limits::DEFAULT).unwrap();
4586        assert_eq!(
4587            fx.store.io().descriptor_reads() - before,
4588            1,
4589            "explain_analyze must open the descriptor exactly once"
4590        );
4591        assert!(
4592            actual.stats.descriptor_bytes_read > 0,
4593            "the single open must still be accounted: {:?}",
4594            actual.stats
4595        );
4596
4597        // A cold page observation *does* promote (Stage C); even then the
4598        // descriptor is read once, because promotion reuses the open manifest.
4599        let page = ObserveRequest::new(Selector::Page(1), Representation::Text);
4600        let before = fx.store.io().descriptor_reads();
4601        let (_, page_actual) =
4602            explain::explain_analyze(&mut fx.store, &fx.field, &page, Limits::DEFAULT).unwrap();
4603        assert_eq!(
4604            fx.store.io().descriptor_reads() - before,
4605            1,
4606            "a cold promotion must not re-open the field for its manifest"
4607        );
4608        assert!(page_actual.stats.deepened, "the cold page must promote");
4609        assert!(page_actual.stats.descriptor_bytes_read > 0);
4610    }
4611
4612    /// A seed store that forbids enumeration and bounds fetches. Putting it on the
4613    /// observation path proves a `Stream(n)+DecodedBytes` resolves through the
4614    /// hierarchical index, never by scanning the store (review fix #3).
4615    struct BoundedSeedStore {
4616        inner: FsSeedStore,
4617        fetches: Cell<u64>,
4618        limit: u64,
4619    }
4620
4621    impl SeedStore for BoundedSeedStore {
4622        fn put_node(&mut self, canonical: &[u8]) -> Result<NodeId> {
4623            self.inner.put_node(canonical)
4624        }
4625
4626        fn get_node(&self, id: &NodeId) -> Result<Vec<u8>> {
4627            let n = self.fetches.get() + 1;
4628            assert!(
4629                n <= self.limit,
4630                "get_node #{n} exceeds the {}-fetch bound: the store was enumerated",
4631                self.limit
4632            );
4633            self.fetches.set(n);
4634            self.inner.get_node(id)
4635        }
4636
4637        fn get_node_range(&self, id: &NodeId, offset: u64, len: u64) -> Result<Vec<u8>> {
4638            let n = self.fetches.get() + 1;
4639            assert!(
4640                n <= self.limit,
4641                "get_node_range #{n} exceeds the {}-fetch bound: the store was enumerated",
4642                self.limit
4643            );
4644            self.fetches.set(n);
4645            self.inner.get_node_range(id, offset, len)
4646        }
4647
4648        fn contains_node(&self, id: &NodeId) -> Result<bool> {
4649            self.inner.contains_node(id)
4650        }
4651
4652        fn list_nodes(&self) -> Result<Vec<(NodeId, u64)>> {
4653            panic!("an observation must never enumerate the seed store")
4654        }
4655    }
4656
4657    #[test]
4658    fn decoded_stream_resolves_without_enumerating_the_store() {
4659        let mut fx = Fixture::new("no-scan", true);
4660        // Many unrelated decoy nodes: a whole-store scan would fetch them all.
4661        for i in 0..256u32 {
4662            let decoy = SeedNode::new(
4663                NodeKind::PdfObject,
4664                1,
4665                u32_params(10_000 + i),
4666                Vec::new(),
4667                "decoy",
4668            );
4669            fx.store
4670                .seeds_mut()
4671                .put_node(&decoy.encode_canonical())
4672                .unwrap();
4673        }
4674        let total = fx.store.seeds().list_nodes().unwrap().len() as u64;
4675        assert!(total > 200, "expected many seed nodes, got {total}");
4676
4677        let field = Field::open(&fx.store, &fx.field, Limits::DEFAULT).unwrap();
4678        let io = fx.store.io().handle();
4679        let seeds = CountingSeedStore::new(BoundedSeedStore {
4680            inner: FsSeedStore::open_with_io(fx.store.root(), io.handle()).unwrap(),
4681            fetches: Cell::new(0),
4682            limit: 8,
4683        });
4684        let istore = FsIndexStore::open_with_io(fx.store.root(), io.handle()).unwrap();
4685        let req = ObserveRequest::new(Selector::Stream(4), Representation::DecodedBytes);
4686        let (answer, stats, _) = observe_with_stores(
4687            &mut fx.store,
4688            FieldView::from_field(&field),
4689            &req,
4690            Limits::DEFAULT,
4691            Instant::now(),
4692            seeds,
4693            istore,
4694        )
4695        .unwrap();
4696        match &answer.value {
4697            AnswerValue::Bytes(b) => {
4698                assert_eq!(b, b"BT /F1 12 Tf 72 720 Td (Hello) Tj ET\n")
4699            }
4700            other => panic!("expected bytes, got {other:?}"),
4701        }
4702        assert!(
4703            stats.seed_nodes_fetched <= 8,
4704            "fetched {} seed nodes for one decoded stream",
4705            stats.seed_nodes_fetched
4706        );
4707        assert!(
4708            stats.seed_nodes_fetched < total,
4709            "must not enumerate the {total}-node store; fetched {}",
4710            stats.seed_nodes_fetched
4711        );
4712    }
4713
4714    /// Extract top-level object keys from a flat JSON object (test helper).
4715    pub(super) fn top_level_keys(json: &str) -> Vec<String> {
4716        let b = json.as_bytes();
4717        let mut keys = Vec::new();
4718        let mut depth = 0i32;
4719        let mut in_str = false;
4720        let mut esc = false;
4721        let mut i = 0;
4722        while i < b.len() {
4723            let c = b[i];
4724            if in_str {
4725                if esc {
4726                    esc = false;
4727                } else if c == b'\\' {
4728                    esc = true;
4729                } else if c == b'"' {
4730                    in_str = false;
4731                    if depth == 1 && b.get(i + 1) == Some(&b':') {
4732                        let mut j = i;
4733                        while j > 0 {
4734                            j -= 1;
4735                            if b[j] == b'"' {
4736                                keys.push(String::from_utf8_lossy(&b[j + 1..i]).into_owned());
4737                                break;
4738                            }
4739                        }
4740                    }
4741                }
4742            } else {
4743                match c {
4744                    b'"' => in_str = true,
4745                    b'{' | b'[' => depth += 1,
4746                    b'}' | b']' => depth -= 1,
4747                    _ => {}
4748                }
4749            }
4750            i += 1;
4751        }
4752        keys
4753    }
4754}