cmt/tree.rs
1//! The Canonical Mutable Tree state machine.
2
3use std::collections::BTreeMap;
4
5use spine::{ARITY_RANGE, Hasher, ProofStep, Seal, nary_mr};
6
7use crate::error::{Error, Result};
8use crate::shape::{self, ShapeNode, covers, leftmost, rightmost};
9
10/// Configuration for a [`Cmt`].
11///
12/// The mutable tree's only structural axis is the proof-spine arity `k`
13/// (`2..=256`), shared with the spine topology. (Prefix domain-separation is
14/// not a spine axis — an application that wants it wraps the [`Hasher`].)
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub struct Config {
17 /// Proof-spine arity `k` (`2..=256`).
18 pub arity: u64,
19}
20
21impl Default for Config {
22 fn default() -> Self {
23 Self { arity: 2 }
24 }
25}
26
27/// A logical cell of the tree: an opaque payload plus an opaque metadata
28/// channel the library carries but never interprets.
29///
30/// A cell is **positional** — addressed by its flat index, never by a key. The
31/// payload is hashed under each registered algorithm to give the cell its
32/// per-algorithm leaf digest (per-node multi-hash); the metadata bytes ride
33/// alongside untouched (INV-METADATA-AGNOSTIC).
34#[derive(Debug, Clone, PartialEq, Eq, Default)]
35struct Cell {
36 payload: Vec<u8>,
37 metadata: Vec<u8>,
38}
39
40/// The mutable materialization state for one registered algorithm: the current
41/// root and the materialized node cache. Split from the immutable hasher identity
42/// so [`Cmt::set`] can borrow the state mutably and the hasher immutably at the
43/// same time, without the remove-and-reinsert dance a single `Alg` struct forced.
44struct AlgState {
45 /// The materialized root digest at the current size, or `None` for the
46 /// empty tree.
47 root: Option<Vec<u8>>,
48 /// Materialized digest of every spine node — leaves and inner nodes —
49 /// keyed by the closed leaf interval `(leftmost, rightmost)` it covers.
50 /// `(size, arity)` fixes the shape, so this key is unique within one
51 /// materialization. A path-recompute reads every off-path node from here
52 /// and rewrites only the path, which is what bounds its work to `O(log n)`.
53 cache: BTreeMap<(u64, u64), Vec<u8>>,
54}
55
56impl std::fmt::Debug for AlgState {
57 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58 f.debug_struct("AlgState")
59 .field("root", &self.root)
60 .field("materialized_nodes", &self.cache.len())
61 .finish()
62 }
63}
64
65/// The Canonical Mutable Tree over the Merkle Spine.
66///
67/// Positional and dense: cells are addressed by flat index `0..len`. The tree
68/// shares the spine's proof-spine index space, so an inclusion proof generated
69/// here verifies with [`spine::verify_inclusion`] against the trusted
70/// `(index, tree_size, arity, root)` topology. Unlike the append-only CML it has
71/// no frontier and no consistency proofs — interior cells mutate, which the
72/// frontier's left-subtrees-sealed assumption cannot model.
73///
74/// Single-algorithm structurally, but the materialization holds one
75/// `{hasher, root, cache}` view per registered algorithm, so a node may be
76/// addressed under many algorithms (per-node multi-hash) and an algorithm may be
77/// added after the fact with the root recomputed in `O(log n)` along the changed
78/// node's ancestors only ([`Cmt::set`], retroactive add). The cross-tree binding
79/// of those per-algorithm roots is the `polydigest` combinator's concern, not the
80/// CMT's — the CMT exposes each algorithm's raw member [`root`](Cmt::root) and
81/// the structural [`seal`](Cmt::seal), never a binding/combined root.
82#[derive(Debug)]
83pub struct Cmt {
84 config: Config,
85 cells: Vec<Cell>,
86 /// Immutable hasher identity, keyed by stable algorithm ID.
87 hashers: BTreeMap<u64, Box<dyn Hasher>>,
88 /// Mutable materialization state, keyed by stable algorithm ID.
89 states: BTreeMap<u64, AlgState>,
90}
91
92impl Cmt {
93 /// Create an empty tree.
94 ///
95 /// Fails with [`Error::InvalidArity`] if `config.arity` is outside the
96 /// spine's `2..=256` range.
97 pub fn new(config: Config) -> Result<Self> {
98 if !ARITY_RANGE.contains(&config.arity) {
99 return Err(Error::InvalidArity(config.arity));
100 }
101 Ok(Self {
102 config,
103 cells: Vec::new(),
104 hashers: BTreeMap::new(),
105 states: BTreeMap::new(),
106 })
107 }
108
109 /// Register an algorithm under `alg_id`, hashing every existing cell under
110 /// it. This is the *bulk* registration cost (`O(n)` over `n` existing
111 /// cells), distinct from the per-node retroactive add below.
112 ///
113 /// Fails with [`Error::DuplicateAlgorithm`] if `alg_id` is already
114 /// registered.
115 pub fn register_algorithm(&mut self, alg_id: u64, hasher: Box<dyn Hasher>) -> Result<()> {
116 if self.hashers.contains_key(&alg_id) {
117 return Err(Error::DuplicateAlgorithm(alg_id));
118 }
119 let mut state = AlgState {
120 root: None,
121 cache: BTreeMap::new(),
122 };
123 recompute_full(&self.cells, self.config.arity, hasher.as_ref(), &mut state);
124 self.hashers.insert(alg_id, hasher);
125 self.states.insert(alg_id, state);
126 Ok(())
127 }
128
129 /// Set the payload (and opaque metadata) of cell `index`.
130 ///
131 /// Indices extend the tree densely: setting `index == len` appends a cell;
132 /// setting an existing index overwrites it. A gap (`index > len`) is
133 /// rejected with [`Error::IndexGap`] — the tree is positional and dense,
134 /// not sparse.
135 ///
136 /// Overwriting an existing cell recomputes only that cell's ancestor path
137 /// in every registered algorithm: `O(log n)` per algorithm. Appending may
138 /// change the spine shape and is recomputed against the new shape.
139 pub fn set(&mut self, index: u64, payload: Vec<u8>, metadata: Vec<u8>) -> Result<()> {
140 let len = self.cells.len() as u64;
141 if index > len {
142 return Err(Error::IndexGap { index, len });
143 }
144 let cell = Cell { payload, metadata };
145 let appended = index == len;
146 if appended {
147 self.cells.push(cell);
148 } else {
149 self.cells[index as usize] = cell;
150 }
151
152 // Split borrows: `states` is mutable, `hashers` is immutable; no
153 // remove-and-reinsert needed since the two maps are independent.
154 for (id, state) in &mut self.states {
155 let h = self
156 .hashers
157 .get(id)
158 .expect("states and hashers are in sync")
159 .as_ref();
160 if appended {
161 // The spine shape may change on append, so recompute fully.
162 recompute_full(&self.cells, self.config.arity, h, state);
163 } else {
164 let _ = recompute_path(&self.cells, self.config.arity, h, state, index);
165 }
166 }
167 Ok(())
168 }
169
170 /// Read the payload of cell `index`, or `None` if out of range.
171 #[must_use]
172 pub fn get(&self, index: u64) -> Option<&[u8]> {
173 self.cells.get(index as usize).map(|c| c.payload.as_slice())
174 }
175
176 /// Read the opaque metadata of cell `index`, or `None` if out of range.
177 ///
178 /// The bytes are returned verbatim; the library never parses them
179 /// (INV-METADATA-AGNOSTIC).
180 #[must_use]
181 pub fn metadata(&self, index: u64) -> Option<&[u8]> {
182 self.cells
183 .get(index as usize)
184 .map(|c| c.metadata.as_slice())
185 }
186
187 /// Number of cells in the tree.
188 #[must_use]
189 pub fn len(&self) -> u64 {
190 self.cells.len() as u64
191 }
192
193 /// Whether the tree holds no cells.
194 #[must_use]
195 pub fn is_empty(&self) -> bool {
196 self.cells.is_empty()
197 }
198
199 /// The configured proof-spine arity.
200 #[must_use]
201 pub fn arity(&self) -> u64 {
202 self.config.arity
203 }
204
205 /// The current per-algorithm **member root** under `alg_id` — the raw root
206 /// the leaves authenticate against — or `None` if the algorithm is
207 /// unregistered or the tree is empty.
208 ///
209 /// This is the structural per-algorithm root. The cross-tree **binding /
210 /// combined root** that folds every algorithm's member root together is the
211 /// `polydigest` combinator's derived view, not the CMT's (D9/D12); the CMT exposes
212 /// the raw member roots it materializes and leaves the binding to `polydigest`.
213 #[must_use]
214 pub fn root(&self, alg_id: u64) -> Option<Vec<u8>> {
215 self.states.get(&alg_id).and_then(|s| s.root.clone())
216 }
217
218 /// The set of registered algorithm IDs paired with their current member
219 /// root, sorted by algorithm ID, skipping any with no root (empty tree).
220 ///
221 /// The `polydigest` combinator reads these raw member roots as the children of
222 /// its binding/combined-root fold; the CMT itself never folds them.
223 #[must_use]
224 pub fn member_roots(&self) -> Vec<(u64, Vec<u8>)> {
225 self.states
226 .iter()
227 .filter_map(|(&id, s)| s.root.clone().map(|r| (id, r)))
228 .collect()
229 }
230
231 /// The hasher registered under `alg_id`, or `None` if unregistered.
232 ///
233 /// The `polydigest` combinator needs the algorithm's own hash to fold the binding
234 /// root over the member roots; the CMT materializes the per-algorithm roots
235 /// but never folds them, so it lends the hasher rather than computing the
236 /// binding root itself.
237 #[must_use]
238 pub fn hasher(&self, alg_id: u64) -> Option<&dyn Hasher> {
239 self.hashers.get(&alg_id).map(AsRef::as_ref)
240 }
241
242 // --- proofs --------------------------------------------------------------
243
244 /// Generate an inclusion proof for cell `index` under `alg_id`.
245 ///
246 /// Returns the leaf digest and the proof path. The path verifies with
247 /// [`spine::verify_inclusion`] against the trusted `(index, len, arity, root)`
248 /// — the CMT shares the spine index space, so it generates paths the spine
249 /// checks rather than running a second verifier. Returns `None` if `alg_id`
250 /// is unregistered or `index` is out of range.
251 #[must_use]
252 pub fn inclusion_proof(&self, alg_id: u64, index: u64) -> Option<(Vec<u8>, Vec<ProofStep>)> {
253 if index >= self.len() {
254 return None;
255 }
256 let h = self.hashers.get(&alg_id)?.as_ref();
257 let state = self.states.get(&alg_id)?;
258 let shape = shape::build(self.len(), self.config.arity)?;
259 let leaf_hash = leaf_digest_raw(&self.cells, h, index);
260 let path = crate::proof::inclusion_path(
261 &shape,
262 index,
263 &state.cache,
264 &mut |pos| leaf_digest_raw(&self.cells, h, pos),
265 &mut |children| nary_mr(h, children),
266 );
267 Some((leaf_hash, path))
268 }
269
270 /// Produce a self-contained [`spine::LeafProof`] for cell `index` under
271 /// `alg_id` — the live "is this a legitimate leaf?" witness, peer of the
272 /// inclusion proof. It bundles the leaf digest with its trusted positional
273 /// parameters `(index, len, arity)` and the inclusion path, so a consumer
274 /// verifies with one [`spine::LeafProof::verify`] call against an
275 /// authenticated root. Returns `None` for an unregistered algorithm or an
276 /// out-of-range index.
277 #[must_use]
278 pub fn leaf_proof(&self, alg_id: u64, index: u64) -> Option<spine::LeafProof> {
279 let (leaf_hash, path) = self.inclusion_proof(alg_id, index)?;
280 Some(spine::LeafProof::new(
281 leaf_hash,
282 index,
283 self.len(),
284 self.config.arity,
285 path,
286 ))
287 }
288
289 /// Generate a non-membership proof for `index` under `alg_id`: an inclusion
290 /// proof for the spine null constant (SAD §5, inclusion-of-null via
291 /// collapse).
292 ///
293 /// Succeeds only when cell `index` actually hashes to `null()` — i.e. the
294 /// position carries no real value — returning the null leaf digest and the
295 /// proof path. A cell with a real payload is *present*, so non-membership
296 /// returns `None`. Returns `None` for an unregistered algorithm or an
297 /// out-of-range index.
298 #[must_use]
299 pub fn non_membership_proof(
300 &self,
301 alg_id: u64,
302 index: u64,
303 ) -> Option<(Vec<u8>, Vec<ProofStep>)> {
304 let h = self.hashers.get(&alg_id)?.as_ref();
305 let (leaf_hash, path) = self.inclusion_proof(alg_id, index)?;
306 if leaf_hash == h.null() {
307 Some((leaf_hash, path))
308 } else {
309 None
310 }
311 }
312
313 /// Add `alg_id` to a single cell `index` after the fact, recomputing the
314 /// root in `O(log n)` along that cell's ancestors only (D11, the
315 /// "Post-Facto Digest" / retroactive algorithm addition).
316 ///
317 /// This is the mutable tree's *incremental* multi-hash operation: distinct
318 /// from bulk filling (a `polydigest` operator, `O(n)`, which re-derives a whole
319 /// algorithm's history). Here a node gains a digest under a *newly seeded*
320 /// algorithm and only its ancestor path is touched; positions other than
321 /// `index` contribute their already-materialized digests (the null
322 /// constant for positions never hashed under this algorithm).
323 ///
324 /// Returns the number of inner-node digests the per-node add recomputed —
325 /// the cell's ancestor depth, the `O(log n)` cost witness (the initial null
326 /// seeding is the one-time `O(n)` materialization, not the per-node cost).
327 ///
328 /// Fails with [`Error::DuplicateAlgorithm`] if `alg_id` is already
329 /// registered and with [`Error::IndexGap`] if `index` is out of range.
330 pub fn add_algorithm_at(
331 &mut self,
332 alg_id: u64,
333 index: u64,
334 hasher: Box<dyn Hasher>,
335 ) -> Result<usize> {
336 if self.hashers.contains_key(&alg_id) {
337 return Err(Error::DuplicateAlgorithm(alg_id));
338 }
339 if index >= self.len() {
340 return Err(Error::IndexGap {
341 index,
342 len: self.len(),
343 });
344 }
345 // Seed the algorithm with every position null, then path-recompute the
346 // single cell that gains a digest — touching only its ancestors.
347 let mut state = AlgState {
348 root: None,
349 cache: BTreeMap::new(),
350 };
351 let recomputed = add_alg_seeded(
352 &self.cells,
353 self.config.arity,
354 hasher.as_ref(),
355 &mut state,
356 index,
357 );
358 self.hashers.insert(alg_id, hasher);
359 self.states.insert(alg_id, state);
360 Ok(recomputed)
361 }
362
363 /// Consume the tree and seal it into the general structural [`spine::Seal`]
364 /// (D13, the structural facet of the seal chain).
365 ///
366 /// One-way: there is no `unseal` and no path back to a `Cmt`
367 /// (C-SEAL-ONEWAY). The seal **computes the resumable frontier** — every
368 /// registered algorithm's frontier peaks (the digests of the perfect k-ary
369 /// subtrees the spine topology names at this size). The `Seal` carries **no
370 /// committed epoch timeline and no binding root** — those are the *epoch
371 /// facet*, added by the `polydigest` combinator as a wrapper over this general
372 /// `Seal` (`polydigest(cmt)`), never baked in here.
373 ///
374 /// A live `Cmt` has no frontier stack — that absence is the mutable/append
375 /// tell. Computing the peaks at seal erases the distinction, so every `Seal`
376 /// uniformly carries a resumable frontier regardless of source kind, and the
377 /// member root every consumer sees is the fold of those peaks (identical to
378 /// the tree's own root, since both fold the same perfect-subtree digests).
379 ///
380 /// # No `from_seal` — why a `Seal` cannot revive a `Cmt`
381 ///
382 /// There is deliberately **no `Cmt::from_seal`**. A frontier is the
383 /// *complete* continuation state of an append-only log (every future append
384 /// folds against the peaks alone), but only *partial* state for a mutable
385 /// tree: mutating an interior cell needs every cell's digest along its
386 /// ancestor path, and the seal dropped all of those, keeping only the peaks.
387 /// Reviving arbitrary mutation over the committed positions would also
388 /// *un-seal the committed past* — the one-way guarantee the seal exists to
389 /// make. The way to a readable, mutable-or-append tree over the committed
390 /// data is the `polydigest` combinator's `fill` (data-required), which rebuilds
391 /// and verifies against the committed binding root; the discarded frontier is
392 /// simply unused when the fill target is a mutable tree.
393 ///
394 /// Fails with [`Error::EmptySeal`] on an empty tree (nothing to seal) and
395 /// propagates [`Error::MalformedSeal`] if the spine rejects the frontier.
396 pub fn seal(self) -> Result<Seal> {
397 if self.cells.is_empty() {
398 return Err(Error::EmptySeal);
399 }
400 let size = self.cells.len() as u64;
401 let k = self.config.arity;
402 let coords = spine::frontier_for_size(size, k);
403 let mut frontiers: Vec<(u64, Vec<Vec<u8>>)> = Vec::with_capacity(self.states.len());
404 // Iterate states mutably for peak_digest's defensive cache-healing fallback.
405 let mut states = self.states;
406 for (id, state) in &mut states {
407 if state.root.is_none() {
408 continue;
409 }
410 let h = self
411 .hashers
412 .get(id)
413 .expect("states and hashers are in sync")
414 .as_ref();
415 let peaks: Vec<Vec<u8>> = coords
416 .iter()
417 .map(|&(left, height)| peak_digest(&self.cells, h, state, left, height, k))
418 .collect();
419 frontiers.push((*id, peaks));
420 }
421 Seal::new(size, k, frontiers).map_err(|_| Error::MalformedSeal)
422 }
423
424 /// Like [`Self::inclusion_proof`] but also returns the number of off-path
425 /// cache misses during proof generation. A fully materialized tree has zero
426 /// misses; any positive count is a regression signal (F4 perf invariant).
427 #[cfg(test)]
428 pub(crate) fn inclusion_proof_miss_count(
429 &self,
430 alg_id: u64,
431 index: u64,
432 ) -> Option<(Vec<u8>, Vec<ProofStep>, usize)> {
433 if index >= self.len() {
434 return None;
435 }
436 let h = self.hashers.get(&alg_id)?.as_ref();
437 let state = self.states.get(&alg_id)?;
438 let shape = shape::build(self.len(), self.config.arity)?;
439 let leaf_hash = leaf_digest_raw(&self.cells, h, index);
440 let (path, misses) = crate::proof::inclusion_path_with_miss_count(
441 &shape,
442 index,
443 &state.cache,
444 &mut |pos| leaf_digest_raw(&self.cells, h, pos),
445 &mut |children| nary_mr(h, children),
446 );
447 Some((leaf_hash, path, misses))
448 }
449}
450
451// --- free helper functions --------------------------------------------------
452
453/// Rebuild one algorithm's materialized spine from scratch (`O(n)`). Used on
454/// registration and on append, where the spine shape may change.
455fn recompute_full(cells: &[Cell], arity: u64, hasher: &dyn Hasher, state: &mut AlgState) {
456 state.cache.clear();
457 state.root = shape::build(cells.len() as u64, arity).map(|shape| {
458 eval_subtree(&mut state.cache, hasher, &shape, &mut |pos| {
459 leaf_digest_raw(cells, hasher, pos)
460 })
461 });
462}
463
464/// Recompute only the ancestor path of cell `index` (`O(log n)`), returning
465/// the number of inner-node digests recomputed.
466///
467/// Walks from the root to the leaf following child spans, recomputing each
468/// inner digest on the way back up. Inner nodes off the path are read from
469/// the materialized cache untouched — that is what bounds the work to the
470/// path length. The returned count is the locality witness inspected by the
471/// `O(log n)` property test: it is the ancestor depth, not the total node
472/// count.
473fn recompute_path(
474 cells: &[Cell],
475 arity: u64,
476 hasher: &dyn Hasher,
477 state: &mut AlgState,
478 index: u64,
479) -> usize {
480 let Some(shape) = shape::build(cells.len() as u64, arity) else {
481 state.root = None;
482 return 0;
483 };
484 let mut recomputed = 0usize;
485 state.root = Some(eval_on_path(
486 cells,
487 hasher,
488 state,
489 &shape,
490 index,
491 &mut recomputed,
492 ));
493 recomputed
494}
495
496/// Recompute the digests on the path to `index`, reading every off-path node
497/// from the cache. Only nodes on the path (the changed leaf and its
498/// ancestors) are recomputed and re-cached; counts the inner nodes
499/// recomputed via `recomputed`.
500fn eval_on_path(
501 cells: &[Cell],
502 hasher: &dyn Hasher,
503 state: &mut AlgState,
504 node: &ShapeNode,
505 index: u64,
506 recomputed: &mut usize,
507) -> Vec<u8> {
508 match node {
509 ShapeNode::Leaf(_) => {
510 let digest = leaf_digest_raw(cells, hasher, shape::leftmost(node));
511 state.cache.insert(node_key(node), digest.clone());
512 digest
513 },
514 ShapeNode::Inner(children) => {
515 let mut refs_owned: Vec<Vec<u8>> = Vec::with_capacity(children.len());
516 for child in children {
517 if covers(child, index) {
518 refs_owned.push(eval_on_path(cells, hasher, state, child, index, recomputed));
519 } else {
520 // Off the path: read the materialized digest, never
521 // re-hashing the subtree. The callers always leave a
522 // complete materialization, so a miss is a logic error;
523 // fall back to a full eval defensively rather than
524 // silently producing a wrong root.
525 let cached = state
526 .cache
527 .get(&node_key(child))
528 .cloned()
529 .unwrap_or_else(|| {
530 eval_subtree(&mut state.cache, hasher, child, &mut |pos| {
531 leaf_digest_raw(cells, hasher, pos)
532 })
533 });
534 refs_owned.push(cached);
535 }
536 }
537 let refs: Vec<&[u8]> = refs_owned.iter().map(Vec::as_slice).collect();
538 let digest = nary_mr(hasher, &refs);
539 state.cache.insert(node_key(node), digest.clone());
540 *recomputed += 1;
541 digest
542 },
543 }
544}
545
546/// Materialize every spine node as the null digest (the state before any
547/// cell has been hashed under a freshly added algorithm). `O(n)` to seed
548/// once; the subsequent per-node add is `O(log n)`.
549fn seed_null(cells: &[Cell], arity: u64, hasher: &dyn Hasher, state: &mut AlgState) {
550 state.cache.clear();
551 let null = hasher.null();
552 state.root = shape::build(cells.len() as u64, arity).map(|shape| {
553 // Cache the leaf as null so the subsequent path-recompute reads
554 // every off-path sibling from the cache (never the real
555 // payload): only the target cell gains a real digest.
556 eval_subtree(&mut state.cache, hasher, &shape, &mut |_| null.clone())
557 });
558}
559
560/// Seed a fresh algorithm with all-null digests and then recompute the
561/// single path to `index`. The ordering is structural: seed MUST precede
562/// path-recompute (a swapped call order produces a correct root at O(n)
563/// cost instead of O(log n), caught only by a perf property test).
564fn add_alg_seeded(
565 cells: &[Cell],
566 arity: u64,
567 hasher: &dyn Hasher,
568 state: &mut AlgState,
569 index: u64,
570) -> usize {
571 seed_null(cells, arity, hasher, state);
572 recompute_path(cells, arity, hasher, state, index)
573}
574
575/// The materialized digest of the perfect k-ary subtree at frontier
576/// coordinate `(left, height)` — the closed leaf interval
577/// `[left, left + k^height - 1]`. Read from the algorithm's cache; a miss
578/// is a logic error (the tree is always fully materialized before seal), but
579/// the defensive fallback re-evaluates and caches the subtree rather than
580/// silently producing a wrong digest.
581fn peak_digest(
582 cells: &[Cell],
583 hasher: &dyn Hasher,
584 state: &mut AlgState,
585 left: u64,
586 height: u32,
587 k: u64,
588) -> Vec<u8> {
589 let right = left + k.pow(height) - 1;
590 if let Some(d) = state.cache.get(&(left, right)) {
591 return d.clone();
592 }
593 let shape = shape::perfect(left, height, k);
594 eval_subtree(&mut state.cache, hasher, &shape, &mut |pos| {
595 leaf_digest_raw(cells, hasher, pos)
596 })
597}
598
599/// Evaluate a subtree's digest, materializing every node — leaves and inner
600/// nodes alike — into `cache`. Parameterized over leaf behavior via `leaf_fn`:
601///
602/// - Real digests: `|pos| leaf_digest_raw(cells, hasher, pos)`
603/// - Null seeding: `|_| null_digest.clone()`
604///
605/// Caching leaves lets a later path-recompute ([`recompute_path`]) read every
606/// off-path sibling from the cache without ever re-hashing the subtree.
607fn eval_subtree(
608 cache: &mut BTreeMap<(u64, u64), Vec<u8>>,
609 hasher: &dyn Hasher,
610 node: &ShapeNode,
611 leaf_fn: &mut dyn FnMut(u64) -> Vec<u8>,
612) -> Vec<u8> {
613 let key = node_key(node);
614 match node {
615 ShapeNode::Leaf(pos) => {
616 let digest = leaf_fn(*pos);
617 cache.insert(key, digest.clone());
618 digest
619 },
620 ShapeNode::Inner(children) => {
621 let child_digests: Vec<Vec<u8>> = children
622 .iter()
623 .map(|c| eval_subtree(cache, hasher, c, leaf_fn))
624 .collect();
625 let refs: Vec<&[u8]> = child_digests.iter().map(Vec::as_slice).collect();
626 let digest = nary_mr(hasher, &refs);
627 cache.insert(key, digest.clone());
628 digest
629 },
630 }
631}
632
633/// The leaf digest of cell `pos` in `cells` under `hasher`. An absent cell
634/// (position beyond the end) hashes the null constant.
635fn leaf_digest_raw(cells: &[Cell], hasher: &dyn Hasher, pos: u64) -> Vec<u8> {
636 match cells.get(pos as usize) {
637 Some(cell) => hasher.leaf(&cell.payload),
638 None => hasher.null(),
639 }
640}
641
642/// A stable cache key for a spine node: the closed interval of leaf positions it
643/// covers, `(leftmost, rightmost)`. `(size, arity)` fixes the shape, so a node
644/// is uniquely identified by the leaves beneath it — distinguishing nested
645/// left-aligned nodes (e.g. the root and its leftmost descendants), which a
646/// `(leftmost, child_count)` key would alias.
647fn node_key(node: &ShapeNode) -> (u64, u64) {
648 (leftmost(node), rightmost(node))
649}