cmt/shape.rs
1//! The proof-spine shape, materialized as an explicit positional tree.
2//!
3//! The CMT is *mutable*, so it cannot reuse the append-only log's frontier
4//! (left-subtrees-sealed is unsound once interior cells change). Instead it
5//! materializes the proof spine — the same `(size, arity)` → topology the
6//! [`spine`] crate pins inclusion proofs against — as an explicit tree of
7//! [`ShapeNode`]s over the leaf positions. A single cell's change recomputes only
8//! the digests on its root-path, which is what makes both `set` and retroactive
9//! per-node algorithm addition cost `O(log n)` rather than a full `O(n)` rebuild.
10//!
11//! The shape is derived purely from `(size, arity)` by two rules the CMT owns:
12//! decompose into a frontier of perfect k-ary subtrees ([`spine::frontier_for_size`],
13//! the shared structural primitive), then fold the frontier by repeatedly
14//! grouping the rightmost `k` — the **rebalanced** topology. The spine itself is
15//! topology-agnostic (it owns no fold); the CMT owns this rebalanced fold and the
16//! matching [`rebalanced_skeleton`] the spine verifier pins a proof against, the
17//! mutable peer of the append-only log's mountain range. [`crate::Cmt::root`] is
18//! property-tested to equal [`spine::evaluate`] over the canonical subtree, so a
19//! drift in this shape is caught deterministically.
20
21use spine::{ARITY_RANGE, Hasher, SkeletonStep, fold_frontier, frontier_for_size, nary_mr};
22
23/// One node of the materialized proof spine.
24///
25/// A `Leaf(position)` names a logical cell; an `Inner(children)` is a hashing
26/// node whose children are shape nodes left-to-right. The shape is uniquely
27/// determined by `(size, arity)` — never stored, always re-derived — so it
28/// stays in lockstep with the spine topology that inclusion proofs check.
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub enum ShapeNode {
31 /// A logical leaf cell at this flat position.
32 Leaf(u64),
33 /// A hashing inner node over its children, left to right.
34 Inner(Vec<ShapeNode>),
35}
36
37/// Build the proof-spine shape for `size` leaves at arity `k`.
38///
39/// Returns `None` when the shape is undefined: `k` out of the spine's
40/// `2..=256` range, or an empty tree (`size == 0`) — a CMT always has a root
41/// only once it holds at least one cell.
42#[must_use]
43pub fn build(size: u64, k: u64) -> Option<ShapeNode> {
44 if !ARITY_RANGE.contains(&k) || size == 0 {
45 return None;
46 }
47 let coords = frontier_for_size(size, k);
48 if coords.is_empty() {
49 return None;
50 }
51
52 // Each frontier entry is a perfect k-ary subtree rooted at its left index.
53 let frontier: Vec<ShapeNode> = coords
54 .iter()
55 .map(|&(left, height)| perfect(left, height, k))
56 .collect();
57
58 // Fold the frontier by repeatedly grouping the rightmost `k` via
59 // `fold_frontier` — the shared canonical grouping loop. A singleton frontier
60 // promotes to the subtree directly; otherwise the final group becomes the root
61 // inner node.
62 Some(fold_frontier(frontier, k as usize, |chunk| {
63 ShapeNode::Inner(chunk.to_vec())
64 }))
65}
66
67/// A perfect k-ary subtree of the given `height`, rooted so its leftmost leaf is
68/// flat position `left`. Height 0 is a lone leaf.
69pub(crate) fn perfect(left: u64, height: u32, k: u64) -> ShapeNode {
70 if height == 0 {
71 return ShapeNode::Leaf(left);
72 }
73 let child_span = k.pow(height - 1);
74 let children = (0..k)
75 .map(|c| perfect(left + c * child_span, height - 1, k))
76 .collect();
77 ShapeNode::Inner(children)
78}
79
80/// The leftmost flat leaf position covered by `node`.
81pub(crate) fn leftmost(node: &ShapeNode) -> u64 {
82 match node {
83 ShapeNode::Leaf(pos) => *pos,
84 ShapeNode::Inner(children) => leftmost(&children[0]),
85 }
86}
87
88/// The rightmost flat leaf position covered by `node`.
89pub(crate) fn rightmost(node: &ShapeNode) -> u64 {
90 match node {
91 ShapeNode::Leaf(pos) => *pos,
92 ShapeNode::Inner(children) => rightmost(children.last().expect("inner node has children")),
93 }
94}
95
96/// Whether `node` covers flat position `index`.
97pub(crate) fn covers(node: &ShapeNode, index: u64) -> bool {
98 leftmost(node) <= index && index <= rightmost(node)
99}
100
101/// The CMT's concrete inclusion skeleton for `index` in a tree of `size` leaves
102/// at arity `k` — the rebalanced topology the spine verifier pins a proof
103/// against.
104///
105/// Walks the [`build`] shape from the root down to the leaf, emitting one
106/// [`SkeletonStep`] per inner node on the path; the steps are returned leaf →
107/// root, matching the proof path order. Returns `None` for an undefined shape
108/// (`k` out of range, `size == 0`, or `index >= size`).
109///
110/// This is the mutable peer of the append-only log's `mountain_skeleton`: the
111/// spine owns no concrete topology, so the CMT supplies its own. `build` is the
112/// single shape source, so the producer's `inclusion_path` and this verifier
113/// skeleton cannot drift.
114#[must_use]
115pub fn rebalanced_skeleton(size: u64, k: u64, index: u64) -> Option<Vec<SkeletonStep>> {
116 if index >= size {
117 return None;
118 }
119 let shape = build(size, k)?;
120 let mut steps = Vec::new();
121 descend_skeleton(&shape, index, &mut steps);
122 // `descend_skeleton` pushes root → leaf; the proof path is leaf → root.
123 steps.reverse();
124 Some(steps)
125}
126
127/// Push one [`SkeletonStep`] per inner node on the root → leaf path to `index`,
128/// in root → leaf order. A lone-child inner node never occurs in `build` (a
129/// perfect subtree or a `2..=k` group), so every emitted step hashes.
130fn descend_skeleton(node: &ShapeNode, index: u64, out: &mut Vec<SkeletonStep>) {
131 if let ShapeNode::Inner(children) = node {
132 let position = children
133 .iter()
134 .position(|c| covers(c, index))
135 .expect("the path index is covered by exactly one child");
136 out.push(SkeletonStep {
137 position,
138 sibling_count: children.len() - 1,
139 });
140 descend_skeleton(&children[position], index, out);
141 }
142}
143
144/// Bag a frontier's peaks into the CMT member root under `hasher` — the
145/// rebalanced fold, grouping the rightmost `k` (the mutable peer of the
146/// append-only log's backward-bag). Mirrors [`build`]'s grouping so the folded
147/// member root equals the live [`crate::Cmt::root`] over the same peaks. An empty
148/// peak set is the empty-tree root.
149#[must_use]
150pub fn rebalanced_bag(hasher: &dyn Hasher, peaks: &[Vec<u8>], k: u64) -> Vec<u8> {
151 if peaks.is_empty() {
152 return hasher.empty();
153 }
154 fold_frontier(peaks.to_vec(), k as usize, |chunk| {
155 let refs: Vec<&[u8]> = chunk.iter().map(|v| v.as_slice()).collect();
156 nary_mr(hasher, &refs)
157 })
158}
159
160#[cfg(test)]
161mod tests {
162 use super::*;
163
164 #[test]
165 fn rejects_undefined_shapes() {
166 assert_eq!(build(0, 2), None);
167 assert_eq!(build(4, 1), None);
168 assert_eq!(build(4, 257), None);
169 }
170
171 #[test]
172 fn singleton_is_a_lone_leaf() {
173 assert_eq!(build(1, 2), Some(ShapeNode::Leaf(0)));
174 }
175
176 /// Every flat position `0..size` appears exactly once, left to right.
177 #[test]
178 fn covers_every_position_in_order() {
179 for k in [2u64, 3, 5] {
180 for size in 1..=64u64 {
181 let mut seen = Vec::new();
182 collect_leaves(&build(size, k).expect("defined"), &mut seen);
183 let expected: Vec<u64> = (0..size).collect();
184 assert_eq!(seen, expected, "k={k} size={size}");
185 }
186 }
187 }
188
189 fn collect_leaves(node: &ShapeNode, out: &mut Vec<u64>) {
190 match node {
191 ShapeNode::Leaf(p) => out.push(*p),
192 ShapeNode::Inner(children) => {
193 for c in children {
194 collect_leaves(c, out);
195 }
196 },
197 }
198 }
199}