Skip to main content

cml/
engine.rs

1//! The single-algorithm canonical-log engine over the [`spine`].
2//!
3//! A [`AlgView`] is **one algorithm's** continuation state — its hasher, its
4//! committed epoch intervals, and its frontier stack — and the free functions
5//! here are the structural operations over it: the base-k frontier carry, the
6//! member-root fold, inclusion/consistency proof generation, and the historical
7//! root reconstructions. Every operation that touches stored bytes reads them
8//! through a borrowed [`NodeReader`]; the engine never owns the store, so the
9//! `polydigest` combinator can drive **N** views over **one** shared substrate
10//! without duplicating leaf data (D14). The engine names no epoch concept: a
11//! view's epoch intervals are read locally only to project a coordinate's
12//! null/active value, never to bind a cross-tree timeline.
13
14use spine::{Hasher, frontier_for_size, nary_mr};
15
16use crate::consistency::ProofStep;
17use crate::error::{Error, Result};
18use crate::mountain::{bag_path, bag_peaks, mountain_skeleton};
19use crate::schedule::reduction_count;
20
21/// A borrowed read substrate the single-algorithm engine folds over.
22///
23/// The engine reads stored node digests and (for flat logs) raw leaf payloads
24/// through this trait; it never writes and never owns the store. The `polydigest`
25/// combinator implements it over its one shared storage so N algorithm views
26/// share a single data substrate (D14). The `alg_id` selects which algorithm's
27/// node namespace the read targets — leaf payloads are algorithm-agnostic and
28/// stored once.
29pub trait NodeReader {
30    /// The read backend's error type.
31    type Error;
32
33    /// Retrieve a sealed internal node digest at `(alg_id, left, height)`, or
34    /// `None` if no node is stored there.
35    fn get_node(
36        &self,
37        alg_id: u64,
38        left: u64,
39        height: u32,
40    ) -> impl std::future::Future<Output = ReadResult<Option<Vec<u8>>, Self>> + Send;
41
42    /// Retrieve the raw leaf payload at `index` (flat logs only).
43    fn get_leaf(
44        &self,
45        index: u64,
46    ) -> impl std::future::Future<Output = ReadResult<Vec<u8>, Self>> + Send;
47}
48
49/// The `Result` a [`NodeReader`] read yields: a value `T` or the backend's own
50/// error. Aliased so the trait's `impl Future` return types stay readable.
51pub type ReadResult<T, R> = std::result::Result<T, <R as NodeReader>::Error>;
52
53/// Whether log-level appends are flat leaf appends or subtree appends.
54///
55/// The single-algorithm engine reads this only to know whether a height-0 cell
56/// is a raw leaf payload (flat) or an authoritative stored subtree digest
57/// (subtree); the combinator owns the value and its persistence.
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub enum LogKind {
60    /// Each append is a raw leaf; the payload is stored verbatim for auditability.
61    Flat,
62    /// Each append is a subtree root; only the evaluated root hash is stored.
63    Subtree,
64}
65
66impl LogKind {
67    /// Serialize to the storage byte representation.
68    #[must_use]
69    pub fn to_byte(self) -> u8 {
70        match self {
71            Self::Flat => 0,
72            Self::Subtree => 1,
73        }
74    }
75
76    /// Deserialize from the storage byte representation.
77    ///
78    /// Returns `None` if the byte is not a recognized `LogKind` variant.
79    #[must_use]
80    pub fn from_byte(b: u8) -> Option<Self> {
81        match b {
82            0 => Some(Self::Flat),
83            1 => Some(Self::Subtree),
84            _ => None,
85        }
86    }
87}
88
89/// One algorithm's single-tree continuation state: its hasher, committed epoch
90/// intervals, and frontier stack. This is the **per-algorithm frontier only**
91/// (D14) — the shared leaf data lives once in the combinator's store, never
92/// here.
93#[derive(Debug)]
94pub struct AlgView {
95    /// Hasher instance.
96    pub hasher: Box<dyn Hasher>,
97    /// Epoch intervals: half-open `[start, end)`.
98    /// `end == u64::MAX` represents an active (open) epoch.
99    pub epochs: Vec<(u64, u64)>,
100    /// Frontier stack: holds hashes of completed subtrees.
101    pub frontier: Vec<Vec<u8>>,
102    /// Coordinates of each frontier node: `(left_index, height)`.
103    pub frontier_coords: Vec<(u64, u32)>,
104}
105
106impl Clone for AlgView {
107    fn clone(&self) -> Self {
108        Self {
109            hasher: self.hasher.clone_box(),
110            epochs: self.epochs.clone(),
111            frontier: self.frontier.clone(),
112            frontier_coords: self.frontier_coords.clone(),
113        }
114    }
115}
116
117impl AlgView {
118    /// Whether this algorithm is currently active (not frozen).
119    #[must_use]
120    pub fn is_active(&self) -> bool {
121        self.epochs.last().is_some_and(|&(_, end)| end == u64::MAX)
122    }
123
124    /// Whether leaf/subtree index `i` falls within any of this algorithm's active epochs.
125    #[must_use]
126    pub fn is_active_at(&self, i: u64) -> bool {
127        self.epochs
128            .iter()
129            .any(|&(start, end)| start <= i && i < end)
130    }
131
132    /// The tree size for this algorithm.
133    ///
134    /// Active algorithms track the global tree size.
135    /// Frozen algorithms stopped at their last deactivation point.
136    #[must_use]
137    pub fn tree_size(&self, global_size: u64) -> u64 {
138        if self.is_active() {
139            global_size
140        } else {
141            self.epochs.last().map_or(0, |&(_, end)| end)
142        }
143    }
144
145    /// The activation index of the first epoch.
146    #[must_use]
147    pub fn first_activation(&self) -> u64 {
148        self.epochs.first().map_or(0, |&(start, _)| start)
149    }
150
151    /// Whether algorithm has active content in the half-open range `[lo, hi)`.
152    ///
153    /// Definition 14b (Active range): true iff any epoch overlaps the interval.
154    #[must_use]
155    pub fn active_range(&self, lo: u64, hi: u64) -> bool {
156        self.epochs
157            .iter()
158            .any(|&(start, end)| start < hi && end > lo)
159    }
160
161    /// Whether the range `[lo, hi)` is fully contained within the active epochs.
162    #[must_use]
163    pub fn fully_active(&self, lo: u64, hi: u64) -> bool {
164        self.epochs
165            .iter()
166            .any(|&(start, end)| start <= lo && hi <= end)
167    }
168
169    /// The algorithm-local tree size corresponding to global size `size`.
170    ///
171    /// Active algorithms (or null-projection spans before first activation)
172    /// see the full global size. Frozen algorithms cap at their last
173    /// deactivation point; the maximum epoch end that is strictly less than
174    /// `size` is the algorithm's effective size.
175    #[must_use]
176    pub fn effective_size_at(&self, size: u64) -> u64 {
177        if size <= self.first_activation() {
178            // Pre-activation: null projections span [0, size); the frontier
179            // geometry uses the global size.  When size == 0 this is 0.
180            size
181        } else if self.is_active_at(size - 1) {
182            size
183        } else {
184            self.epochs
185                .iter()
186                .filter(|&&(_, end)| end < size)
187                .map(|&(_, end)| end)
188                .max()
189                .unwrap_or(0)
190        }
191    }
192
193    /// The epoch timeline as it stood at size `size`, for commitment into
194    /// the combined root and audit checkpoints (Design A+).
195    ///
196    /// Intervals beginning after `size` are dropped; an interval extending
197    /// past `size` is encoded as open (`end == u64::MAX`), matching what the
198    /// timeline looked like when the log was that size. An interval closed
199    /// exactly at `size` stays closed — this is what distinguishes
200    /// "deactivated at the tip" from "still live, log idle" (frontier
201    /// freshness). Returns `None` if the algorithm was not yet registered.
202    #[must_use]
203    pub fn epochs_at(&self, size: u64) -> Option<Vec<(u64, u64)>> {
204        let mut out = Vec::new();
205        for &(start, end) in &self.epochs {
206            if start > size {
207                break;
208            }
209            if end > size {
210                out.push((start, u64::MAX));
211                break;
212            }
213            out.push((start, end));
214        }
215        if out.is_empty() { None } else { Some(out) }
216    }
217}
218
219/// Compute the member root from an algorithm's in-memory frontier — the MMR
220/// backward-bag of its frontier peaks under its own hash.
221#[must_use]
222pub fn compute_root(view: &AlgView, k: usize) -> Vec<u8> {
223    bag_peaks(view.hasher.as_ref(), &view.frontier, k as u64)
224}
225
226/// One step of the base-k frontier carry for a single algorithm view.
227///
228/// Pushes `digest` onto the view's frontier at log position `count`, then runs
229/// the [`reduction_count`] merges the schedule mandates, appending any newly
230/// sealed internal node as `(left, height, hash)` to `out_nodes`. The view is
231/// mutated in place; the carry is pure over `(view, digest, count, arity)`.
232///
233/// # Errors
234///
235/// Returns [`Error::Corrupted`] if the frontier or coordinate stack underflows
236/// during a reduction — a structural invariant break, never expected for a
237/// well-formed view.
238pub fn carry<E>(
239    view: &mut AlgView,
240    alg_id: u64,
241    digest: Vec<u8>,
242    count: u64,
243    arity: u64,
244    out_nodes: &mut Vec<(u64, u64, u32, Vec<u8>)>,
245) -> Result<(), E> {
246    view.frontier.push(digest);
247    view.frontier_coords.push((count, 0));
248
249    let merges = reduction_count(count, arity);
250    for _ in 0..merges {
251        let mut children = Vec::with_capacity(arity as usize);
252        let mut coords = Vec::with_capacity(arity as usize);
253        for _ in 0..arity as usize {
254            children.push(view.frontier.pop().ok_or_else(|| Error::Corrupted {
255                alg_id,
256                reason: "frontier stack underflow during reduction".to_string(),
257            })?);
258            coords.push(view.frontier_coords.pop().ok_or_else(|| Error::Corrupted {
259                alg_id,
260                reason: "frontier_coords stack underflow during reduction".to_string(),
261            })?);
262        }
263        children.reverse();
264        coords.reverse();
265        let child_refs: Vec<&[u8]> = children.iter().map(|c| c.as_slice()).collect();
266        let parent = nary_mr(view.hasher.as_ref(), &child_refs);
267
268        let parent_left_index = coords[0].0;
269        let parent_height = coords[0].1 + 1;
270
271        if parent != view.hasher.null() {
272            out_nodes.push((alg_id, parent_left_index, parent_height, parent.clone()));
273        }
274
275        view.frontier.push(parent);
276        view.frontier_coords
277            .push((parent_left_index, parent_height));
278    }
279    Ok(())
280}
281
282/// Retrieve a node hash from the read substrate, or return the algorithm's null
283/// constant if the coordinate's range carries no active leaf.
284///
285/// # Errors
286///
287/// Returns [`Error::Read`] on a backend error, or [`Error::Corrupted`] if a
288/// stored node has the wrong digest width or an active range is missing its node.
289pub async fn get_node_hash<R: NodeReader>(
290    reader: &R,
291    view: &AlgView,
292    alg_id: u64,
293    left: u64,
294    height: u32,
295    arity: u64,
296) -> Result<Vec<u8>, R::Error> {
297    let cap = match arity.checked_pow(height) {
298        Some(c) => c,
299        None => return Ok(view.hasher.null()),
300    };
301    let limit = match left.checked_add(cap) {
302        Some(val) => val,
303        None => return Ok(view.hasher.null()),
304    };
305    if !view.active_range(left, limit) {
306        return Ok(view.hasher.null());
307    }
308    if let Some(hash) = reader
309        .get_node(alg_id, left, height)
310        .await
311        .map_err(Error::Read)?
312    {
313        let expected = view.hasher.null().len();
314        if hash.len() != expected {
315            return Err(Error::Corrupted {
316                alg_id,
317                reason: format!(
318                    "node at left {left} height {height} has wrong digest length: expected \
319                     {expected}, got {}",
320                    hash.len()
321                ),
322            });
323        }
324        Ok(hash)
325    } else {
326        // Any node whose range has at least one active leaf will be stored
327        // as a non-null value (null requires every active leaf to collide
328        // with the null digest — negligible probability). A missing node
329        // in any active range is therefore corruption, whether the range is
330        // fully or only partially active.
331        Err(Error::Corrupted {
332            alg_id,
333            reason: format!(
334                "missing internal node for algorithm {alg_id} at left {left} height {height}"
335            ),
336        })
337    }
338}
339
340/// Generate an inclusion proof for `index` in a tree of size `tree_size` for the
341/// algorithm `view`, reading nodes through `reader`. Returns `None` when out of
342/// range or `tree_size` exceeds the algorithm's committed size.
343///
344/// # Errors
345///
346/// Propagates [`Error`] from the node reads.
347pub async fn inclusion_proof<R: NodeReader>(
348    reader: &R,
349    view: &AlgView,
350    alg_id: u64,
351    index: u64,
352    tree_size: u64,
353    global_size: u64,
354    arity: u64,
355) -> Result<Option<crate::consistency::InclusionProof>, R::Error> {
356    let max_size = view.tree_size(global_size);
357
358    if tree_size == 0 || index >= tree_size || tree_size > max_size {
359        return Ok(None);
360    }
361
362    let k = arity;
363    let coords = frontier_for_size(tree_size, k);
364
365    let mut target_f_idx = None;
366    for (f_idx, &(left, height)) in coords.iter().enumerate() {
367        let cap = k.pow(height);
368        if index >= left && index < left + cap {
369            target_f_idx = Some((f_idx, left, height));
370            break;
371        }
372    }
373
374    let (f_idx, left, height) = match target_f_idx {
375        Some(val) => val,
376        None => return Ok(None),
377    };
378
379    let mut path = Vec::new();
380    log_level_bisection_path_to_height(
381        reader, view, alg_id, left, height, index, 0, arity, &mut path,
382    )
383    .await?;
384    path.reverse();
385
386    let mut peaks = Vec::with_capacity(coords.len());
387    for &(l, h) in &coords {
388        let hash = get_node_hash(reader, view, alg_id, l, h, arity).await?;
389        peaks.push(hash);
390    }
391
392    // bagPath: lift the leaf's mountain peak through the backward-bag to the
393    // root. peakPath (above) is the durable prefix; this is the re-derived suffix.
394    let bag = bag_path(&peaks, f_idx, view.hasher.as_ref(), arity);
395    path.extend(bag);
396
397    // The MMR commitment topology is owned by the `mountain` module; generation
398    // must emit exactly the skeleton the verifier will check against. This holds
399    // by construction — the pin guards against the producer and verifier drifting.
400    debug_assert!(
401        mountain_skeleton(k, tree_size, index).is_some_and(|skeleton| {
402            skeleton.len() == path.len()
403                && path.iter().zip(skeleton.iter()).all(|(step, shape)| {
404                    step.position == shape.position && step.siblings.len() == shape.sibling_count
405                })
406        }),
407        "generated inclusion proof must match the canonical mountain skeleton"
408    );
409
410    Ok(Some(crate::consistency::InclusionProof { path }))
411}
412
413/// Produce a self-contained [`spine::LeafProof`] for `index` in a tree of size
414/// `tree_size`. Returns `None` when no inclusion proof exists.
415///
416/// # Errors
417///
418/// Propagates [`Error`] from the node reads.
419pub async fn leaf_proof<R: NodeReader>(
420    reader: &R,
421    view: &AlgView,
422    alg_id: u64,
423    index: u64,
424    tree_size: u64,
425    global_size: u64,
426    arity: u64,
427) -> Result<Option<spine::LeafProof>, R::Error> {
428    let Some(proof) =
429        inclusion_proof(reader, view, alg_id, index, tree_size, global_size, arity).await?
430    else {
431        return Ok(None);
432    };
433    // The leaf digest is the height-0 node at the leaf's position.
434    let leaf_hash = get_node_hash(reader, view, alg_id, index, 0, arity).await?;
435    Ok(Some(spine::LeafProof::new(
436        leaf_hash, index, tree_size, arity, proof.path,
437    )))
438}
439
440/// Generate a consistency proof between `old_size` and `new_size` for the
441/// algorithm `view`. Returns `None` when the request is out of range.
442///
443/// # Errors
444///
445/// Propagates [`Error`] from the node reads.
446#[allow(clippy::too_many_arguments)]
447pub async fn consistency_proof<R: NodeReader>(
448    reader: &R,
449    view: &AlgView,
450    alg_id: u64,
451    old_size: u64,
452    new_size: u64,
453    global_size: u64,
454    arity: u64,
455) -> Result<Option<crate::consistency::ConsistencyProof>, R::Error> {
456    let max_size = view.tree_size(global_size);
457
458    if old_size == 0 || old_size >= new_size || new_size > max_size {
459        return Ok(None);
460    }
461
462    let k = arity;
463    let old_coords = frontier_for_size(old_size, k);
464    let &(boundary_left, boundary_height) = old_coords.last().ok_or_else(|| Error::Corrupted {
465        alg_id,
466        reason: "empty old_coords for non-zero old_size".to_string(),
467    })?;
468
469    let start_hash =
470        get_node_hash(reader, view, alg_id, boundary_left, boundary_height, arity).await?;
471
472    let new_coords = frontier_for_size(new_size, k);
473    let mut target_new_f_idx = None;
474    for (f_idx, &(new_left, new_height)) in new_coords.iter().enumerate() {
475        let cap = k.pow(new_height);
476        if boundary_left >= new_left && boundary_left < new_left + cap {
477            target_new_f_idx = Some((f_idx, new_left, new_height));
478            break;
479        }
480    }
481
482    let (f_idx, left, height) = match target_new_f_idx {
483        Some(val) => val,
484        None => return Ok(None),
485    };
486
487    if height < boundary_height {
488        return Ok(None);
489    }
490
491    // The boundary peak's inclusion path *within* its new mountain — from its own
492    // height up to the mountain peak. The bag from the peak to the root is not
493    // emitted: the verifier re-derives both roots by bagging `new_peaks`, and the
494    // older peaks that merged into the boundary mountain ride along as this path's
495    // left-siblings.
496    let mut peak_path = Vec::new();
497    log_level_bisection_path_to_height(
498        reader,
499        view,
500        alg_id,
501        left,
502        height,
503        boundary_left,
504        boundary_height,
505        arity,
506        &mut peak_path,
507    )
508    .await?;
509    peak_path.reverse();
510
511    let mut new_peaks = Vec::with_capacity(new_coords.len());
512    for &(l, h) in &new_coords {
513        let hash = get_node_hash(reader, view, alg_id, l, h, arity).await?;
514        new_peaks.push(hash);
515    }
516
517    // Producer/verifier lockstep: the emitted path must match the boundary
518    // mountain's slice of the canonical skeleton the verifier pins against — the
519    // climb steps at heights `[boundary_height, new_height)`.
520    debug_assert!(
521        mountain_skeleton(k, new_size, boundary_left).is_some_and(|skeleton| {
522            let (bh, nh) = (boundary_height as usize, height as usize);
523            skeleton.len() >= nh
524                && skeleton[bh..nh].len() == peak_path.len()
525                && peak_path
526                    .iter()
527                    .zip(&skeleton[bh..nh])
528                    .all(|(step, shape)| {
529                        step.position == shape.position
530                            && step.siblings.len() == shape.sibling_count
531                    })
532        }),
533        "generated consistency peak_path must match the canonical mountain skeleton"
534    );
535
536    Ok(Some(crate::consistency::ConsistencyProof {
537        boundary_hash: start_hash,
538        peak_path,
539        new_peaks,
540        split_index: f_idx,
541    }))
542}
543
544#[allow(clippy::too_many_arguments)]
545async fn log_level_bisection_path_to_height<R: NodeReader>(
546    reader: &R,
547    view: &AlgView,
548    alg_id: u64,
549    left_index: u64,
550    height: u32,
551    target_index: u64,
552    target_height: u32,
553    arity: u64,
554    path: &mut Vec<ProofStep>,
555) -> Result<(), R::Error> {
556    let mut curr_left = left_index;
557    let mut curr_height = height;
558    let k = arity;
559
560    while curr_height > target_height {
561        let child_capacity = k.pow(curr_height - 1);
562        let child_idx = (target_index - curr_left) / child_capacity;
563
564        let mut siblings = Vec::with_capacity(arity as usize - 1);
565        for j in 0..arity as usize {
566            let j_u64 = j as u64;
567            if j_u64 == child_idx {
568                continue;
569            }
570            let c_left = curr_left + j_u64 * child_capacity;
571            let hash = get_node_hash(reader, view, alg_id, c_left, curr_height - 1, arity).await?;
572            siblings.push(hash);
573        }
574
575        path.push(ProofStep {
576            siblings,
577            position: child_idx as usize,
578        });
579
580        curr_left += child_idx * child_capacity;
581        curr_height -= 1;
582    }
583    Ok(())
584}
585
586/// The materialized digest of the frontier peak at coordinate `(left, height)` —
587/// one perfect k-ary subtree root of the frontier. Returns the algorithm's null
588/// constant for a coordinate whose range carries no active leaf.
589///
590/// # Errors
591///
592/// Propagates [`Error`] from the node read.
593pub async fn peak_at<R: NodeReader>(
594    reader: &R,
595    view: &AlgView,
596    alg_id: u64,
597    left: u64,
598    height: u32,
599    arity: u64,
600) -> Result<Vec<u8>, R::Error> {
601    get_node_hash(reader, view, alg_id, left, height, arity).await
602}
603
604/// Retrieve the raw member root for the algorithm at a historical tree size.
605///
606/// # Errors
607///
608/// Propagates [`Error`] from the node reads.
609pub async fn root_for_at<R: NodeReader>(
610    reader: &R,
611    view: &AlgView,
612    alg_id: u64,
613    size: u64,
614    arity: u64,
615) -> Result<Vec<u8>, R::Error> {
616    let alg_size = view.effective_size_at(size);
617
618    if alg_size == 0 {
619        return Ok(view.hasher.empty());
620    }
621
622    let coords = frontier_for_size(alg_size, arity);
623
624    let mut frontier = Vec::with_capacity(coords.len());
625    for &(left, height) in &coords {
626        let hash = get_node_hash(reader, view, alg_id, left, height, arity).await?;
627        frontier.push(hash);
628    }
629
630    Ok(bag_peaks(view.hasher.as_ref(), &frontier, arity))
631}
632
633/// Gather an algorithm's frontier peaks at `size` — the digests of the perfect
634/// k-ary subtrees [`frontier_for_size`] names, the structural snapshot facet a
635/// [`spine::Seal`] freezes. Each peak is the algorithm's null constant for a
636/// coordinate whose range carries no active leaf.
637///
638/// # Errors
639///
640/// Propagates [`Error`] from the node reads.
641pub async fn frontier_peaks<R: NodeReader>(
642    reader: &R,
643    view: &AlgView,
644    alg_id: u64,
645    size: u64,
646    arity: u64,
647) -> Result<Vec<Vec<u8>>, R::Error> {
648    let coords = frontier_for_size(size, arity);
649    let mut peaks = Vec::with_capacity(coords.len());
650    for &(left, height) in &coords {
651        peaks.push(peak_at(reader, view, alg_id, left, height, arity).await?);
652    }
653    Ok(peaks)
654}
655
656/// Validate an algorithm's committed epoch intervals against the global size.
657///
658/// # Errors
659///
660/// Returns [`Error::Corrupted`] for an empty, non-monotonic, or out-of-range
661/// interval sequence.
662pub fn validate_epochs<E>(alg_id: u64, epochs: &[(u64, u64)], global_size: u64) -> Result<(), E> {
663    if epochs.is_empty() {
664        return Err(Error::Corrupted {
665            alg_id,
666            reason: "epoch sequence is empty".to_string(),
667        });
668    }
669    let mut last_end = 0;
670    for (i, &(start, end)) in epochs.iter().enumerate() {
671        if start > end {
672            return Err(Error::Corrupted {
673                alg_id,
674                reason: format!("epoch start {start} exceeds end {end}"),
675            });
676        }
677        if start < last_end {
678            return Err(Error::Corrupted {
679                alg_id,
680                reason: format!("epoch start {start} is less than prior end {last_end}"),
681            });
682        }
683        if end != u64::MAX && end > global_size {
684            return Err(Error::Corrupted {
685                alg_id,
686                reason: format!("epoch end {end} exceeds global size {global_size}"),
687            });
688        }
689        if end == u64::MAX && i != epochs.len() - 1 {
690            return Err(Error::Corrupted {
691                alg_id,
692                reason: "open epoch (end = u64::MAX) is not the final entry".to_string(),
693            });
694        }
695        last_end = end;
696    }
697    if let Some(&(start, end)) = epochs.last() {
698        if end == u64::MAX && start > global_size {
699            return Err(Error::Corrupted {
700                alg_id,
701                reason: format!("active epoch start {start} exceeds global size {global_size}"),
702            });
703        }
704    }
705    Ok(())
706}
707
708/// Reconstruct an algorithm's frontier view from the read substrate at
709/// `global_size`. The hasher and committed `epochs` are supplied by the
710/// combinator (which owns the timeline); the frontier peaks are read back.
711///
712/// # Errors
713///
714/// Propagates [`Error`] from the node reads, or [`Error::Corrupted`] for an
715/// ill-formed epoch sequence or a missing frontier node.
716pub async fn reconstruct_view<R: NodeReader>(
717    reader: &R,
718    alg_id: u64,
719    hasher: Box<dyn Hasher>,
720    epochs: &[(u64, u64)],
721    global_size: u64,
722    arity: u64,
723) -> Result<AlgView, R::Error> {
724    validate_epochs(alg_id, epochs, global_size)?;
725
726    let is_active = epochs.last().is_some_and(|&(_, end)| end == u64::MAX);
727    let tree_size = if is_active {
728        global_size
729    } else {
730        epochs.last().map_or(0, |&(_, end)| end)
731    };
732
733    let mut view = AlgView {
734        hasher,
735        epochs: epochs.to_vec(),
736        frontier: Vec::new(),
737        frontier_coords: Vec::new(),
738    };
739
740    if tree_size == 0 {
741        return Ok(view);
742    }
743
744    let coords = frontier_for_size(tree_size, arity);
745    let mut frontier = Vec::with_capacity(coords.len());
746    for &(left, height) in &coords {
747        let cap = arity.pow(height);
748        let hash = if !view.active_range(left, left + cap) {
749            view.hasher.null()
750        } else {
751            reader
752                .get_node(alg_id, left, height)
753                .await
754                .map_err(Error::Read)?
755                .ok_or_else(|| Error::Corrupted {
756                    alg_id,
757                    reason: format!(
758                        "missing frontier node for algorithm {alg_id} at left {left} height \
759                         {height}"
760                    ),
761                })?
762        };
763        frontier.push(hash);
764    }
765
766    view.frontier = frontier;
767    view.frontier_coords = coords;
768
769    Ok(view)
770}
771
772/// Recursively resolve a subtree root over the read substrate and collect the
773/// mixed boundary nodes that must be persisted (used when reopening a frozen
774/// algorithm's frontier). Returns `(root, mixed_nodes)` where `mixed_nodes` are
775/// `(left, height, hash)` triples.
776///
777/// # Errors
778///
779/// Propagates [`Error`] from the node/leaf reads.
780#[allow(clippy::type_complexity)]
781#[allow(clippy::too_many_arguments)]
782pub fn reconstruct_subtree_root<'a, R>(
783    reader: &'a R,
784    alg_id: u64,
785    view: &'a AlgView,
786    lo: u64,
787    hi: u64,
788    k: u64,
789    kind: LogKind,
790    store_mixed: bool,
791) -> std::pin::Pin<
792    Box<
793        dyn std::future::Future<Output = Result<(Vec<u8>, Vec<(u64, u32, Vec<u8>)>), R::Error>>
794            + Send
795            + 'a,
796    >,
797>
798where
799    R: NodeReader + Sync + 'a,
800{
801    Box::pin(async move {
802        let size = hi - lo;
803        if size == 0 {
804            return Ok((view.hasher.empty(), Vec::new()));
805        }
806        if size == 1 {
807            if view.is_active_at(lo) {
808                if kind == LogKind::Subtree {
809                    if let Some(hash) = reader.get_node(alg_id, lo, 0).await.map_err(Error::Read)? {
810                        let expected = view.hasher.null().len();
811                        if hash.len() != expected {
812                            return Err(Error::Corrupted {
813                                alg_id,
814                                reason: format!(
815                                    "subtree node at left {lo} height 0 has wrong digest length: \
816                                     expected {expected}, got {}",
817                                    hash.len()
818                                ),
819                            });
820                        }
821                        return Ok((hash, Vec::new()));
822                    } else {
823                        return Ok((view.hasher.null(), Vec::new()));
824                    }
825                } else {
826                    let data = reader.get_leaf(lo).await.map_err(Error::Read)?;
827                    return Ok((view.hasher.leaf(&data), Vec::new()));
828                }
829            }
830            return Ok((view.hasher.null(), Vec::new()));
831        }
832
833        if !view.active_range(lo, hi) {
834            return Ok((view.hasher.null(), Vec::new()));
835        }
836
837        let is_power_of_k = {
838            let mut temp = size;
839            while temp % k == 0 {
840                temp /= k;
841            }
842            temp == 1
843        };
844
845        if is_power_of_k {
846            let height = {
847                let mut h = 0;
848                let mut temp = size;
849                while temp > 1 {
850                    temp /= k;
851                    h += 1;
852                }
853                h as u32
854            };
855            if view.fully_active(lo, hi) {
856                if let Some(hash) = reader
857                    .get_node(alg_id, lo, height)
858                    .await
859                    .map_err(Error::Read)?
860                {
861                    let expected = view.hasher.null().len();
862                    if hash.len() != expected {
863                        return Err(Error::Corrupted {
864                            alg_id,
865                            reason: format!(
866                                "node at left {lo} height {height} has wrong digest length: \
867                                 expected {expected}, got {}",
868                                hash.len()
869                            ),
870                        });
871                    }
872                    return Ok((hash, Vec::new()));
873                }
874            }
875
876            let child_size = size / k;
877            let mut child_hashes = Vec::with_capacity(k as usize);
878            let mut mixed_nodes = Vec::new();
879            for j in 0..k {
880                let c_lo = lo + j * child_size;
881                let c_hi = lo + (j + 1) * child_size;
882                let (child_hash, child_mixed) = reconstruct_subtree_root(
883                    reader,
884                    alg_id,
885                    view,
886                    c_lo,
887                    c_hi,
888                    k,
889                    kind,
890                    store_mixed,
891                )
892                .await?;
893                child_hashes.push(child_hash);
894                mixed_nodes.extend(child_mixed);
895            }
896            let child_refs: Vec<&[u8]> = child_hashes.iter().map(|c| c.as_slice()).collect();
897            let hash = nary_mr(view.hasher.as_ref(), &child_refs);
898
899            if store_mixed {
900                mixed_nodes.push((lo, height, hash.clone()));
901            }
902            Ok((hash, mixed_nodes))
903        } else {
904            let coords = frontier_for_size(size, k);
905            let mut component_hashes = Vec::with_capacity(coords.len());
906            let mut mixed_nodes = Vec::new();
907            for &(part_left, part_height) in &coords {
908                let cap = k.pow(part_height);
909                let c_lo = lo + part_left;
910                let c_hi = c_lo + cap;
911                let (part_root, part_mixed) = reconstruct_subtree_root(
912                    reader,
913                    alg_id,
914                    view,
915                    c_lo,
916                    c_hi,
917                    k,
918                    kind,
919                    store_mixed,
920                )
921                .await?;
922                component_hashes.push(part_root);
923                mixed_nodes.extend(part_mixed);
924            }
925
926            // A non-perfect range's root is the backward-bag of its perfect
927            // frontier components — the same MMR commitment cml uses everywhere,
928            // so a reconstructed subtree root agrees with a from-scratch build.
929            let root = bag_peaks(view.hasher.as_ref(), &component_hashes, k);
930            Ok((root, mixed_nodes))
931        }
932    })
933}
934
935#[cfg(test)]
936mod tests {
937    use sha2::Digest as _;
938
939    use super::*;
940
941    /// A real fixed-width (32-byte) hasher. These tests exercise the view's
942    /// epoch geometry, independent of digest content.
943    #[derive(Debug)]
944    struct TestHasher;
945    impl Hasher for TestHasher {
946        fn leaf(&self, data: &[u8]) -> Vec<u8> {
947            sha2::Sha256::digest(data).to_vec()
948        }
949
950        fn node(&self, children: &[&[u8]]) -> Vec<u8> {
951            let mut h = sha2::Sha256::new();
952            for c in children {
953                h.update(c);
954            }
955            h.finalize().to_vec()
956        }
957
958        fn empty(&self) -> Vec<u8> {
959            sha2::Sha256::digest(b"").to_vec()
960        }
961
962        fn hash(&self, data: &[u8]) -> Vec<u8> {
963            sha2::Sha256::digest(data).to_vec()
964        }
965
966        fn clone_box(&self) -> Box<dyn Hasher> {
967            Box::new(TestHasher)
968        }
969    }
970
971    // The committed timeline an `AlgView` projects at a historical size: intervals
972    // past the size drop, an interval spanning the size encodes open, and an
973    // interval closed exactly at the size stays closed (frontier-freshness).
974    #[test]
975    fn epochs_at_snapshot_clamping() {
976        let view = AlgView {
977            hasher: Box::new(TestHasher),
978            epochs: vec![(2, 5), (7, 12), (15, u64::MAX)],
979            frontier: Vec::new(),
980            frontier_coords: Vec::new(),
981        };
982        // Not yet registered.
983        assert_eq!(view.epochs_at(1), None);
984        // Mid-interval: encoded open, later intervals dropped.
985        assert_eq!(view.epochs_at(3), Some(vec![(2, u64::MAX)]));
986        // Exactly at a deactivation boundary: stays closed.
987        assert_eq!(view.epochs_at(5), Some(vec![(2, 5)]));
988        // Between intervals.
989        assert_eq!(view.epochs_at(6), Some(vec![(2, 5)]));
990        // Activation exactly at the snapshot: registered, open, no content.
991        assert_eq!(view.epochs_at(7), Some(vec![(2, 5), (7, u64::MAX)]));
992        // Past all closed intervals, inside the open one.
993        assert_eq!(
994            view.epochs_at(20),
995            Some(vec![(2, 5), (7, 12), (15, u64::MAX)])
996        );
997    }
998}