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