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}