Skip to main content

vole_document/field/
edit.rs

1//! Immutable node-level edit of a procedural field (Phase 11.12).
2//!
3//! ## The shape of the witness
4//!
5//! A field is not a stack of unrelated whole-document snapshots: it is a
6//! content-addressed procedural **trajectory**. [`replace_page_content`] makes
7//! that concrete. Given an already-ingested, indexed field `R0` and a page `p`,
8//! it installs caller-supplied bytes as the new decoded content of exactly that
9//! page and returns a new root `R1` that:
10//!
11//! * reuses the exact same `.voldoc` descriptor blob and the same
12//!   `DocumentExact` root node id as `R0` (`descriptor_id` and `root_node` are
13//!   copied verbatim), so `R0` and `R1` both materialize the *same* original
14//!   bytes and neither weakens `materialize(root) == original_bytes`;
15//! * replaces only the `SEL_PAGE(p)` binding in the hierarchical observation
16//!   index with a new `PageContent` node whose single dependency is a raw
17//!   `Literal` holding the new content bytes;
18//! * carries **every other** selector binding forward by content id, so the new
19//!   index tree shares all unchanged leaf nodes with `R0` (content addressing
20//!   makes an unchanged leaf hash to an unchanged id; only the leaf that holds
21//!   `p` and the internal spine above it are rewritten);
22//! * and never reads the descriptor blob, never re-parses the document, and
23//!   never re-bakes the old source.
24//!
25//! The derived per-page projections (`ContentOperators`, `TextRuns`,
26//! `PagePreview`) are then recomputed lazily from the new content by the ordinary
27//! observation path, unchanged.
28//!
29//! ## What is shared vs copied, stated exactly
30//!
31//! **Shared by id** (never re-written): the descriptor blob, the `DocumentExact`
32//! root, every unaffected `PageContent`/`PdfObject`/`PdfStreamEncoded`/
33//! `PdfStreamDecoded`/`PdfRevision` seed node, and every index leaf that does not
34//! contain the edited page's selector.
35//!
36//! **Copied/newly written**: two new seed nodes (the `Literal` and its
37//! `PageContent`), the index leaf containing `SEL_PAGE(p)` and the internal
38//! spine from that leaf to a new root, and one new manifest.
39//!
40//! ## The honest supported subset
41//!
42//! This is **not** generic document editing. It is a single, narrow operation:
43//! *decode-level content override of one existing page of an indexed field*.
44//! The exact archive is untouched, so this is a *procedural* edit, not a rewrite
45//! of the PDF. It makes **no** authorial-intent claim, offers no insert/delete/
46//! reorder, no cross-reference or object-graph mutation, no re-encoding, and no
47//! change to any other page. The caller-supplied content is bounded by
48//! [`MAX_EDIT_CONTENT_BYTES`] (it must fit one canonical seed node). Any page
49//! whose content was overridden reports no source byte span in its index entry
50//! and no content-stream object numbers in its `structure` observation, because
51//! the new bytes are not present in the source; `text` and `preview` projections
52//! are computed from the new bytes as usual.
53
54use std::collections::{BTreeMap, BTreeSet};
55
56use crate::error::{Error, Result};
57use crate::field::index::{self, FsIndexStore, SEL_PAGE, SelectorKey};
58use crate::field::node::{MAX_NODE_BYTES, NodeKind, SeedNode, u32_params};
59use crate::field::{FieldId, FieldStore};
60use crate::store::{IoSnapshot, NodeId};
61
62/// Maximum caller-supplied content bytes one edit may install.
63///
64/// The bytes live in a `Literal` node's parameters, so the canonical node must
65/// fit [`MAX_NODE_BYTES`]. The margin covers the fixed node header (magic,
66/// version, kind, materializer, lengths, limits) and the provenance string.
67pub const MAX_EDIT_CONTENT_BYTES: usize = 48 * 1024;
68
69/// Seed nodes one edit always declares for the edited page: the `Literal` and
70/// its `PageContent`. This is a fixed count (not the number physically written),
71/// so an identical repeated edit hashes to the identical manifest.
72const EDIT_SEED_NODES: u64 = 2;
73
74/// What one immutable edit produced.
75///
76/// Every counter is a measured fact about *this* call; none of them is a
77/// claim about the document's semantics.
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct EditReport {
80    /// The new field root `R1`.
81    pub field: FieldId,
82    /// The field that was edited `R0` (left byte-valid and unchanged).
83    pub previous: FieldId,
84    /// The edited page (1-based, as in the observation index).
85    pub page: u32,
86    /// The new `PageContent` node that serves `SEL_PAGE(page)` in `R1`.
87    pub page_content: NodeId,
88    /// The `Literal` node holding the caller-supplied content bytes.
89    pub content_literal: NodeId,
90    /// The `R1` hierarchical index root.
91    pub index_root: NodeId,
92    /// Total selector bindings in the `R1` index.
93    pub index_entries: u64,
94    /// Bindings carried forward unchanged (same key, same node id) from `R0`.
95    pub index_entries_reused: u64,
96    /// Bindings whose node id changed (the edited page).
97    pub index_entries_replaced: u64,
98    /// New seed nodes written (checked against the store before writing).
99    pub seed_nodes_new: u64,
100    /// Seed nodes the edit intended to write that already existed by id.
101    pub seed_nodes_reused: u64,
102    /// `R1` index-tree nodes that already existed before the edit (so they were
103    /// not re-written): `R0`'s own tree nodes and any leaf an earlier edit left
104    /// behind.
105    pub index_nodes_reused: u64,
106    /// `R1` index-tree nodes the edit physically wrote for the first time.
107    pub index_nodes_new: u64,
108    /// Canonical payload bytes the edit actually had to persist: the new seed
109    /// nodes, the new index nodes, and the new manifest, each counted only when
110    /// it was not already present by content id. It excludes filesystem framing,
111    /// `.tmp` writes, and `fsync`.
112    pub bytes_newly_persisted: u64,
113    /// Descriptor-blob bytes the edit read (zero by construction).
114    pub descriptor_bytes_read: u64,
115    /// Field-manifest bytes the edit read.
116    pub manifest_bytes_read: u64,
117    /// Hierarchical-index bytes the edit read.
118    pub index_bytes_read: u64,
119    /// Seed-node bytes the edit read.
120    pub seed_bytes_read: u64,
121}
122
123/// Replace one page's decoded content with `new_content`, returning a new field
124/// root; the input field is never mutated.
125///
126/// See the module documentation for the exact supported subset. Fails closed
127/// (typed error, no partial write to any *new* root) when the field has no
128/// index, when the page is absent, or when the content exceeds
129/// [`MAX_EDIT_CONTENT_BYTES`].
130pub fn replace_page_content(
131    store: &mut FieldStore,
132    field: &FieldId,
133    page: u32,
134    new_content: &[u8],
135) -> Result<EditReport> {
136    if page == 0 {
137        return Err(Error::usage("edit page numbers are 1-based"));
138    }
139    if new_content.len() > MAX_EDIT_CONTENT_BYTES {
140        return Err(Error::resource_limit(format!(
141            "edit content is {} bytes > {MAX_EDIT_CONTENT_BYTES}",
142            new_content.len()
143        )));
144    }
145    let io_before = store.io().snapshot();
146    let io_handle = store.io().handle();
147    let previous = *field;
148
149    // Read the manifest only: no descriptor blob, no document parse.
150    let manifest = store.get_field(field)?;
151    if !manifest.has_index() {
152        return Err(Error::unsupported_feature(
153            "immutable edit requires an indexed field (ingest with a recovered index)",
154        ));
155    }
156    let old_root = NodeId::from_bytes(manifest.index_root);
157    let istore = FsIndexStore::open_with_io(store.root(), io_handle.clone())?;
158    let before = index::inspect(&istore, &old_root)?;
159    // Every index node that already exists physically (R0's tree plus any
160    // orphan from a prior edit), so "reused/new" is a physical fact.
161    let preexisting: BTreeSet<NodeId> = istore.list_ids()?.into_iter().collect();
162    let old_by_key: BTreeMap<SelectorKey, NodeId> =
163        before.entries.iter().map(|e| (e.key, e.node_id)).collect();
164
165    let page_key = SelectorKey::new(SEL_PAGE, page);
166    if !old_by_key.contains_key(&page_key) {
167        return Err(Error::usage(format!(
168            "field {previous} has no page {page} in its index"
169        )));
170    }
171
172    // Build the two new seed nodes. The literal carries the bytes; the
173    // page-content node is a one-dependency exact concatenation of them, so the
174    // ordinary `PageContent` materializer serves the new bytes unchanged.
175    let literal = SeedNode::new(
176        NodeKind::Literal,
177        new_content.len() as u64,
178        new_content.to_vec(),
179        Vec::new(),
180        format!("field:edit;literal;page={page}"),
181    );
182    let literal_canonical = literal.encode_canonical();
183    if literal_canonical.len() > MAX_NODE_BYTES {
184        return Err(Error::resource_limit(format!(
185            "edit content encodes to {} bytes > the {MAX_NODE_BYTES}-byte node framing limit",
186            literal_canonical.len()
187        )));
188    }
189    let literal_id = literal.content_id();
190    let page_content = SeedNode::new(
191        NodeKind::PageContent,
192        new_content.len() as u64,
193        u32_params(page),
194        vec![literal_id],
195        format!("field:edit;page-content;page={page}"),
196    );
197    let page_content_id = page_content.content_id();
198
199    let mut seed_nodes_new = 0u64;
200    let mut seed_nodes_reused = 0u64;
201    let mut bytes_newly_persisted = 0u64;
202    for node in [&literal, &page_content] {
203        let canonical = node.encode_canonical();
204        let id = NodeId::of_node(&canonical);
205        if store.seeds().contains_node(&id)? {
206            seed_nodes_reused = seed_nodes_reused.saturating_add(1);
207        } else {
208            seed_nodes_new = seed_nodes_new.saturating_add(1);
209            bytes_newly_persisted = bytes_newly_persisted.saturating_add(canonical.len() as u64);
210        }
211        store.seeds_mut().put_node(&canonical)?;
212    }
213
214    // Carry every binding forward by id, replacing only the edited page. The
215    // replaced entry loses its source span: the new bytes are not in the source.
216    let mut entries = before.entries.clone();
217    let mut index_entries_reused = 0u64;
218    let mut index_entries_replaced = 0u64;
219    for e in entries.iter_mut() {
220        if e.key == page_key {
221            e.node_id = page_content_id;
222            e.out_off = 0;
223            e.out_len = 0;
224            index_entries_replaced = index_entries_replaced.saturating_add(1);
225        } else if old_by_key.get(&e.key).copied() == Some(e.node_id) {
226            index_entries_reused = index_entries_reused.saturating_add(1);
227        }
228    }
229
230    let mut new_istore = FsIndexStore::open_with_io(store.root(), io_handle)?;
231    let new_root = index::build(&mut new_istore, &entries)?;
232    let (index_node_count, _depth, after_nodes) = index::validate_nodes(&new_istore, &new_root)?;
233    let index_nodes_reused = after_nodes
234        .iter()
235        .filter(|id| preexisting.contains(id))
236        .count() as u64;
237    let index_nodes_new = after_nodes.len() as u64 - index_nodes_reused;
238    for id in &after_nodes {
239        if !preexisting.contains(id) {
240            bytes_newly_persisted =
241                bytes_newly_persisted.saturating_add(new_istore.get(id)?.len() as u64);
242        }
243    }
244
245    // The new manifest changes only the index binding and counts; the descriptor
246    // id, the `DocumentExact` root, the universe, and the declared source are
247    // copied verbatim, which is what keeps `R1` exact for the original bytes.
248    let mut new_manifest = manifest.clone();
249    new_manifest.index_root = *new_root.as_bytes();
250    new_manifest.index_node_count = index_node_count;
251    new_manifest.node_count = manifest.node_count.saturating_add(EDIT_SEED_NODES);
252    new_manifest.provenance = format!("field:edit;page={page};prev={previous}");
253    let manifest_bytes = new_manifest.encode_canonical();
254    let new_id = new_manifest.content_id();
255    if !manifest_exists(store, &new_id)? {
256        bytes_newly_persisted = bytes_newly_persisted.saturating_add(manifest_bytes.len() as u64);
257    }
258    store.put_field(&new_manifest)?;
259
260    let io = io_before.delta(&store.io().snapshot());
261    Ok(EditReport {
262        field: new_id,
263        previous,
264        page,
265        page_content: page_content_id,
266        content_literal: literal_id,
267        index_root: new_root,
268        index_entries: entries.len() as u64,
269        index_entries_reused,
270        index_entries_replaced,
271        seed_nodes_new,
272        seed_nodes_reused,
273        index_nodes_reused,
274        index_nodes_new,
275        bytes_newly_persisted,
276        descriptor_bytes_read: io.descriptor_bytes,
277        manifest_bytes_read: io.manifest_bytes,
278        index_bytes_read: io.index_bytes,
279        seed_bytes_read: io.seed_bytes,
280    })
281}
282
283/// The raw physical-I/O accounting of an interval, re-exported for callers that
284/// want to attribute an edit without unpacking the report.
285pub fn io_of(report: &EditReport) -> IoSnapshot {
286    IoSnapshot {
287        descriptor_bytes: report.descriptor_bytes_read,
288        manifest_bytes: report.manifest_bytes_read,
289        index_bytes: report.index_bytes_read,
290        seed_bytes: report.seed_bytes_read,
291    }
292}
293
294/// Whether a manifest id is already present, without paying a manifest read on
295/// the filesystem backend (where a manifest is one file). The EntropyFS backend
296/// has no enumeration, so it falls back to a counted fetch.
297fn manifest_exists(store: &FieldStore, id: &FieldId) -> Result<bool> {
298    let path = store.root().join("field").join(id.to_hex());
299    if path.exists() {
300        return Ok(true);
301    }
302    Ok(store.get_field(id).is_ok())
303}