Skip to main content

vole_document/field/
coarse.rs

1//! Phase 29.2 — a persistent **coarse structural index**.
2//!
3//! Phase 28.6 measured the closure for a narrow request as the *whole encoded
4//! descriptor* and decided a **coarse** index (PDF objects/pages, ZIP members,
5//! Parquet row groups/column chunks) — not per-record and not tiny fixed
6//! fragments. This module builds that index and uses it to serve a narrow
7//! observation from real `pread` range reads of the authority object.
8//!
9//! ## What is stored
10//!
11//! For each supported source the index records a partition of `[0, source_len)`
12//! into **format-native addressable units** ([`CoarseUnit`]) with their byte
13//! ranges in the authority. The authority here is the descriptor's single
14//! `OBJECT` payload (`EMIT_OBJECT(0)`), which — when its SHA-256 equals the
15//! declared source SHA-256 — is byte-identical to the source, so a source range
16//! `[a,b)` is the descriptor range `[object_base+a, object_base+b)`. That
17//! precondition is *checked*, never assumed: a descriptor whose authority is not
18//! a verbatim single object is declined and the caller falls back to the full
19//! read.
20//!
21//! ## Exactness and the fallback
22//!
23//! The index narrows *where the bytes come from*; it never changes the bytes.
24//! [`CoarseSource`] serves source ranges out of whole coarse units. If a request
25//! ever needs a range the plan did not cover, the read is a **typed decline**
26//! (`UnsupportedFeature`) that the caller turns into a full descriptor read — a
27//! missing/malformed index, an unknown format, or an uncovered unit must degrade
28//! degraded to the full read, never to a wrong answer. A field without the index is
29//! unaffected: `materialize --exact` and every other lane do not touch it.
30//!
31//! ## Realized granularity
32//!
33//! The index is exercised wherever an observation reads the source through
34//! `serve_range` — a byte range, a PDF object/stream, a ZIP/OPC member, a Parquet
35//! column-chunk span. A format *model* node that instead materializes the whole
36//! exact root (the DOCX/ODT/Parquet reading projections) still reads the whole
37//! source, so for those requests the units are built but inert. Phase 29.2's
38//! court measures both shapes and reports the difference rather than assuming
39//! the index narrows every request.
40
41use std::cell::{Cell, RefCell};
42use std::collections::BTreeMap;
43use std::fs::File;
44use std::io::{Read, Seek, SeekFrom};
45use std::os::unix::fs::FileExt;
46use std::path::{Path, PathBuf};
47
48use sha2::{Digest, Sha256};
49
50use crate::container::header::{HEADER_LEN, Header};
51use crate::container::record::{RECORD_HEADER_LEN, RECORD_TRAILER_LEN, RecordTag, read_record_at};
52use crate::dra::Program;
53use crate::dra::op::Op;
54use crate::error::{Error, Result};
55use crate::limits::Limits;
56use crate::store::Id;
57
58use super::FieldStore;
59use super::dag::SourceServer;
60use super::manifest::{FieldId, FieldRoot};
61use super::observe::{ObserveRequest, ObserveStats};
62
63/// Wire magic + version of the persisted index.
64const COARSE_MAGIC: [u8; 4] = *b"VCI1";
65/// A bound on the unit count so a hostile/malformed index cannot allocate.
66const MAX_UNITS: u64 = 1 << 20;
67/// Unit entry length: kind(1) + id(4) + offset(8) + len(4).
68const ENTRY_LEN: usize = 17;
69
70/// The native kind of a coarse unit.
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72#[repr(u8)]
73pub enum UnitKind {
74    /// A byte range no native unit covers (framing, xref tables, padding).
75    Gap = 0,
76    /// The whole authority object (a format with no finer units).
77    AuthorityLeaf = 1,
78    /// A PDF indirect object (`N G obj` … `endobj`).
79    PdfObject = 2,
80    /// The PDF xref/trailer tail.
81    PdfTail = 3,
82    /// A ZIP/OPC container member (local header + compressed data).
83    ZipMember = 4,
84    /// The ZIP central directory + EOCD region.
85    ZipDirectory = 5,
86    /// A Parquet column chunk (its narrowest addressable unit).
87    ParquetColumnChunk = 6,
88    /// The Parquet footer.
89    ParquetFooter = 7,
90}
91
92impl UnitKind {
93    fn code(self) -> u8 {
94        self as u8
95    }
96
97    fn from_code(c: u8) -> Result<Self> {
98        Ok(match c {
99            0 => Self::Gap,
100            1 => Self::AuthorityLeaf,
101            2 => Self::PdfObject,
102            3 => Self::PdfTail,
103            4 => Self::ZipMember,
104            5 => Self::ZipDirectory,
105            6 => Self::ParquetColumnChunk,
106            7 => Self::ParquetFooter,
107            _ => return Err(Error::invalid_container("unknown coarse unit kind")),
108        })
109    }
110}
111
112/// One format-native addressable unit: a byte range of the source/authority.
113#[derive(Debug, Clone, Copy)]
114pub struct CoarseUnit {
115    /// Native kind.
116    pub kind: UnitKind,
117    /// Ordinal within its kind (an object number, a member index, …).
118    pub id: u32,
119    /// Start offset in the source.
120    pub offset: u64,
121    /// Length in bytes.
122    pub len: u32,
123}
124
125/// The persisted coarse structural directory for one field.
126#[derive(Debug, Clone)]
127pub struct CoarseIndex {
128    /// `0` unsupported (no native units), `1` PDF, `2` ZIP, `3` Parquet.
129    pub format: u8,
130    /// Exact source length.
131    pub source_len: u64,
132    /// Exact source SHA-256.
133    pub source_sha256: [u8; 32],
134    /// Offset of the authority object payload in the descriptor.
135    pub object_base: u64,
136    /// The descriptor's object count (for the observation view).
137    pub object_count: u32,
138    /// The descriptor's graph op count (for the observation view).
139    pub graph_ops: u32,
140    /// A partition of `[0, source_len)`.
141    pub units: Vec<CoarseUnit>,
142}
143
144/// The detected format of a coarse index.
145pub fn format_name(code: u8) -> &'static str {
146    match code {
147        1 => "pdf",
148        2 => "zip",
149        3 => "parquet",
150        _ => "unsupported",
151    }
152}
153
154impl CoarseIndex {
155    /// Encode the index into its bounded, versioned wheel.
156    pub fn encode(&self) -> Vec<u8> {
157        let mut out = Vec::with_capacity(69 + self.units.len() * ENTRY_LEN);
158        out.extend_from_slice(&COARSE_MAGIC);
159        out.push(self.format);
160        out.extend_from_slice(&self.object_count.to_le_bytes());
161        out.extend_from_slice(&self.graph_ops.to_le_bytes());
162        out.extend_from_slice(&self.source_len.to_le_bytes());
163        out.extend_from_slice(&self.object_base.to_le_bytes());
164        out.extend_from_slice(&self.source_sha256);
165        out.extend_from_slice(&(self.units.len() as u32).to_le_bytes());
166        for u in &self.units {
167            out.push(u.kind.code());
168            out.extend_from_slice(&u.id.to_le_bytes());
169            out.extend_from_slice(&u.offset.to_le_bytes());
170            out.extend_from_slice(&u.len.to_le_bytes());
171        }
172        out
173    }
174
175    /// Decode and structurally validate a persisted index.
176    pub fn decode(bytes: &[u8], limits: Limits) -> Result<Self> {
177        let mut p = 0usize;
178        let magic = bytes
179            .get(p..p + 4)
180            .ok_or_else(|| Error::invalid_container("coarse index truncated (magic)"))?;
181        if magic != COARSE_MAGIC {
182            return Err(Error::invalid_container("coarse index magic mismatch"));
183        }
184        p += 4;
185        let format = *bytes
186            .get(p)
187            .ok_or_else(|| Error::invalid_container("coarse index truncated (format)"))?;
188        p += 1;
189        let object_count = read_u32(bytes, &mut p)?;
190        let graph_ops = read_u32(bytes, &mut p)?;
191        let source_len = read_u64(bytes, &mut p)?;
192        let object_base = read_u64(bytes, &mut p)?;
193        let sha = bytes
194            .get(p..p + 32)
195            .ok_or_else(|| Error::invalid_container("coarse index truncated (sha)"))?;
196        let mut source_sha256 = [0u8; 32];
197        source_sha256.copy_from_slice(sha);
198        p += 32;
199        let count = u64::from(read_u32(bytes, &mut p)?);
200        if count > MAX_UNITS || count > u64::from(limits.max_record_len) {
201            return Err(Error::resource_limit(
202                "coarse index unit count exceeds limit",
203            ));
204        }
205        let need = (count as usize)
206            .checked_mul(ENTRY_LEN)
207            .ok_or_else(|| Error::resource_limit("coarse index size overflow"))?;
208        if bytes.len().saturating_sub(p) < need {
209            return Err(Error::invalid_container("coarse index truncated (units)"));
210        }
211        let mut units = Vec::with_capacity(count as usize);
212        for _ in 0..count {
213            let kind = UnitKind::from_code(bytes[p])?;
214            p += 1;
215            let id = read_u32(bytes, &mut p)?;
216            let offset = read_u64(bytes, &mut p)?;
217            let len = read_u32(bytes, &mut p)?;
218            units.push(CoarseUnit {
219                kind,
220                id,
221                offset,
222                len,
223            });
224        }
225        if p != bytes.len() {
226            return Err(Error::invalid_container("coarse index has trailing bytes"));
227        }
228        let index = CoarseIndex {
229            format,
230            source_len,
231            source_sha256,
232            object_base,
233            object_count,
234            graph_ops,
235            units,
236        };
237        index.validate()?;
238        Ok(index)
239    }
240
241    /// Confirm the units are a well-formed, in-bounds, ascending partition.
242    fn validate(&self) -> Result<()> {
243        let mut cursor = 0u64;
244        for u in &self.units {
245            let end = u
246                .offset
247                .checked_add(u64::from(u.len))
248                .ok_or_else(|| Error::invalid_container("coarse unit length overflow"))?;
249            if u.offset != cursor || end > self.source_len {
250                return Err(Error::invalid_container(
251                    "coarse units are not an ascending partition of the source",
252                ));
253            }
254            cursor = end;
255        }
256        if cursor != self.source_len {
257            return Err(Error::invalid_container(
258                "coarse units do not cover the whole source",
259            ));
260        }
261        Ok(())
262    }
263
264    /// The units intersecting `[offset, end)`, in ascending order.
265    #[allow(dead_code)]
266    fn covering(&self, offset: u64, end: u64) -> Vec<usize> {
267        let mut out = Vec::new();
268        for (i, u) in self.units.iter().enumerate() {
269            let ue = u.offset + u64::from(u.len);
270            if u.offset < end && offset < ue {
271                out.push(i);
272            }
273        }
274        out
275    }
276
277    /// Bytes the encoded index occupies.
278    pub fn encoded_len(&self) -> u64 {
279        self.encode().len() as u64
280    }
281}
282
283fn read_u32(bytes: &[u8], p: &mut usize) -> Result<u32> {
284    let s = bytes
285        .get(*p..*p + 4)
286        .ok_or_else(|| Error::invalid_container("coarse index truncated"))?;
287    *p += 4;
288    Ok(u32::from_le_bytes([s[0], s[1], s[2], s[3]]))
289}
290
291fn read_u64(bytes: &[u8], p: &mut usize) -> Result<u64> {
292    let s = bytes
293        .get(*p..*p + 8)
294        .ok_or_else(|| Error::invalid_container("coarse index truncated"))?;
295    *p += 8;
296    Ok(u64::from_le_bytes([
297        s[0], s[1], s[2], s[3], s[4], s[5], s[6], s[7],
298    ]))
299}
300
301// ---------------------------------------------------------------------------
302// Unit construction
303// ---------------------------------------------------------------------------
304
305fn push(units: &mut Vec<CoarseUnit>, kind: UnitKind, id: u32, offset: u64, len: u64, total: u64) {
306    if len == 0 || offset >= total || u32::try_from(len).is_err() {
307        return;
308    }
309    let capped = len.min(total - offset);
310    units.push(CoarseUnit {
311        kind,
312        id,
313        offset,
314        len: capped as u32,
315    });
316}
317
318/// Turn native spans into a partition of `[0, total)` by inserting gap units and
319/// resolving overlaps (finer spans win, coarser duplicates are dropped).
320fn partition(mut units: Vec<CoarseUnit>, total: u64) -> Vec<CoarseUnit> {
321    units.retain(|u| u.len > 0 && u.offset < total);
322    units.sort_by(|a, b| a.offset.cmp(&b.offset).then(a.len.cmp(&b.len)));
323    let mut out: Vec<CoarseUnit> = Vec::new();
324    let mut cursor = 0u64;
325    for u in units {
326        let start = u.offset.max(cursor);
327        let end = u.offset + u64::from(u.len);
328        if end <= cursor {
329            continue;
330        }
331        if start > cursor {
332            out.push(CoarseUnit {
333                kind: UnitKind::Gap,
334                id: 0,
335                offset: cursor,
336                len: (start - cursor) as u32,
337            });
338        }
339        let len = (end - start) as u32;
340        out.push(CoarseUnit {
341            kind: u.kind,
342            id: u.id,
343            offset: start,
344            len,
345        });
346        cursor = end;
347    }
348    if cursor < total {
349        out.push(CoarseUnit {
350            kind: UnitKind::Gap,
351            id: 0,
352            offset: cursor,
353            len: (total - cursor) as u32,
354        });
355    }
356    out
357}
358
359fn is_word(b: u8) -> bool {
360    b.is_ascii_alphanumeric() || b == b'_'
361}
362
363fn find(hay: &[u8], needle: &[u8]) -> Option<usize> {
364    if needle.is_empty() || hay.len() < needle.len() {
365        return None;
366    }
367    let first = needle[0];
368    let mut i = 0;
369    while i + needle.len() <= hay.len() {
370        if hay[i] == first && &hay[i..i + needle.len()] == needle {
371            return Some(i);
372        }
373        i += 1;
374    }
375    None
376}
377
378fn rfind(hay: &[u8], needle: &[u8]) -> Option<usize> {
379    if needle.is_empty() || hay.len() < needle.len() {
380        return None;
381    }
382    let mut i = hay.len() - needle.len();
383    loop {
384        if &hay[i..i + needle.len()] == needle {
385            return Some(i);
386        }
387        if i == 0 {
388            return None;
389        }
390        i -= 1;
391    }
392}
393
394fn obj_header_start(d: &[u8], objp: usize) -> Option<usize> {
395    let mut i = objp;
396    while i > 0 && d[i - 1].is_ascii_whitespace() {
397        i -= 1;
398    }
399    let g_end = i;
400    while i > 0 && d[i - 1].is_ascii_digit() {
401        i -= 1;
402    }
403    if i == g_end {
404        return None;
405    }
406    while i > 0 && d[i - 1].is_ascii_whitespace() {
407        i -= 1;
408    }
409    let n_end = i;
410    while i > 0 && d[i - 1].is_ascii_digit() {
411        i -= 1;
412    }
413    if i == n_end {
414        return None;
415    }
416    Some(i)
417}
418
419fn pdf_units(data: &[u8], units: &mut Vec<CoarseUnit>) {
420    let n = data.len();
421    let total = n as u64;
422    let mut pos = 0usize;
423    let mut id = 0u32;
424    while pos + 3 <= n {
425        if &data[pos..pos + 3] == b"obj"
426            && (pos + 3 == n || !is_word(data[pos + 3]))
427            && let Some(start) = obj_header_start(data, pos)
428        {
429            match find(&data[pos + 3..], b"endobj") {
430                Some(e) => {
431                    let end = pos + 3 + e + b"endobj".len();
432                    push(
433                        units,
434                        UnitKind::PdfObject,
435                        id,
436                        start as u64,
437                        (end - start) as u64,
438                        total,
439                    );
440                    id += 1;
441                    pos = end;
442                    continue;
443                }
444                // No `endobj` remains anywhere after `pos`, so no later candidate
445                // can pair either: stop scanning instead of rescanning the tail
446                // once per `obj` (a hostile all-`obj` blob is otherwise O(n^2)).
447                None => break,
448            }
449        }
450        pos += 1;
451    }
452    if let Some(x) = rfind(data, b"startxref") {
453        push(units, UnitKind::PdfTail, 0, x as u64, (n - x) as u64, total);
454    }
455}
456
457fn u16le(d: &[u8], i: usize) -> Option<u16> {
458    let s = d.get(i..i + 2)?;
459    Some(u16::from_le_bytes([s[0], s[1]]))
460}
461
462fn u32le(d: &[u8], i: usize) -> Option<u32> {
463    let s = d.get(i..i + 4)?;
464    Some(u32::from_le_bytes([s[0], s[1], s[2], s[3]]))
465}
466
467/// Build ZIP/OPC member units and the central-directory unit. Declines (returns
468/// `Err`) on ZIP64 / multi-disk / malformed geometry so the caller falls back.
469fn zip_units(data: &[u8], units: &mut Vec<CoarseUnit>) -> Result<()> {
470    let n = data.len();
471    let total = n as u64;
472    let search_from = n.saturating_sub(65_557);
473    let eocd = rfind(&data[search_from..], b"PK\x05\x06")
474        .map(|i| i + search_from)
475        .ok_or_else(|| Error::invalid_container("ZIP: no end-of-central-directory"))?;
476    let disk = u16le(data, eocd + 4).unwrap_or(1);
477    let entries_disk = u16le(data, eocd + 8).unwrap_or(0);
478    let entries_total = u16le(data, eocd + 10).unwrap_or(0);
479    let cd_size = u32le(data, eocd + 12).unwrap_or(0);
480    let cd_off = u32le(data, eocd + 16).unwrap_or(0);
481    if disk != 0 || entries_disk != entries_total {
482        return Err(Error::unsupported_feature(
483            "ZIP: multi-disk archives declined",
484        ));
485    }
486    if entries_total == u16::MAX || cd_size == u32::MAX || cd_off == u32::MAX {
487        return Err(Error::unsupported_feature("ZIP64 archives declined"));
488    }
489    let cd_off = cd_off as usize;
490    if cd_off > n {
491        return Err(Error::invalid_container(
492            "ZIP: central directory offset out of range",
493        ));
494    }
495    let mut p = cd_off;
496    for i in 0..entries_total {
497        if data.get(p..p + 4) != Some(b"PK\x01\x02") {
498            return Err(Error::invalid_container("ZIP: bad central-directory entry"));
499        }
500        let method = u16le(data, p + 10).unwrap_or(0);
501        let comp = u32le(data, p + 20).unwrap_or(0);
502        let name_len = u16le(data, p + 28).unwrap_or(0) as usize;
503        let extra_len = u16le(data, p + 30).unwrap_or(0) as usize;
504        let comment_len = u16le(data, p + 32).unwrap_or(0) as usize;
505        let lo_raw = u32le(data, p + 42).unwrap_or(0);
506        let _ = method;
507        if comp == u32::MAX || lo_raw == u32::MAX {
508            return Err(Error::unsupported_feature("ZIP64 member declined"));
509        }
510        let lo = lo_raw as usize;
511        // Local header carries the authoritative name/extra lengths.
512        if data.get(lo..lo + 4) != Some(b"PK\x03\x04") {
513            return Err(Error::invalid_container("ZIP: bad local header"));
514        }
515        let ln = u16le(data, lo + 26).unwrap_or(0) as usize;
516        let le = u16le(data, lo + 28).unwrap_or(0) as usize;
517        let start = lo as u64;
518        let end = (lo as u64)
519            .saturating_add(30)
520            .saturating_add(ln as u64)
521            .saturating_add(le as u64)
522            .saturating_add(comp as u64);
523        if end > total || end < start {
524            return Err(Error::invalid_container("ZIP: member span out of range"));
525        }
526        units.push(CoarseUnit {
527            kind: UnitKind::ZipMember,
528            id: i as u32,
529            offset: start,
530            len: (end - start) as u32,
531        });
532        p = p
533            .checked_add(46 + name_len + extra_len + comment_len)
534            .ok_or_else(|| Error::invalid_container("ZIP: central directory overflow"))?;
535    }
536    push(
537        units,
538        UnitKind::ZipDirectory,
539        0,
540        cd_off as u64,
541        total.saturating_sub(cd_off as u64),
542        total,
543    );
544    Ok(())
545}
546
547/// A minimal Thrift-Compact value, only as much as the Parquet footer needs.
548#[derive(Debug)]
549enum TVal {
550    Bool,
551    Int(i64),
552    Bytes,
553    List(Vec<TVal>),
554    Struct(Vec<(i16, TVal)>),
555    Other,
556}
557
558/// A bound on Thrift-Compact nesting, so a hostile footer cannot recurse the
559/// parser into a stack overflow. Real Parquet footers nest a handful of levels.
560const MAX_THRIFT_DEPTH: u32 = 64;
561
562struct TC<'a> {
563    b: &'a [u8],
564    i: usize,
565    depth: u32,
566}
567
568impl<'a> TC<'a> {
569    fn byte(&mut self) -> Result<u8> {
570        let v = *self
571            .b
572            .get(self.i)
573            .ok_or_else(|| Error::invalid_container("thrift: truncated"))?;
574        self.i += 1;
575        Ok(v)
576    }
577
578    fn varint(&mut self) -> Result<u64> {
579        let mut shift = 0u32;
580        let mut out = 0u64;
581        loop {
582            let c = self.byte()?;
583            out |= u64::from(c & 0x7F) << shift;
584            if c & 0x80 == 0 {
585                return Ok(out);
586            }
587            shift += 7;
588            if shift > 63 {
589                return Err(Error::invalid_container("thrift: varint overflow"));
590            }
591        }
592    }
593
594    fn zig(&mut self) -> Result<i64> {
595        let u = self.varint()?;
596        Ok(((u >> 1) as i64) ^ -((u & 1) as i64))
597    }
598
599    fn skip(&mut self, t: u8) -> Result<TVal> {
600        Ok(match t {
601            1 | 2 => TVal::Bool,
602            3 => TVal::Int(i64::from(self.byte()?)),
603            4..=6 => TVal::Int(self.zig()?),
604            7 => {
605                self.i = self
606                    .i
607                    .checked_add(8)
608                    .ok_or_else(|| Error::invalid_container("thrift: overflow"))?;
609                TVal::Other
610            }
611            8 => {
612                let ln = self.varint()? as usize;
613                if self.b.get(self.i..self.i + ln).is_none() {
614                    return Err(Error::invalid_container("thrift: truncated binary"));
615                }
616                self.i += ln;
617                TVal::Bytes
618            }
619            9 | 10 => {
620                let h = self.byte()?;
621                let mut size = u64::from((h & 0xF0) >> 4);
622                let et = h & 0x0F;
623                if size == 15 {
624                    size = self.varint()?;
625                }
626                if size > MAX_UNITS {
627                    return Err(Error::resource_limit("thrift: list too long"));
628                }
629                self.descend()?;
630                let mut items = Vec::with_capacity(size as usize);
631                for _ in 0..size {
632                    items.push(self.skip(et)?);
633                }
634                self.ascend();
635                TVal::List(items)
636            }
637            11 => {
638                let size = self.varint()?;
639                if size == 0 {
640                    TVal::Other
641                } else {
642                    let kv = self.byte()?;
643                    self.descend()?;
644                    for _ in 0..size {
645                        self.skip((kv & 0xF0) >> 4)?;
646                        self.skip(kv & 0x0F)?;
647                    }
648                    self.ascend();
649                    TVal::Other
650                }
651            }
652            12 => {
653                self.descend()?;
654                let s = TVal::Struct(self.read_struct()?);
655                self.ascend();
656                s
657            }
658            _ => return Err(Error::invalid_container("thrift: unknown type")),
659        })
660    }
661
662    fn descend(&mut self) -> Result<()> {
663        self.depth += 1;
664        if self.depth > MAX_THRIFT_DEPTH {
665            return Err(Error::invalid_container("thrift: nesting too deep"));
666        }
667        Ok(())
668    }
669
670    fn ascend(&mut self) {
671        self.depth = self.depth.saturating_sub(1);
672    }
673
674    fn read_struct(&mut self) -> Result<Vec<(i16, TVal)>> {
675        let mut out = Vec::new();
676        let mut last: i16 = 0;
677        loop {
678            let h = self.byte()?;
679            if h == 0 {
680                return Ok(out);
681            }
682            let delta = i16::from((h & 0xF0) >> 4);
683            let t = h & 0x0F;
684            let fid = if delta == 0 {
685                self.zig()? as i16
686            } else {
687                last + delta
688            };
689            last = fid;
690            let v = self.skip(t)?;
691            out.push((fid, v));
692        }
693    }
694}
695
696fn struct_field(fields: &[(i16, TVal)], id: i16) -> Option<&TVal> {
697    fields.iter().find(|(k, _)| *k == id).map(|(_, v)| v)
698}
699
700fn as_list(v: Option<&TVal>) -> &[TVal] {
701    match v {
702        Some(TVal::List(l)) => l,
703        _ => &[],
704    }
705}
706
707fn as_i64(v: Option<&TVal>) -> Option<i64> {
708    match v {
709        Some(TVal::Int(i)) => Some(*i),
710        _ => None,
711    }
712}
713
714/// Build Parquet column-chunk units + the footer unit. Declines on a malformed
715/// footer so the caller falls back.
716fn parquet_units(data: &[u8], units: &mut Vec<CoarseUnit>) -> Result<()> {
717    let n = data.len();
718    let total = n as u64;
719    let flen =
720        u32le(data, n - 8).ok_or_else(|| Error::invalid_container("parquet: short"))? as usize;
721    if flen + 8 > n || flen == 0 {
722        return Err(Error::invalid_container("parquet: bad footer length"));
723    }
724    let fstart = n - 8 - flen;
725    let mut tc = TC {
726        b: &data[fstart..n - 8],
727        i: 0,
728        depth: 0,
729    };
730    let meta = tc.read_struct()?;
731    let rgs = as_list(struct_field(&meta, 4));
732    let mut id = 0u32;
733    for rg in rgs {
734        let TVal::Struct(rg_fields) = rg else {
735            continue;
736        };
737        for col in as_list(struct_field(rg_fields, 1)) {
738            let TVal::Struct(col_fields) = col else {
739                continue;
740            };
741            let Some(TVal::Struct(cm)) = struct_field(col_fields, 3) else {
742                continue;
743            };
744            let start = as_i64(struct_field(cm, 11))
745                .or_else(|| as_i64(struct_field(cm, 9)))
746                .unwrap_or(0);
747            let size = as_i64(struct_field(cm, 7)).unwrap_or(0);
748            if start < 0 || size <= 0 {
749                continue;
750            }
751            let start = start as u64;
752            let size = size as u64;
753            if start + size > total || size > u64::from(u32::MAX) {
754                return Err(Error::invalid_container(
755                    "parquet: column chunk out of range",
756                ));
757            }
758            units.push(CoarseUnit {
759                kind: UnitKind::ParquetColumnChunk,
760                id,
761                offset: start,
762                len: size as u32,
763            });
764            id += 1;
765        }
766    }
767    push(
768        units,
769        UnitKind::ParquetFooter,
770        0,
771        fstart as u64,
772        (8 + flen) as u64,
773        total,
774    );
775    Ok(())
776}
777
778/// Detect the format and build the native unit set for `source`.
779pub fn build_units(source: &[u8]) -> (u8, Vec<CoarseUnit>) {
780    let n = source.len() as u64;
781    let mut units: Vec<CoarseUnit> = Vec::new();
782    let format = if source.starts_with(b"%PDF-") {
783        pdf_units(source, &mut units);
784        1
785    } else if source.starts_with(b"PK\x03\x04") && zip_units(source, &mut units).is_ok() {
786        2
787    } else if source.len() >= 12
788        && source.starts_with(b"PAR1")
789        && source.ends_with(b"PAR1")
790        && parquet_units(source, &mut units).is_ok()
791    {
792        3
793    } else {
794        units.clear();
795        0
796    };
797    if format == 0 {
798        units.clear();
799        units.push(CoarseUnit {
800            kind: UnitKind::AuthorityLeaf,
801            id: 0,
802            offset: 0,
803            len: u32::try_from(n).unwrap_or(u32::MAX),
804        });
805    }
806    (format, partition(units, n))
807}
808
809// ---------------------------------------------------------------------------
810// Authority discovery
811// ---------------------------------------------------------------------------
812
813/// The descriptor's verbatim authority object, when the descriptor is exactly
814/// `EMIT_OBJECT(0)` over one object whose bytes equal the source.
815pub struct Authority {
816    /// Offset of the object payload in the descriptor file.
817    pub object_base: u64,
818    /// The object payload (byte-identical to the source).
819    pub payload: Vec<u8>,
820    /// Object count from the descriptor.
821    pub object_count: u32,
822    /// Graph op count from the descriptor.
823    pub graph_ops: u32,
824}
825
826/// Walk the descriptor framing (read-only) and return the single authority
827/// object **iff** it is the verbatim source under `EMIT_OBJECT(0)`.
828///
829/// Returns `Ok(None)` for any other shape — external objects, a multi-object
830/// program, a transformed program, or a payload whose SHA-256 is not the
831/// declared source SHA-256. The caller then falls back to the full read.
832pub fn discover_authority(
833    descriptor_path: &Path,
834    manifest: &FieldRoot,
835    limits: Limits,
836) -> Result<Option<Authority>> {
837    let mut file =
838        File::open(descriptor_path).map_err(|e| Error::io(format!("opening descriptor: {e}")))?;
839    let mut hdr = [0u8; HEADER_LEN];
840    file.read_exact(&mut hdr)
841        .map_err(|_| Error::invalid_container("truncated header"))?;
842    let _header = Header::decode(&hdr)?;
843    let file_len = file
844        .seek(SeekFrom::End(0))
845        .map_err(|e| Error::io(format!("seek to end failed: {e}")))?;
846    let mut object_sites: Vec<(u64, u32)> = Vec::new();
847    let mut graph: Option<Vec<u8>> = None;
848    let mut pos = HEADER_LEN as u64;
849    while pos < file_len {
850        let mut rec_hdr = [0u8; RECORD_HEADER_LEN];
851        file.seek(SeekFrom::Start(pos))
852            .map_err(|e| Error::io(format!("seek failed: {e}")))?;
853        file.read_exact(&mut rec_hdr)
854            .map_err(|_| Error::invalid_container("truncated record header"))?;
855        let tag = rec_hdr[0];
856        let len = u32::from_le_bytes([rec_hdr[4], rec_hdr[5], rec_hdr[6], rec_hdr[7]]);
857        let total = RECORD_HEADER_LEN as u64 + u64::from(len) + RECORD_TRAILER_LEN as u64;
858        let end = pos
859            .checked_add(total)
860            .ok_or_else(|| Error::invalid_container("record length overflow"))?;
861        if end > file_len {
862            return Err(Error::invalid_container("truncated record"));
863        }
864        match RecordTag::from_u8(tag) {
865            Some(RecordTag::Object) => {
866                object_sites.push((pos + RECORD_HEADER_LEN as u64, len));
867            }
868            Some(RecordTag::Graph) => {
869                let rec = read_record_at(&mut file, pos, limits)?;
870                graph = Some(rec.payload);
871            }
872            Some(RecordTag::ExternalRef) => return Ok(None),
873            Some(RecordTag::Trailer) => break,
874            _ => {}
875        }
876        pos = end;
877    }
878    if object_sites.len() != 1 {
879        return Ok(None);
880    }
881    let Some(graph) = graph else {
882        return Ok(None);
883    };
884    let program = Program::decode(&graph, limits)?;
885    if program.ops.len() != 1 || !matches!(program.ops[0], Op::EmitObject { object_id: 0 }) {
886        return Ok(None);
887    }
888    let (object_base, object_len) = object_sites[0];
889    let mut payload = vec![0u8; object_len as usize];
890    file.read_exact_at(&mut payload, object_base)
891        .map_err(|e| Error::io(format!("reading authority object: {e}")))?;
892    let mut h = Sha256::new();
893    h.update(&payload);
894    let digest: [u8; 32] = h.finalize().into();
895    if digest != manifest.source_sha256 || payload.len() as u64 != manifest.source_len {
896        return Ok(None);
897    }
898    Ok(Some(Authority {
899        object_base,
900        payload,
901        object_count: 1,
902        graph_ops: 1,
903    }))
904}
905
906// ---------------------------------------------------------------------------
907// Persistent side structure
908// ---------------------------------------------------------------------------
909
910fn hex(bytes: &[u8]) -> String {
911    const HEX: &[u8; 16] = b"0123456789abcdef";
912    let mut s = String::with_capacity(bytes.len() * 2);
913    for b in bytes {
914        s.push(HEX[(b >> 4) as usize] as char);
915        s.push(HEX[(b & 0x0F) as usize] as char);
916    }
917    s
918}
919
920fn coarse_dir(root: &Path) -> PathBuf {
921    root.join("coarse")
922}
923
924/// The persisted index path for a descriptor id.
925pub fn index_path(root: &Path, descriptor_id: &[u8; 32]) -> PathBuf {
926    coarse_dir(root).join(format!("{}.idx", hex(descriptor_id)))
927}
928
929/// The dependency-map path for a descriptor id.
930pub fn map_path(root: &Path, descriptor_id: &[u8; 32]) -> PathBuf {
931    coarse_dir(root).join(format!("{}.map", hex(descriptor_id)))
932}
933
934/// Persist or load a dependency map (request line → unit ordinals).
935pub fn load_map(path: &Path) -> BTreeMap<String, Vec<usize>> {
936    let mut out = BTreeMap::new();
937    let Ok(text) = std::fs::read_to_string(path) else {
938        return out;
939    };
940    for line in text.lines() {
941        if let Some((k, v)) = line.split_once('\t') {
942            let units: Vec<usize> = v
943                .split(',')
944                .filter_map(|s| s.parse::<usize>().ok())
945                .collect();
946            out.insert(k.to_string(), units);
947        }
948    }
949    out
950}
951
952fn save_map(path: &Path, map: &BTreeMap<String, Vec<usize>>) {
953    let mut text = String::new();
954    for (k, v) in map {
955        let joined: Vec<String> = v.iter().map(usize::to_string).collect();
956        text.push_str(k);
957        text.push('\t');
958        text.push_str(&joined.join(","));
959        text.push('\n');
960    }
961    let _ = super::write_atomic(path, text.as_bytes());
962}
963
964// ---------------------------------------------------------------------------
965// The narrow reader
966// ---------------------------------------------------------------------------
967
968/// A [`SourceServer`] that serves source ranges out of whole coarse units of the
969/// verbatim authority object, reading each unit with a real `pread`.
970pub struct CoarseSource {
971    file: File,
972    object_base: u64,
973    source_len: u64,
974    units: Vec<CoarseUnit>,
975    loaded: RefCell<Vec<Option<Vec<u8>>>>,
976    expand: bool,
977    reads: Cell<u64>,
978    bytes_read: Cell<u64>,
979    misses: Cell<u64>,
980    covered_lo: Cell<u64>,
981    covered_hi: Cell<u64>,
982}
983
984impl CoarseSource {
985    fn new(descriptor_path: &Path, index: &CoarseIndex, expand: bool) -> Result<Self> {
986        let file = File::open(descriptor_path)
987            .map_err(|e| Error::io(format!("opening descriptor: {e}")))?;
988        let n = index.units.len();
989        Ok(CoarseSource {
990            file,
991            object_base: index.object_base,
992            source_len: index.source_len,
993            units: index.units.clone(),
994            loaded: RefCell::new(vec![None; n]),
995            expand,
996            reads: Cell::new(0),
997            bytes_read: Cell::new(0),
998            misses: Cell::new(0),
999            covered_lo: Cell::new(u64::MAX),
1000            covered_hi: Cell::new(0),
1001        })
1002    }
1003
1004    /// Real descriptor payload bytes fetched by this reader.
1005    pub fn bytes_read(&self) -> u64 {
1006        self.bytes_read.get()
1007    }
1008
1009    /// Number of `pread` calls made.
1010    pub fn reads(&self) -> u64 {
1011        self.reads.get()
1012    }
1013
1014    /// Number of coverage misses (should be 0 for an exact, complete closure).
1015    pub fn misses(&self) -> u64 {
1016        self.misses.get()
1017    }
1018
1019    /// Unit ordinals actually fetched.
1020    pub fn touched_units(&self) -> Vec<usize> {
1021        self.loaded
1022            .borrow()
1023            .iter()
1024            .enumerate()
1025            .filter(|(_, v)| v.is_some())
1026            .map(|(i, _)| i)
1027            .collect()
1028    }
1029
1030    /// The covered byte span (for the receipt).
1031    pub fn covered_span(&self) -> Option<(u64, u64)> {
1032        if self.covered_hi.get() <= self.covered_lo.get() {
1033            None
1034        } else {
1035            Some((self.covered_lo.get(), self.covered_hi.get()))
1036        }
1037    }
1038
1039    fn fetch(&self, i: usize, loaded: &mut [Option<Vec<u8>>]) -> Result<()> {
1040        if loaded[i].is_some() {
1041            return Ok(());
1042        }
1043        if !self.expand {
1044            self.misses.set(self.misses.get() + 1);
1045            return Err(Error::unsupported_feature(
1046                "coarse plan did not cover a required unit; falling back to the full read",
1047            ));
1048        }
1049        self.read_unit(i, loaded)
1050    }
1051
1052    /// Read one unit unconditionally (a real `pread`), regardless of `expand`.
1053    fn read_unit(&self, i: usize, loaded: &mut [Option<Vec<u8>>]) -> Result<()> {
1054        if loaded[i].is_some() {
1055            return Ok(());
1056        }
1057        let u = self.units[i];
1058        let mut buf = vec![0u8; u.len as usize];
1059        self.file
1060            .read_exact_at(&mut buf, self.object_base + u.offset)
1061            .map_err(|e| Error::io(format!("coarse pread failed: {e}")))?;
1062        self.bytes_read
1063            .set(self.bytes_read.get() + buf.len() as u64);
1064        self.reads.set(self.reads.get() + 1);
1065        self.covered_lo.set(self.covered_lo.get().min(u.offset));
1066        self.covered_hi
1067            .set(self.covered_hi.get().max(u.offset + u64::from(u.len)));
1068        loaded[i] = Some(buf);
1069        Ok(())
1070    }
1071
1072    /// Prefetch a planned set of units so the closure is exactly that set.
1073    ///
1074    /// The plan is authoritative: its units are read unconditionally. `expand`
1075    /// only governs an *unplanned* unit requested during serving (a coverage
1076    /// miss falls back to the full read, never to discovery).
1077    fn prefetch(&self, ordinals: &[usize]) -> Result<()> {
1078        let mut loaded = self.loaded.borrow_mut();
1079        for &i in ordinals {
1080            if i < self.units.len() {
1081                self.read_unit(i, &mut loaded)?;
1082            }
1083        }
1084        Ok(())
1085    }
1086}
1087
1088impl SourceServer for CoarseSource {
1089    fn serve_range(&self, offset: u64, len: u64, _limits: Limits) -> Result<Vec<u8>> {
1090        let end = offset
1091            .checked_add(len)
1092            .ok_or_else(|| Error::usage("source slice end overflows"))?;
1093        if end > self.source_len {
1094            return Err(Error::usage(format!(
1095                "source slice {offset}..{end} exceeds source length {}",
1096                self.source_len
1097            )));
1098        }
1099        if len == 0 {
1100            return Ok(Vec::new());
1101        }
1102        // Units are a partition, so the intersection is non-empty and ordered.
1103        let needed = {
1104            let mut v = Vec::new();
1105            for (i, u) in self.units.iter().enumerate() {
1106                let ue = u.offset + u64::from(u.len);
1107                if u.offset < end && offset < ue {
1108                    v.push(i);
1109                }
1110            }
1111            v
1112        };
1113        if needed.is_empty() {
1114            return Err(Error::internal_invariant(
1115                "coarse units do not cover a requested source range",
1116            ));
1117        }
1118        let mut loaded = self.loaded.borrow_mut();
1119        for &i in &needed {
1120            self.fetch(i, &mut loaded)?;
1121        }
1122        let mut out = Vec::with_capacity((end - offset) as usize);
1123        let mut cursor = offset;
1124        for &i in &needed {
1125            let u = self.units[i];
1126            let ue = u.offset + u64::from(u.len);
1127            let s = cursor.max(u.offset);
1128            let e = end.min(ue);
1129            if e > s {
1130                if let Some(buf) = loaded[i].as_ref() {
1131                    let a = (s - u.offset) as usize;
1132                    let b = (e - u.offset) as usize;
1133                    out.extend_from_slice(&buf[a..b]);
1134                }
1135                cursor = e;
1136            }
1137        }
1138        if cursor != end {
1139            return Err(Error::internal_invariant(
1140                "coarse units left a gap in a requested source range",
1141            ));
1142        }
1143        Ok(out)
1144    }
1145
1146    fn serve_document(&self, _limits: Limits) -> Result<Vec<u8>> {
1147        let mut buf = vec![0u8; self.source_len as usize];
1148        self.file
1149            .read_exact_at(&mut buf, self.object_base)
1150            .map_err(|e| Error::io(format!("coarse whole-object pread failed: {e}")))?;
1151        self.bytes_read
1152            .set(self.bytes_read.get() + buf.len() as u64);
1153        self.reads.set(self.reads.get() + 1);
1154        self.covered_lo.set(0);
1155        self.covered_hi.set(self.source_len);
1156        Ok(buf)
1157    }
1158}
1159
1160// ---------------------------------------------------------------------------
1161// Public entry points (called by the CLI; all `pub(crate)` guts stay here)
1162// ---------------------------------------------------------------------------
1163
1164/// Outcome of building the index for one field.
1165pub struct BuildReport {
1166    /// Detected format name.
1167    pub format: &'static str,
1168    /// Number of units (a partition of the source).
1169    pub units: usize,
1170    /// Native units (excluding gap fillers).
1171    pub native_units: usize,
1172    /// Encoded index bytes.
1173    pub index_bytes: u64,
1174    /// Whole-store regular-file bytes after building.
1175    pub store_bytes: u64,
1176    /// `index_bytes / store_bytes`.
1177    pub fraction: f64,
1178    /// Persisted index path.
1179    pub path: String,
1180}
1181
1182fn dir_bytes(path: &Path) -> u64 {
1183    let mut total = 0u64;
1184    let Ok(rd) = std::fs::read_dir(path) else {
1185        return 0;
1186    };
1187    for entry in rd.flatten() {
1188        let Ok(ft) = entry.file_type() else { continue };
1189        if ft.is_dir() {
1190            total += dir_bytes(&entry.path());
1191        } else if let Ok(md) = entry.metadata() {
1192            total += md.len();
1193        }
1194    }
1195    total
1196}
1197
1198/// Open the store, discover the authority, build and persist the coarse index.
1199pub fn build(
1200    store_dir: &Path,
1201    packed: bool,
1202    field_hex: &str,
1203    limits: Limits,
1204) -> Result<BuildReport> {
1205    let store = if packed {
1206        FieldStore::open_packed(store_dir)?
1207    } else {
1208        FieldStore::open(store_dir)?
1209    };
1210    let field_id = FieldId::from_hex(field_hex)?;
1211    let manifest = store.get_field(&field_id)?;
1212    let descriptor_path = store
1213        .descriptor_path(&Id::from_bytes(manifest.descriptor_id))
1214        .ok_or_else(|| Error::unsupported_feature("descriptor is not a filesystem file"))?;
1215    let authority = discover_authority(&descriptor_path, &manifest, limits)?.ok_or_else(|| {
1216        Error::unsupported_feature(
1217            "descriptor is not a verbatim single authority object; coarse index not built",
1218        )
1219    })?;
1220    let (format, units) = build_units(&authority.payload);
1221    let native_units = units
1222        .iter()
1223        .filter(|u| u.kind != UnitKind::Gap && u.kind != UnitKind::AuthorityLeaf)
1224        .count();
1225    let index = CoarseIndex {
1226        format,
1227        source_len: manifest.source_len,
1228        source_sha256: manifest.source_sha256,
1229        object_base: authority.object_base,
1230        object_count: authority.object_count,
1231        graph_ops: authority.graph_ops,
1232        units,
1233    };
1234    let encoded = index.encode();
1235    let path = index_path(store.root(), &manifest.descriptor_id);
1236    super::write_atomic(&path, &encoded)?;
1237    let store_bytes = dir_bytes(store.root());
1238    let index_bytes = encoded.len() as u64;
1239    Ok(BuildReport {
1240        format: format_name(format),
1241        units: index.units.len(),
1242        native_units,
1243        index_bytes,
1244        store_bytes,
1245        fraction: if store_bytes == 0 {
1246            0.0
1247        } else {
1248            index_bytes as f64 / store_bytes as f64
1249        },
1250        path: path.display().to_string(),
1251    })
1252}
1253
1254/// One row of the coarse-read receipt.
1255pub struct ReadRow {
1256    /// The request line.
1257    pub request: String,
1258    /// `planned` (served from the index), `fallback` (full descriptor read), or
1259    /// `declined` (the observation itself declined).
1260    pub outcome: &'static str,
1261    /// Why a fallback happened, when it did.
1262    pub fallback_reason: String,
1263    /// Units fetched by the coarse source.
1264    pub units_read: usize,
1265    /// Real `pread` calls made by the coarse source.
1266    pub preads: u64,
1267    /// Real descriptor payload bytes fetched by the coarse source.
1268    pub unit_bytes: u64,
1269    /// The covered source span, if any.
1270    pub covered: Option<(u64, u64)>,
1271    /// Manifest bytes the observation fetched.
1272    pub manifest_bytes: u64,
1273    /// Hierarchical-index bytes the observation fetched.
1274    pub index_bytes: u64,
1275    /// Seed bytes the observation fetched.
1276    pub seed_bytes: u64,
1277    /// Persisted index file size (the directory the read pays).
1278    pub dir_bytes: u64,
1279    /// Honest total physical bytes for this lane.
1280    pub closure_bytes: u64,
1281    /// Bytes returned by the observation.
1282    pub answer_bytes: u64,
1283    /// Whether the answer matched the full-read answer.
1284    pub matches_full: bool,
1285}
1286
1287fn same_answer(a: &super::provenance::FieldAnswer, b: &super::provenance::FieldAnswer) -> bool {
1288    a.value == b.value && a.basis == b.basis
1289}
1290
1291/// Run each request through the coarse lane, falling back to the full read when
1292/// the plan cannot cover it. When `use_map` is set, the persisted dependency map
1293/// supplies the unit plan and a coverage miss falls back; otherwise the reader
1294/// discovers the closure and records it into the map.
1295pub fn read(
1296    store_dir: &Path,
1297    packed: bool,
1298    field_hex: &str,
1299    requests: &[(String, ObserveRequest)],
1300    use_map: bool,
1301    limits: Limits,
1302) -> Result<Vec<ReadRow>> {
1303    let mut store = if packed {
1304        FieldStore::open_packed(store_dir)?
1305    } else {
1306        FieldStore::open(store_dir)?
1307    };
1308    let field_id = FieldId::from_hex(field_hex)?;
1309    let manifest = store.get_field(&field_id)?;
1310    let descriptor_path = store
1311        .descriptor_path(&Id::from_bytes(manifest.descriptor_id))
1312        .ok_or_else(|| Error::unsupported_feature("descriptor is not a filesystem file"))?;
1313    let idx_path = index_path(store.root(), &manifest.descriptor_id);
1314    let map_path = map_path(store.root(), &manifest.descriptor_id);
1315    let mut map = load_map(&map_path);
1316    let index = std::fs::read(&idx_path)
1317        .ok()
1318        .and_then(|b| CoarseIndex::decode(&b, limits).ok());
1319    let dir_bytes = std::fs::metadata(&idx_path).map(|m| m.len()).unwrap_or(0);
1320
1321    let mut rows = Vec::with_capacity(requests.len());
1322    for (line, req) in requests {
1323        let mut row = ReadRow {
1324            request: line.clone(),
1325            outcome: "fallback",
1326            fallback_reason: String::new(),
1327            units_read: 0,
1328            preads: 0,
1329            unit_bytes: 0,
1330            covered: None,
1331            manifest_bytes: 0,
1332            index_bytes: 0,
1333            seed_bytes: 0,
1334            dir_bytes,
1335            closure_bytes: 0,
1336            answer_bytes: 0,
1337            matches_full: false,
1338        };
1339        // The full-read answer is the ground truth every coarse lane is checked
1340        // against; it also measures the control closure.
1341        let mut cold = req.clone();
1342        cold.use_cache = false;
1343        let (full_answer, full_stats, _) =
1344            super::observe::observe(&mut store, &field_id, &cold, limits)?;
1345
1346        let plan = match (&index, use_map) {
1347            (Some(_), true) => map.get(line).cloned(),
1348            _ => None,
1349        };
1350        let expand = !(use_map && plan.is_some());
1351        let outcome: std::result::Result<(), String> = match &index {
1352            None => Err("no coarse index for this field".into()),
1353            Some(ix) if ix.format == 0 => {
1354                Err("coarse index declines this format (no native units)".into())
1355            }
1356            Some(ix) => {
1357                let src = CoarseSource::new(&descriptor_path, ix, expand)?;
1358                if let Some(units) = &plan {
1359                    src.prefetch(units)?;
1360                }
1361                let res = super::observe::observe_with_source(
1362                    &mut store,
1363                    &manifest,
1364                    field_id,
1365                    &src,
1366                    (ix.object_count, ix.graph_ops),
1367                    &cold,
1368                    limits,
1369                );
1370                match res {
1371                    Ok((ans, stats, _)) if src.misses() == 0 => {
1372                        row.outcome = "planned";
1373                        row.units_read = src.touched_units().len();
1374                        row.preads = src.reads();
1375                        row.unit_bytes = src.bytes_read();
1376                        row.covered = src.covered_span();
1377                        row.manifest_bytes = stats.manifest_bytes_read;
1378                        row.index_bytes = stats.index_bytes_read;
1379                        row.seed_bytes = stats.seed_bytes_read;
1380                        row.answer_bytes = stats.bytes_returned;
1381                        row.closure_bytes = dir_bytes
1382                            .saturating_add(src.bytes_read())
1383                            .saturating_add(stats.manifest_bytes_read)
1384                            .saturating_add(stats.index_bytes_read)
1385                            .saturating_add(stats.seed_bytes_read);
1386                        row.matches_full = same_answer(&ans, &full_answer);
1387                        if !use_map {
1388                            map.insert(line.clone(), src.touched_units());
1389                        }
1390                        Ok(())
1391                    }
1392                    Ok(_) => Err("coverage miss (uncovered unit)".into()),
1393                    Err(e) => Err(format!("coarse lane declined: {e}")),
1394                }
1395            }
1396        };
1397        if let Err(reason) = outcome {
1398            // Honest fallback: the full read already produced the answer above.
1399            row.outcome = "fallback";
1400            row.fallback_reason = reason;
1401            row.manifest_bytes = full_stats.manifest_bytes_read;
1402            row.index_bytes = full_stats.index_bytes_read;
1403            row.seed_bytes = full_stats.seed_bytes_read;
1404            row.answer_bytes = full_stats.bytes_returned;
1405            row.closure_bytes = full_stats.bytes_read;
1406            row.matches_full = true;
1407        }
1408        rows.push(row);
1409    }
1410    if !use_map {
1411        save_map(&map_path, &map);
1412    }
1413    Ok(rows)
1414}
1415
1416/// The control closure (whole-descriptor read) for the same requests.
1417pub fn full_read(
1418    store_dir: &Path,
1419    packed: bool,
1420    field_hex: &str,
1421    requests: &[(String, ObserveRequest)],
1422    limits: Limits,
1423) -> Result<Vec<ObserveStats>> {
1424    let mut store = if packed {
1425        FieldStore::open_packed(store_dir)?
1426    } else {
1427        FieldStore::open(store_dir)?
1428    };
1429    let field_id = FieldId::from_hex(field_hex)?;
1430    let mut out = Vec::with_capacity(requests.len());
1431    for (_, req) in requests {
1432        let mut cold = req.clone();
1433        cold.use_cache = false;
1434        let (_, stats, _) = super::observe::observe(&mut store, &field_id, &cold, limits)?;
1435        out.push(stats);
1436    }
1437    Ok(out)
1438}