Skip to main content

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}