Skip to main content

ftui_layout/
pane_persistent.rs

1//! Prototype: persistent / versioned `PaneTree` with structural sharing.
2//!
3//! This module is the spike deliverable for bead `bd-1k7ek.5`. It answers a
4//! single strategic question: does a persistent (structurally shared) pane tree
5//! buy enough asymptotic value — specifically **replay-free `O(1)` undo/redo** —
6//! to justify its complexity and memory footprint versus the checkpointed
7//! [`PaneInteractionTimeline`](crate::pane::PaneInteractionTimeline) replay path?
8//!
9//! # Design
10//!
11//! The canonical [`PaneTree`](crate::pane::PaneTree) stores nodes in a flat
12//! `BTreeMap<PaneId, PaneNodeRecord>` with explicit parent pointers. That shape
13//! is excellent for `O(1)` id lookup but is *not* naturally persistent: cloning
14//! the whole map to snapshot a version is `O(nodes)`, and the timeline therefore
15//! recovers historical states by **replaying** operations forward from the
16//! nearest checkpoint.
17//!
18//! [`VersionedPaneTree`] instead represents the tree as an immutable tree of
19//! `Arc<PersistentNode>`. A structural change produces a **new root** by
20//! *path-copying*: only the nodes on the root→target path are re-allocated;
21//! every off-path subtree is reused by an `Arc` clone (a refcount bump). A
22//! [`PaneVersionStore`] keeps a `Vec` of version roots plus a cursor, so undo
23//! and redo are pure index moves — no clone, no validation, no replay.
24//!
25//! Parent links are intentionally **absent** from [`PersistentNode`]. A node
26//! that knew its parent could not be shared between two versions (or two
27//! positions), so parents are reconstructed during [`VersionedPaneTree::to_pane_tree`].
28//! This is the property that makes structural sharing possible at all.
29//!
30//! # Scope (prototype simplifications)
31//!
32//! - Native path-copying covers [`SetSplitRatio`](crate::pane::PaneOperation::SetSplitRatio),
33//!   [`SplitLeaf`](crate::pane::PaneOperation::SplitLeaf),
34//!   [`CloseNode`](crate::pane::PaneOperation::CloseNode), and
35//!   [`SwapNodes`](crate::pane::PaneOperation::SwapNodes) — i.e. the resize,
36//!   create, destroy, and rearrange shapes. These exercise in-place mutation,
37//!   id-allocating insertion, sibling-promoting removal, and two-path rebuild.
38//! - [`MoveSubtree`](crate::pane::PaneOperation::MoveSubtree) and
39//!   [`NormalizeRatios`](crate::pane::PaneOperation::NormalizeRatios) use a
40//!   *rebuild fallback*: flatten to a canonical [`PaneTree`](crate::pane::PaneTree),
41//!   apply the certified conservative operation, and rebuild the persistent
42//!   tree. This guarantees total semantic parity for the differential oracle at
43//!   the cost of zero sharing for those (rare) operations.
44//! - The path-copy search is `O(nodes)` (no id→path index). A production
45//!   version would maintain a positional index for `O(depth)` location. The
46//!   headline win (`O(1)` version navigation) does not depend on this.
47//!
48//! Every native operation is proven byte-identical to
49//! [`PaneTree::apply_operation_conservative`](crate::pane::PaneTree::apply_operation_conservative)
50//! over lockstep histories in `tests/pane_persistent_equivalence.rs`.
51
52use std::collections::{BTreeMap, HashSet};
53use std::sync::Arc;
54
55use crate::pane::{
56    PANE_TREE_SCHEMA_VERSION, PaneConstraints, PaneId, PaneLeaf, PaneModelError, PaneNodeKind,
57    PaneNodeRecord, PaneOperation, PaneOperationKind, PanePlacement, PaneSplit, PaneSplitRatio,
58    PaneTree, PaneTreeSnapshot, SplitAxis,
59};
60
61/// Immutable persistent pane node.
62///
63/// Split children are `Arc`-shared so that unchanged subtrees are reused across
64/// versions. Parent links are deliberately omitted (see the module docs); they
65/// are reconstructed during flattening.
66#[derive(Debug, Clone, PartialEq, Eq)]
67pub enum PersistentNode {
68    /// Terminal content node.
69    Leaf {
70        /// Stable node id (matches the canonical tree's id space).
71        id: PaneId,
72        /// Per-node size bounds.
73        constraints: PaneConstraints,
74        /// Forward-compatible record-level extension bag.
75        node_extensions: BTreeMap<String, String>,
76        /// Leaf payload (surface key + leaf-level extensions).
77        leaf: PaneLeaf,
78    },
79    /// Interior split node with two shared children.
80    Split {
81        /// Stable node id.
82        id: PaneId,
83        /// Per-node size bounds.
84        constraints: PaneConstraints,
85        /// Forward-compatible record-level extension bag.
86        node_extensions: BTreeMap<String, String>,
87        /// Split orientation.
88        axis: SplitAxis,
89        /// First/second weight ratio (always reduced).
90        ratio: PaneSplitRatio,
91        /// First child subtree.
92        first: Arc<PersistentNode>,
93        /// Second child subtree.
94        second: Arc<PersistentNode>,
95    },
96}
97
98impl PersistentNode {
99    /// Node id of this node.
100    #[must_use]
101    pub const fn id(&self) -> PaneId {
102        match self {
103            Self::Leaf { id, .. } | Self::Split { id, .. } => *id,
104        }
105    }
106
107    /// Whether this node is a leaf.
108    #[must_use]
109    pub const fn is_leaf(&self) -> bool {
110        matches!(self, Self::Leaf { .. })
111    }
112
113    /// Total number of nodes in the subtree rooted at `self`.
114    #[must_use]
115    pub fn node_count(&self) -> usize {
116        match self {
117            Self::Leaf { .. } => 1,
118            Self::Split { first, second, .. } => 1 + first.node_count() + second.node_count(),
119        }
120    }
121
122    /// Maximum depth (root counts as depth `1`).
123    #[must_use]
124    pub fn depth(&self) -> usize {
125        match self {
126            Self::Leaf { .. } => 1,
127            Self::Split { first, second, .. } => 1 + first.depth().max(second.depth()),
128        }
129    }
130}
131
132/// One immutable version of a pane tree.
133///
134/// Cloning a version is cheap: it bumps the root `Arc` refcount and clones the
135/// (usually empty) tree-level extension map.
136#[derive(Debug, Clone)]
137pub struct VersionedPaneTree {
138    schema_version: u16,
139    root: Arc<PersistentNode>,
140    next_id: PaneId,
141    extensions: BTreeMap<String, String>,
142}
143
144impl VersionedPaneTree {
145    /// Build a singleton versioned tree holding one root leaf.
146    #[must_use]
147    pub fn singleton(surface_key: impl Into<String>) -> Self {
148        let root = PaneId::MIN;
149        let node = Arc::new(PersistentNode::Leaf {
150            id: root,
151            constraints: PaneConstraints::default(),
152            node_extensions: BTreeMap::new(),
153            leaf: PaneLeaf::new(surface_key),
154        });
155        Self {
156            schema_version: PANE_TREE_SCHEMA_VERSION,
157            root: node,
158            next_id: root.checked_next().unwrap_or(root),
159            extensions: BTreeMap::new(),
160        }
161    }
162
163    /// Build a versioned tree from a canonical [`PaneTree`].
164    #[must_use]
165    pub fn from_pane_tree(tree: &PaneTree) -> Self {
166        Self::from_snapshot(&tree.to_snapshot())
167    }
168
169    /// Build a versioned tree from a canonical snapshot.
170    #[must_use]
171    pub fn from_snapshot(snapshot: &PaneTreeSnapshot) -> Self {
172        let mut records: BTreeMap<PaneId, &PaneNodeRecord> = BTreeMap::new();
173        for record in &snapshot.nodes {
174            let _ = records.insert(record.id, record);
175        }
176        let root = build_persistent(&records, snapshot.root);
177        Self {
178            schema_version: snapshot.schema_version,
179            root,
180            next_id: snapshot.next_id,
181            extensions: snapshot.extensions.clone(),
182        }
183    }
184
185    /// Root node of this version.
186    #[must_use]
187    pub fn root(&self) -> &Arc<PersistentNode> {
188        &self.root
189    }
190
191    /// Root pane id.
192    #[must_use]
193    pub fn root_id(&self) -> PaneId {
194        self.root.id()
195    }
196
197    /// Next deterministic id value.
198    #[must_use]
199    pub const fn next_id(&self) -> PaneId {
200        self.next_id
201    }
202
203    /// Schema version.
204    #[must_use]
205    pub const fn schema_version(&self) -> u16 {
206        self.schema_version
207    }
208
209    /// Number of nodes in this version.
210    #[must_use]
211    pub fn node_count(&self) -> usize {
212        self.root.node_count()
213    }
214
215    /// Maximum tree depth in this version.
216    #[must_use]
217    pub fn depth(&self) -> usize {
218        self.root.depth()
219    }
220
221    /// Flatten to a canonical snapshot (reconstructing parent links).
222    #[must_use]
223    pub fn to_snapshot(&self) -> PaneTreeSnapshot {
224        let mut nodes = Vec::with_capacity(self.node_count());
225        flatten_persistent(&self.root, None, &mut nodes);
226        let mut snapshot = PaneTreeSnapshot {
227            schema_version: self.schema_version,
228            root: self.root.id(),
229            next_id: self.next_id,
230            nodes,
231            extensions: self.extensions.clone(),
232        };
233        snapshot.canonicalize();
234        snapshot
235    }
236
237    /// Flatten and validate into a canonical [`PaneTree`].
238    ///
239    /// This is the bridge back to the production data structure and the basis of
240    /// the differential equivalence oracle.
241    ///
242    /// # Errors
243    ///
244    /// Returns the canonical tree's validation error if the flattened structure
245    /// violates a pane-tree invariant (which should never happen for a tree
246    /// built solely through [`VersionedPaneTree::apply_operation`]).
247    pub fn to_pane_tree(&self) -> Result<PaneTree, PaneModelError> {
248        PaneTree::from_snapshot(self.to_snapshot())
249    }
250
251    /// Deterministic structural hash, identical to the canonical
252    /// [`PaneTree::state_hash`](crate::pane::PaneTree::state_hash).
253    ///
254    /// # Errors
255    ///
256    /// Returns a validation error if the flattened tree is malformed.
257    pub fn state_hash(&self) -> Result<u64, PaneModelError> {
258        Ok(self.to_pane_tree()?.state_hash())
259    }
260
261    /// Apply one structural operation, returning a **new** version.
262    ///
263    /// `self` is never mutated (persistence). Native operations path-copy with
264    /// structural sharing; [`PersistentApplyStrategy::Rebuild`] operations
265    /// delegate to the certified conservative baseline.
266    ///
267    /// # Errors
268    ///
269    /// Returns a [`PersistentApplyError`] mirroring the canonical baseline's
270    /// accept/reject decision for the operation.
271    pub fn apply_operation(&self, operation: &PaneOperation) -> Result<Self, PersistentApplyError> {
272        match operation {
273            PaneOperation::SetSplitRatio { split, ratio } => {
274                self.apply_set_split_ratio(*split, *ratio)
275            }
276            PaneOperation::SplitLeaf {
277                target,
278                axis,
279                ratio,
280                placement,
281                new_leaf,
282            } => self.apply_split_leaf(*target, *axis, *ratio, *placement, new_leaf),
283            PaneOperation::CloseNode { target } => self.apply_close_node(*target),
284            PaneOperation::SwapNodes { first, second } => self.apply_swap_nodes(*first, *second),
285            PaneOperation::MoveSubtree { .. } | PaneOperation::NormalizeRatios => {
286                self.apply_via_rebuild(operation)
287            }
288        }
289    }
290
291    /// Strategy this prototype uses for an operation kind.
292    #[must_use]
293    pub const fn operation_strategy(kind: PaneOperationKind) -> PersistentApplyStrategy {
294        match kind {
295            PaneOperationKind::SetSplitRatio
296            | PaneOperationKind::SplitLeaf
297            | PaneOperationKind::CloseNode
298            | PaneOperationKind::SwapNodes => PersistentApplyStrategy::PathCopy,
299            PaneOperationKind::MoveSubtree | PaneOperationKind::NormalizeRatios => {
300                PersistentApplyStrategy::Rebuild
301            }
302        }
303    }
304
305    fn with_root(&self, root: Arc<PersistentNode>, next_id: PaneId) -> Self {
306        Self {
307            schema_version: self.schema_version,
308            root,
309            next_id,
310            extensions: self.extensions.clone(),
311        }
312    }
313
314    fn apply_set_split_ratio(
315        &self,
316        split: PaneId,
317        ratio: PaneSplitRatio,
318    ) -> Result<Self, PersistentApplyError> {
319        // `ratio` is already reduced: `PaneSplitRatio` has no non-normalizing
320        // constructor, so re-normalizing (as the baseline does) is a no-op.
321        match rebuild_set_ratio(&self.root, split, ratio)? {
322            Some(new_root) => Ok(self.with_root(new_root, self.next_id)),
323            None => Err(PersistentApplyError::MissingNode { node_id: split }),
324        }
325    }
326
327    fn apply_split_leaf(
328        &self,
329        target: PaneId,
330        axis: SplitAxis,
331        ratio: PaneSplitRatio,
332        placement: PanePlacement,
333        new_leaf: &PaneLeaf,
334    ) -> Result<Self, PersistentApplyError> {
335        let mut next_id = self.next_id;
336        match rebuild_split_leaf(
337            &self.root,
338            target,
339            axis,
340            ratio,
341            placement,
342            new_leaf,
343            &mut next_id,
344        )? {
345            Some(new_root) => Ok(self.with_root(new_root, next_id)),
346            None => Err(PersistentApplyError::MissingNode { node_id: target }),
347        }
348    }
349
350    fn apply_close_node(&self, target: PaneId) -> Result<Self, PersistentApplyError> {
351        if target == self.root.id() {
352            return Err(PersistentApplyError::CannotCloseRoot { node_id: target });
353        }
354        match rebuild_close_node(&self.root, target) {
355            Some(new_root) => Ok(self.with_root(new_root, self.next_id)),
356            None => Err(PersistentApplyError::MissingNode { node_id: target }),
357        }
358    }
359
360    fn apply_swap_nodes(
361        &self,
362        first: PaneId,
363        second: PaneId,
364    ) -> Result<Self, PersistentApplyError> {
365        if first == second {
366            // Baseline short-circuits to a no-op before existence checks.
367            return Ok(self.clone());
368        }
369        let Some(first_subtree) = find_subtree(&self.root, first) else {
370            return Err(PersistentApplyError::MissingNode { node_id: first });
371        };
372        let Some(second_subtree) = find_subtree(&self.root, second) else {
373            return Err(PersistentApplyError::MissingNode { node_id: second });
374        };
375        if subtree_contains(&first_subtree, second) {
376            return Err(PersistentApplyError::AncestorConflict {
377                ancestor: first,
378                descendant: second,
379            });
380        }
381        if subtree_contains(&second_subtree, first) {
382            return Err(PersistentApplyError::AncestorConflict {
383                ancestor: second,
384                descendant: first,
385            });
386        }
387        let new_root = replace_two(&self.root, first, &second_subtree, second, &first_subtree);
388        Ok(self.with_root(new_root, self.next_id))
389    }
390
391    fn apply_via_rebuild(&self, operation: &PaneOperation) -> Result<Self, PersistentApplyError> {
392        let mut tree = self
393            .to_pane_tree()
394            .map_err(PersistentApplyError::InvalidTree)?;
395        tree.apply_operation_conservative(0, operation.clone())
396            .map_err(|err| PersistentApplyError::Rebuild(format!("{:?}", err.reason)))?;
397        Ok(Self::from_pane_tree(&tree))
398    }
399}
400
401/// Which application strategy the prototype uses for a given operation.
402#[derive(Debug, Clone, Copy, PartialEq, Eq)]
403pub enum PersistentApplyStrategy {
404    /// Native structural-sharing path copy (`O(depth)` new nodes).
405    PathCopy,
406    /// Flatten → conservative baseline → rebuild (no sharing).
407    Rebuild,
408}
409
410/// Failure from [`VersionedPaneTree::apply_operation`].
411///
412/// The variants mirror the canonical baseline's reject conditions closely
413/// enough that the differential oracle only needs accept/reject agreement plus
414/// state-hash equality on the accept path.
415#[derive(Debug, Clone, PartialEq, Eq)]
416pub enum PersistentApplyError {
417    /// Referenced node id is absent from the tree.
418    MissingNode {
419        /// The missing node id.
420        node_id: PaneId,
421    },
422    /// Operation targeted a non-split node where a split was required.
423    NotSplit {
424        /// The offending node id.
425        node_id: PaneId,
426    },
427    /// Operation targeted a non-leaf node where a leaf was required.
428    NotLeaf {
429        /// The offending node id.
430        node_id: PaneId,
431    },
432    /// Attempted to close the root node.
433    CannotCloseRoot {
434        /// The root id.
435        node_id: PaneId,
436    },
437    /// One node is an ancestor of the other (illegal for swap).
438    AncestorConflict {
439        /// The ancestor node.
440        ancestor: PaneId,
441        /// The descendant node.
442        descendant: PaneId,
443    },
444    /// Ran out of the pane id space.
445    IdOverflow {
446        /// The id at which allocation failed.
447        current: PaneId,
448    },
449    /// The flattened tree failed canonical validation (should be unreachable).
450    InvalidTree(PaneModelError),
451    /// The rebuild-fallback baseline rejected the operation.
452    Rebuild(String),
453}
454
455/// A history of pane-tree versions with replay-free undo/redo.
456///
457/// `undo`/`redo`/`current` are `O(1)` — they move or read a cursor over a `Vec`
458/// of immutable version roots. Applying a new operation truncates any redo
459/// branch (matching the canonical timeline) and appends a new version.
460#[derive(Debug, Clone)]
461pub struct PaneVersionStore {
462    versions: Vec<VersionedPaneTree>,
463    cursor: usize,
464    max_versions: usize,
465    pruned: usize,
466}
467
468impl PaneVersionStore {
469    /// Create a store seeded with an initial version (the baseline).
470    #[must_use]
471    pub fn new(initial: VersionedPaneTree) -> Self {
472        Self {
473            versions: vec![initial],
474            cursor: 0,
475            max_versions: 0,
476            pruned: 0,
477        }
478    }
479
480    /// Create a store with a bounded retention window.
481    ///
482    /// When the retained version count exceeds `max_versions`, the oldest
483    /// versions are pruned (which discards the ability to undo into them, the
484    /// same posture as the canonical timeline's entry limit). `0` is unbounded.
485    #[must_use]
486    pub fn with_max_versions(initial: VersionedPaneTree, max_versions: usize) -> Self {
487        let mut store = Self::new(initial);
488        store.max_versions = max_versions;
489        store
490    }
491
492    /// Install a retention bound and immediately enforce it, pruning the oldest
493    /// versions if the store currently exceeds `max_versions`.
494    ///
495    /// Unlike [`with_max_versions`](Self::with_max_versions) this acts on an
496    /// existing store, so a retention policy can re-bound it retroactively. The
497    /// newest versions (including the current one) are always kept, so the
498    /// current state — and therefore [`current`](Self::current)'s state hash —
499    /// is never discarded. Returns the number of versions pruned by this call.
500    /// `0` is unbounded.
501    pub fn set_max_versions(&mut self, max_versions: usize) -> usize {
502        let pruned_before = self.pruned;
503        self.max_versions = max_versions;
504        self.enforce_retention();
505        self.pruned.saturating_sub(pruned_before)
506    }
507
508    /// Apply an operation to the current version, appending a new version.
509    ///
510    /// # Errors
511    ///
512    /// Propagates the [`PersistentApplyError`] from the underlying apply.
513    pub fn apply(
514        &mut self,
515        operation: &PaneOperation,
516    ) -> Result<&VersionedPaneTree, PersistentApplyError> {
517        let next = self.current().apply_operation(operation)?;
518        // Drop any redo branch, then append.
519        self.versions.truncate(self.cursor + 1);
520        self.versions.push(next);
521        self.cursor = self.versions.len() - 1;
522        self.enforce_retention();
523        Ok(self.current())
524    }
525
526    /// Move to the previous version. Returns `false` at the oldest retained version.
527    pub fn undo(&mut self) -> bool {
528        if self.cursor == 0 {
529            return false;
530        }
531        self.cursor -= 1;
532        true
533    }
534
535    /// Move to the next version. Returns `false` at the newest version.
536    pub fn redo(&mut self) -> bool {
537        if self.cursor + 1 >= self.versions.len() {
538            return false;
539        }
540        self.cursor += 1;
541        true
542    }
543
544    /// The current version.
545    #[must_use]
546    pub fn current(&self) -> &VersionedPaneTree {
547        &self.versions[self.cursor]
548    }
549
550    /// Number of retained versions.
551    #[must_use]
552    pub fn version_count(&self) -> usize {
553        self.versions.len()
554    }
555
556    /// Current cursor index.
557    #[must_use]
558    pub const fn cursor(&self) -> usize {
559        self.cursor
560    }
561
562    /// Whether [`undo`](Self::undo) would succeed.
563    #[must_use]
564    pub const fn can_undo(&self) -> bool {
565        self.cursor > 0
566    }
567
568    /// Whether [`redo`](Self::redo) would succeed.
569    #[must_use]
570    pub fn can_redo(&self) -> bool {
571        self.cursor + 1 < self.versions.len()
572    }
573
574    /// Number of versions pruned by the retention bound so far.
575    #[must_use]
576    pub const fn pruned(&self) -> usize {
577        self.pruned
578    }
579
580    /// Compute a structural-sharing and memory-retention report.
581    #[must_use]
582    pub fn report(&self) -> PaneVersioningReport {
583        let mut distinct: HashSet<usize> = HashSet::new();
584        collect_distinct(
585            self.versions.iter().map(VersionedPaneTree::root),
586            &mut distinct,
587        );
588        let total_logical_nodes: usize = self
589            .versions
590            .iter()
591            .map(VersionedPaneTree::node_count)
592            .sum();
593        let distinct_nodes = distinct.len();
594        let shared_nodes = total_logical_nodes.saturating_sub(distinct_nodes);
595        let sharing_ratio = if total_logical_nodes == 0 {
596            0.0
597        } else {
598            shared_nodes as f64 / total_logical_nodes as f64
599        };
600        let current = self.current();
601        PaneVersioningReport {
602            version_count: self.versions.len(),
603            cursor: self.cursor,
604            distinct_nodes,
605            total_logical_nodes,
606            shared_nodes,
607            sharing_ratio,
608            current_node_count: current.node_count(),
609            current_depth: current.depth(),
610            pruned_versions: self.pruned,
611        }
612    }
613
614    /// Compute a deterministic retained-memory byte model over the distinct
615    /// nodes physically held by all retained versions.
616    ///
617    /// The model mirrors the canonical timeline's
618    /// [`retention_diagnostics`](crate::pane::PaneInteractionTimeline::retention_diagnostics)
619    /// methodology — `size_of` struct estimates plus measured string payload
620    /// bytes — so the two strategies are directly comparable. Because shared
621    /// subtrees are counted once (via `Arc` pointer identity), node bytes scale
622    /// with *distinct* nodes, not the logical node total.
623    #[must_use]
624    pub fn retention(&self) -> PaneVersionRetention {
625        // Per-distinct-node byte accounting in a single traversal.
626        let mut seen: HashSet<usize> = HashSet::new();
627        let mut distinct_node_count = 0usize;
628        let mut distinct_leaf_payload_bytes = 0usize;
629        let mut distinct_extension_payload_bytes = 0usize;
630        let mut stack: Vec<&Arc<PersistentNode>> =
631            self.versions.iter().map(VersionedPaneTree::root).collect();
632        while let Some(node) = stack.pop() {
633            if !seen.insert(Arc::as_ptr(node).addr()) {
634                continue;
635            }
636            distinct_node_count += 1;
637            match &**node {
638                PersistentNode::Leaf {
639                    node_extensions,
640                    leaf,
641                    ..
642                } => {
643                    distinct_leaf_payload_bytes += leaf.surface_key.len();
644                    distinct_extension_payload_bytes += string_map_payload_bytes(&leaf.extensions)
645                        .saturating_add(string_map_payload_bytes(node_extensions));
646                }
647                PersistentNode::Split {
648                    node_extensions,
649                    first,
650                    second,
651                    ..
652                } => {
653                    distinct_extension_payload_bytes += string_map_payload_bytes(node_extensions);
654                    stack.push(first);
655                    stack.push(second);
656                }
657            }
658        }
659
660        let total_logical_node_count: usize = self
661            .versions
662            .iter()
663            .map(VersionedPaneTree::node_count)
664            .sum();
665        let arc_node_bytes = std::mem::size_of::<PersistentNode>().saturating_add(ARC_HEADER_BYTES);
666        let distinct_struct_bytes = distinct_node_count.saturating_mul(arc_node_bytes);
667        let version_metadata_bytes = self
668            .versions
669            .len()
670            .saturating_mul(std::mem::size_of::<VersionedPaneTree>());
671        let estimated_total_retained_bytes = std::mem::size_of::<Self>()
672            .saturating_add(distinct_struct_bytes)
673            .saturating_add(distinct_leaf_payload_bytes)
674            .saturating_add(distinct_extension_payload_bytes)
675            .saturating_add(version_metadata_bytes);
676        let shared_nodes = total_logical_node_count.saturating_sub(distinct_node_count);
677        let sharing_ratio = if total_logical_node_count == 0 {
678            0.0
679        } else {
680            shared_nodes as f64 / total_logical_node_count as f64
681        };
682
683        PaneVersionRetention {
684            version_count: self.versions.len(),
685            distinct_node_count,
686            total_logical_node_count,
687            sharing_ratio,
688            distinct_struct_bytes,
689            distinct_leaf_payload_bytes,
690            distinct_extension_payload_bytes,
691            version_metadata_bytes,
692            estimated_total_retained_bytes,
693        }
694    }
695
696    fn enforce_retention(&mut self) {
697        if self.max_versions == 0 || self.versions.len() <= self.max_versions {
698            return;
699        }
700        // Never prune at or after the cursor: the version `current()` points
701        // at (and everything the user can still redo into) must survive, even
702        // when the user has undone deep into history. Any excess beyond
703        // `max_versions` is tolerated until the cursor advances again.
704        let drop_count = (self.versions.len() - self.max_versions).min(self.cursor);
705        if drop_count == 0 {
706            return;
707        }
708        let _ = self.versions.drain(0..drop_count);
709        self.pruned += drop_count;
710        self.cursor -= drop_count;
711    }
712}
713
714/// Structural-sharing and memory-retention diagnostics for a [`PaneVersionStore`].
715#[derive(Debug, Clone, PartialEq, serde::Serialize)]
716pub struct PaneVersioningReport {
717    /// Number of retained versions.
718    pub version_count: usize,
719    /// Current cursor index.
720    pub cursor: usize,
721    /// Physically distinct `Arc` node allocations across all retained versions.
722    pub distinct_nodes: usize,
723    /// Sum of per-version node counts (what naive snapshot-per-version stores).
724    pub total_logical_nodes: usize,
725    /// `total_logical_nodes - distinct_nodes`: nodes reused via sharing.
726    pub shared_nodes: usize,
727    /// `shared_nodes / total_logical_nodes` in `[0, 1]`.
728    pub sharing_ratio: f64,
729    /// Node count of the current version.
730    pub current_node_count: usize,
731    /// Depth of the current version.
732    pub current_depth: usize,
733    /// Versions pruned by the retention bound.
734    pub pruned_versions: usize,
735}
736
737/// Deterministic retained-memory byte model for a [`PaneVersionStore`].
738///
739/// Mirrors the canonical timeline's
740/// [`PaneInteractionTimelineRetentionDiagnostics`](crate::pane::PaneInteractionTimelineRetentionDiagnostics)
741/// methodology (`size_of` struct estimates plus measured string payload bytes)
742/// so the persistent and checkpointed strategies are directly comparable. The
743/// defining economic property of structural sharing is captured here: node
744/// struct bytes scale with *physically distinct* (`Arc`-shared) nodes, not the
745/// logical node total summed over every retained version.
746#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize)]
747pub struct PaneVersionRetention {
748    /// Number of retained versions held by the store.
749    pub version_count: usize,
750    /// Physically distinct `Arc` node allocations across all retained versions.
751    pub distinct_node_count: usize,
752    /// Sum of per-version node counts (what naive snapshot-per-version stores).
753    pub total_logical_node_count: usize,
754    /// `(total_logical - distinct) / total_logical` in `[0, 1]`.
755    pub sharing_ratio: f64,
756    /// `distinct_node_count × (size_of::<PersistentNode>() + ARC_HEADER_BYTES)`.
757    pub distinct_struct_bytes: usize,
758    /// Measured leaf surface-key payload bytes over distinct nodes.
759    pub distinct_leaf_payload_bytes: usize,
760    /// Measured node + leaf extension-map payload bytes over distinct nodes.
761    pub distinct_extension_payload_bytes: usize,
762    /// `version_count × size_of::<VersionedPaneTree>()` (the per-version handles).
763    pub version_metadata_bytes: usize,
764    /// Estimated total retained bytes (container + structs + payload + metadata).
765    pub estimated_total_retained_bytes: usize,
766}
767
768// ---------------------------------------------------------------------------
769// Path-copying primitives (free functions over `Arc<PersistentNode>`).
770//
771// Convention for `rebuild_*`: `Ok(None)` means "target not found in this
772// subtree" (caller keeps searching / surfaces `MissingNode`); `Ok(Some(node))`
773// means "found and rebuilt"; `Err(_)` means "found but the operation is
774// illegal here". Off-path subtrees are returned via `Arc::clone`, preserving
775// structural sharing.
776// ---------------------------------------------------------------------------
777
778fn allocate_id(next_id: &mut PaneId) -> Result<PaneId, PersistentApplyError> {
779    let current = *next_id;
780    *next_id = current
781        .checked_next()
782        .map_err(|_| PersistentApplyError::IdOverflow { current })?;
783    Ok(current)
784}
785
786fn rebuild_split_with(
787    template: &Arc<PersistentNode>,
788    first: Arc<PersistentNode>,
789    second: Arc<PersistentNode>,
790) -> Arc<PersistentNode> {
791    match &**template {
792        PersistentNode::Split {
793            id,
794            constraints,
795            node_extensions,
796            axis,
797            ratio,
798            ..
799        } => Arc::new(PersistentNode::Split {
800            id: *id,
801            constraints: *constraints,
802            node_extensions: node_extensions.clone(),
803            axis: *axis,
804            ratio: *ratio,
805            first,
806            second,
807        }),
808        // Only ever called with a split template.
809        PersistentNode::Leaf { .. } => template.clone(),
810    }
811}
812
813fn rebuild_set_ratio(
814    node: &Arc<PersistentNode>,
815    target: PaneId,
816    ratio: PaneSplitRatio,
817) -> Result<Option<Arc<PersistentNode>>, PersistentApplyError> {
818    match &**node {
819        PersistentNode::Leaf { id, .. } => {
820            if *id == target {
821                Err(PersistentApplyError::NotSplit { node_id: target })
822            } else {
823                Ok(None)
824            }
825        }
826        PersistentNode::Split {
827            id,
828            constraints,
829            node_extensions,
830            axis,
831            first,
832            second,
833            ..
834        } => {
835            if *id == target {
836                return Ok(Some(Arc::new(PersistentNode::Split {
837                    id: *id,
838                    constraints: *constraints,
839                    node_extensions: node_extensions.clone(),
840                    axis: *axis,
841                    ratio,
842                    first: first.clone(),
843                    second: second.clone(),
844                })));
845            }
846            if let Some(new_first) = rebuild_set_ratio(first, target, ratio)? {
847                return Ok(Some(rebuild_split_with(node, new_first, second.clone())));
848            }
849            if let Some(new_second) = rebuild_set_ratio(second, target, ratio)? {
850                return Ok(Some(rebuild_split_with(node, first.clone(), new_second)));
851            }
852            Ok(None)
853        }
854    }
855}
856
857#[allow(clippy::too_many_arguments)]
858fn rebuild_split_leaf(
859    node: &Arc<PersistentNode>,
860    target: PaneId,
861    axis: SplitAxis,
862    ratio: PaneSplitRatio,
863    placement: PanePlacement,
864    new_leaf: &PaneLeaf,
865    next_id: &mut PaneId,
866) -> Result<Option<Arc<PersistentNode>>, PersistentApplyError> {
867    match &**node {
868        PersistentNode::Leaf { id, .. } => {
869            if *id != target {
870                return Ok(None);
871            }
872            // Allocation order matches the baseline: split id, then leaf id.
873            let split_id = allocate_id(next_id)?;
874            let new_leaf_id = allocate_id(next_id)?;
875            let existing = node.clone();
876            let incoming = Arc::new(PersistentNode::Leaf {
877                id: new_leaf_id,
878                constraints: PaneConstraints::default(),
879                node_extensions: BTreeMap::new(),
880                leaf: new_leaf.clone(),
881            });
882            let (first, second) = match placement {
883                PanePlacement::ExistingFirst => (existing, incoming),
884                PanePlacement::IncomingFirst => (incoming, existing),
885            };
886            Ok(Some(Arc::new(PersistentNode::Split {
887                id: split_id,
888                constraints: PaneConstraints::default(),
889                node_extensions: BTreeMap::new(),
890                axis,
891                ratio,
892                first,
893                second,
894            })))
895        }
896        PersistentNode::Split {
897            id, first, second, ..
898        } => {
899            if *id == target {
900                return Err(PersistentApplyError::NotLeaf { node_id: target });
901            }
902            if let Some(new_first) =
903                rebuild_split_leaf(first, target, axis, ratio, placement, new_leaf, next_id)?
904            {
905                return Ok(Some(rebuild_split_with(node, new_first, second.clone())));
906            }
907            if let Some(new_second) =
908                rebuild_split_leaf(second, target, axis, ratio, placement, new_leaf, next_id)?
909            {
910                return Ok(Some(rebuild_split_with(node, first.clone(), new_second)));
911            }
912            Ok(None)
913        }
914    }
915}
916
917/// Close `target` by promoting its sibling. Returns the rebuilt parent path, or
918/// `None` if `target` is not a child anywhere in the subtree.
919fn rebuild_close_node(node: &Arc<PersistentNode>, target: PaneId) -> Option<Arc<PersistentNode>> {
920    let PersistentNode::Split { first, second, .. } = &**node else {
921        return None;
922    };
923    if first.id() == target {
924        return Some(second.clone());
925    }
926    if second.id() == target {
927        return Some(first.clone());
928    }
929    if let Some(new_first) = rebuild_close_node(first, target) {
930        return Some(rebuild_split_with(node, new_first, second.clone()));
931    }
932    if let Some(new_second) = rebuild_close_node(second, target) {
933        return Some(rebuild_split_with(node, first.clone(), new_second));
934    }
935    None
936}
937
938/// Find the subtree rooted at `target` (an `Arc` clone), if present.
939fn find_subtree(node: &Arc<PersistentNode>, target: PaneId) -> Option<Arc<PersistentNode>> {
940    if node.id() == target {
941        return Some(node.clone());
942    }
943    if let PersistentNode::Split { first, second, .. } = &**node {
944        if let Some(found) = find_subtree(first, target) {
945            return Some(found);
946        }
947        if let Some(found) = find_subtree(second, target) {
948            return Some(found);
949        }
950    }
951    None
952}
953
954/// Whether `target` appears anywhere in the subtree (including the root).
955fn subtree_contains(node: &Arc<PersistentNode>, target: PaneId) -> bool {
956    if node.id() == target {
957        return true;
958    }
959    match &**node {
960        PersistentNode::Leaf { .. } => false,
961        PersistentNode::Split { first, second, .. } => {
962            subtree_contains(first, target) || subtree_contains(second, target)
963        }
964    }
965}
966
967/// Rebuild `node`, replacing the node with id `a` by `arc_a` and id `b` by
968/// `arc_b`. Subtrees containing neither are returned by `Arc::clone`, so sharing
969/// is preserved everywhere off the two replacement paths.
970fn replace_two(
971    node: &Arc<PersistentNode>,
972    a: PaneId,
973    arc_a: &Arc<PersistentNode>,
974    b: PaneId,
975    arc_b: &Arc<PersistentNode>,
976) -> Arc<PersistentNode> {
977    if node.id() == a {
978        return arc_a.clone();
979    }
980    if node.id() == b {
981        return arc_b.clone();
982    }
983    match &**node {
984        PersistentNode::Leaf { .. } => node.clone(),
985        PersistentNode::Split { first, second, .. } => {
986            let new_first = replace_two(first, a, arc_a, b, arc_b);
987            let new_second = replace_two(second, a, arc_a, b, arc_b);
988            if Arc::ptr_eq(&new_first, first) && Arc::ptr_eq(&new_second, second) {
989                node.clone()
990            } else {
991                rebuild_split_with(node, new_first, new_second)
992            }
993        }
994    }
995}
996
997fn build_persistent(
998    records: &BTreeMap<PaneId, &PaneNodeRecord>,
999    id: PaneId,
1000) -> Arc<PersistentNode> {
1001    let record = records
1002        .get(&id)
1003        .copied()
1004        .expect("snapshot references only existing ids");
1005    match &record.kind {
1006        PaneNodeKind::Leaf(leaf) => Arc::new(PersistentNode::Leaf {
1007            id: record.id,
1008            constraints: record.constraints,
1009            node_extensions: record.extensions.clone(),
1010            leaf: leaf.clone(),
1011        }),
1012        PaneNodeKind::Split(split) => Arc::new(PersistentNode::Split {
1013            id: record.id,
1014            constraints: record.constraints,
1015            node_extensions: record.extensions.clone(),
1016            axis: split.axis,
1017            ratio: split.ratio,
1018            first: build_persistent(records, split.first),
1019            second: build_persistent(records, split.second),
1020        }),
1021    }
1022}
1023
1024fn flatten_persistent(
1025    node: &Arc<PersistentNode>,
1026    parent: Option<PaneId>,
1027    out: &mut Vec<PaneNodeRecord>,
1028) {
1029    match &**node {
1030        PersistentNode::Leaf {
1031            id,
1032            constraints,
1033            node_extensions,
1034            leaf,
1035        } => out.push(PaneNodeRecord {
1036            id: *id,
1037            parent,
1038            constraints: *constraints,
1039            kind: PaneNodeKind::Leaf(leaf.clone()),
1040            extensions: node_extensions.clone(),
1041        }),
1042        PersistentNode::Split {
1043            id,
1044            constraints,
1045            node_extensions,
1046            axis,
1047            ratio,
1048            first,
1049            second,
1050        } => {
1051            out.push(PaneNodeRecord {
1052                id: *id,
1053                parent,
1054                constraints: *constraints,
1055                kind: PaneNodeKind::Split(PaneSplit {
1056                    axis: *axis,
1057                    ratio: *ratio,
1058                    first: first.id(),
1059                    second: second.id(),
1060                }),
1061                extensions: node_extensions.clone(),
1062            });
1063            flatten_persistent(first, Some(*id), out);
1064            flatten_persistent(second, Some(*id), out);
1065        }
1066    }
1067}
1068
1069/// Bytes an `Arc<T>` allocation prepends ahead of `T`: the strong and weak
1070/// reference counters (`AtomicUsize` each). Added to `size_of::<PersistentNode>()`
1071/// so the persistent retained-memory model accounts for the real heap cost of
1072/// each physically distinct shared node.
1073const ARC_HEADER_BYTES: usize = 2 * std::mem::size_of::<usize>();
1074
1075/// Measured payload bytes (keys + values) of a string→string extension map.
1076///
1077/// Mirrors the canonical timeline's private helper of the same name (in
1078/// [`crate::pane`]) so the persistent and checkpointed retained-memory models
1079/// are byte-comparable.
1080pub(crate) fn string_map_payload_bytes(map: &BTreeMap<String, String>) -> usize {
1081    map.iter()
1082        .map(|(key, value)| key.len().saturating_add(value.len()))
1083        .sum()
1084}
1085
1086/// Collect physically distinct node allocations reachable from `roots`.
1087///
1088/// A node already in `seen` short-circuits: because sharing is structural, a
1089/// previously visited `Arc` shares its entire subtree, so the subtree is already
1090/// counted. This makes distinct-counting proportional to the number of distinct
1091/// nodes rather than the logical node total.
1092fn collect_distinct<'a>(
1093    roots: impl Iterator<Item = &'a Arc<PersistentNode>>,
1094    seen: &mut HashSet<usize>,
1095) {
1096    let mut stack: Vec<&Arc<PersistentNode>> = roots.collect();
1097    while let Some(node) = stack.pop() {
1098        let key = Arc::as_ptr(node).addr();
1099        if !seen.insert(key) {
1100            continue;
1101        }
1102        if let PersistentNode::Split { first, second, .. } = &**node {
1103            stack.push(first);
1104            stack.push(second);
1105        }
1106    }
1107}
1108
1109#[cfg(test)]
1110mod tests {
1111    use super::*;
1112    use crate::pane::PaneTree;
1113
1114    fn ratio(n: u32, d: u32) -> PaneSplitRatio {
1115        PaneSplitRatio::new(n, d).expect("valid ratio")
1116    }
1117
1118    /// Build a small balanced-ish tree by splitting the root then its children.
1119    /// Returns the version plus the ids of the two interior splits.
1120    fn build_demo() -> VersionedPaneTree {
1121        let v0 = VersionedPaneTree::singleton("root");
1122        // Split root leaf (id 1) → split id 2, new leaf id 3.
1123        let op1 = PaneOperation::SplitLeaf {
1124            target: PaneId::MIN,
1125            axis: SplitAxis::Horizontal,
1126            ratio: ratio(1, 1),
1127            placement: PanePlacement::ExistingFirst,
1128            new_leaf: PaneLeaf::new("b"),
1129        };
1130        let v1 = v0.apply_operation(&op1).expect("split root");
1131        // Split leaf id 1 again → split id 4, new leaf id 5.
1132        let op2 = PaneOperation::SplitLeaf {
1133            target: PaneId::MIN,
1134            axis: SplitAxis::Vertical,
1135            ratio: ratio(2, 1),
1136            placement: PanePlacement::ExistingFirst,
1137            new_leaf: PaneLeaf::new("c"),
1138        };
1139        v1.apply_operation(&op2).expect("split leaf 1")
1140    }
1141
1142    #[test]
1143    fn singleton_round_trips_through_canonical_tree() {
1144        let versioned = VersionedPaneTree::singleton("root");
1145        let canonical = PaneTree::singleton("root");
1146        assert_eq!(
1147            versioned.state_hash().expect("hash"),
1148            canonical.state_hash()
1149        );
1150    }
1151
1152    #[test]
1153    fn split_leaf_matches_canonical_baseline() {
1154        let versioned = build_demo();
1155        let mut canonical = PaneTree::singleton("root");
1156        canonical
1157            .apply_operation_conservative(
1158                1,
1159                PaneOperation::SplitLeaf {
1160                    target: PaneId::MIN,
1161                    axis: SplitAxis::Horizontal,
1162                    ratio: ratio(1, 1),
1163                    placement: PanePlacement::ExistingFirst,
1164                    new_leaf: PaneLeaf::new("b"),
1165                },
1166            )
1167            .expect("split root");
1168        canonical
1169            .apply_operation_conservative(
1170                2,
1171                PaneOperation::SplitLeaf {
1172                    target: PaneId::MIN,
1173                    axis: SplitAxis::Vertical,
1174                    ratio: ratio(2, 1),
1175                    placement: PanePlacement::ExistingFirst,
1176                    new_leaf: PaneLeaf::new("c"),
1177                },
1178            )
1179            .expect("split leaf 1");
1180        assert_eq!(
1181            versioned.state_hash().expect("hash"),
1182            canonical.state_hash()
1183        );
1184        assert_eq!(versioned.next_id(), canonical.next_id());
1185    }
1186
1187    #[test]
1188    fn set_split_ratio_preserves_off_path_sharing() {
1189        let base = build_demo();
1190        // The interior split created first is id 2 (root). Its second child is
1191        // leaf id 3, which must be physically shared after we re-ratio split id 4.
1192        let before_second = match &**base.root() {
1193            PersistentNode::Split { second, .. } => second.clone(),
1194            PersistentNode::Leaf { .. } => unreachable!("root is a split"),
1195        };
1196        let next = base
1197            .apply_operation(&PaneOperation::SetSplitRatio {
1198                split: PaneId::new(4).unwrap(),
1199                ratio: ratio(3, 1),
1200            })
1201            .expect("set ratio");
1202        let after_second = match &**next.root() {
1203            PersistentNode::Split { second, .. } => second.clone(),
1204            PersistentNode::Leaf { .. } => unreachable!("root is a split"),
1205        };
1206        assert!(
1207            Arc::ptr_eq(&before_second, &after_second),
1208            "off-path subtree must be shared, not re-allocated"
1209        );
1210        // And the root itself was re-allocated (the path changed).
1211        assert!(!Arc::ptr_eq(base.root(), next.root()));
1212    }
1213
1214    #[test]
1215    fn set_split_ratio_on_leaf_is_rejected() {
1216        let base = build_demo();
1217        let err = base
1218            .apply_operation(&PaneOperation::SetSplitRatio {
1219                split: PaneId::MIN,
1220                ratio: ratio(1, 1),
1221            })
1222            .expect_err("leaf is not a split");
1223        assert_eq!(
1224            err,
1225            PersistentApplyError::NotSplit {
1226                node_id: PaneId::MIN
1227            }
1228        );
1229    }
1230
1231    #[test]
1232    fn close_node_promotes_sibling_like_baseline() {
1233        let base = build_demo();
1234        let op = PaneOperation::CloseNode {
1235            target: PaneId::new(3).unwrap(),
1236        };
1237        let next = base.apply_operation(&op).expect("close leaf 3");
1238
1239        let mut canonical = base.to_pane_tree().expect("flatten");
1240        canonical
1241            .apply_operation_conservative(99, op)
1242            .expect("close leaf 3");
1243        assert_eq!(next.state_hash().expect("hash"), canonical.state_hash());
1244    }
1245
1246    #[test]
1247    fn close_root_is_rejected() {
1248        let base = build_demo();
1249        let err = base
1250            .apply_operation(&PaneOperation::CloseNode {
1251                target: base.root_id(),
1252            })
1253            .expect_err("cannot close root");
1254        assert!(matches!(err, PersistentApplyError::CannotCloseRoot { .. }));
1255    }
1256
1257    #[test]
1258    fn swap_nodes_matches_baseline_and_shares() {
1259        let base = build_demo();
1260        let op = PaneOperation::SwapNodes {
1261            first: PaneId::new(3).unwrap(),
1262            second: PaneId::new(5).unwrap(),
1263        };
1264        let next = base.apply_operation(&op).expect("swap leaves");
1265
1266        let mut canonical = base.to_pane_tree().expect("flatten");
1267        canonical.apply_operation_conservative(7, op).expect("swap");
1268        assert_eq!(next.state_hash().expect("hash"), canonical.state_hash());
1269    }
1270
1271    #[test]
1272    fn swap_with_self_is_noop() {
1273        let base = build_demo();
1274        let next = base
1275            .apply_operation(&PaneOperation::SwapNodes {
1276                first: PaneId::new(3).unwrap(),
1277                second: PaneId::new(3).unwrap(),
1278            })
1279            .expect("self swap is a no-op");
1280        assert_eq!(next.state_hash().expect("a"), base.state_hash().expect("b"));
1281    }
1282
1283    #[test]
1284    fn version_store_undo_redo_is_o1_and_correct() {
1285        let mut store = PaneVersionStore::new(VersionedPaneTree::singleton("root"));
1286        let h0 = store.current().state_hash().expect("h0");
1287        store
1288            .apply(&PaneOperation::SplitLeaf {
1289                target: PaneId::MIN,
1290                axis: SplitAxis::Horizontal,
1291                ratio: ratio(1, 1),
1292                placement: PanePlacement::ExistingFirst,
1293                new_leaf: PaneLeaf::new("b"),
1294            })
1295            .expect("split");
1296        let h1 = store.current().state_hash().expect("h1");
1297        assert_ne!(h0, h1);
1298
1299        assert!(store.undo());
1300        assert_eq!(store.current().state_hash().expect("u"), h0);
1301        assert!(store.redo());
1302        assert_eq!(store.current().state_hash().expect("r"), h1);
1303        assert!(!store.redo());
1304        assert!(store.undo());
1305        assert!(!store.undo());
1306    }
1307
1308    #[test]
1309    fn report_quantifies_sharing() {
1310        let mut store = PaneVersionStore::new(build_demo());
1311        for n in 1..=8u32 {
1312            store
1313                .apply(&PaneOperation::SetSplitRatio {
1314                    split: PaneId::new(4).unwrap(),
1315                    ratio: ratio(n, 1),
1316                })
1317                .expect("set ratio");
1318        }
1319        let report = store.report();
1320        assert_eq!(report.version_count, 9);
1321        assert!(
1322            report.shared_nodes > 0,
1323            "consecutive versions must share nodes"
1324        );
1325        assert!(report.distinct_nodes < report.total_logical_nodes);
1326        assert!(report.sharing_ratio > 0.0 && report.sharing_ratio < 1.0);
1327    }
1328
1329    fn ratio_storm_store() -> PaneVersionStore {
1330        let mut store = PaneVersionStore::new(build_demo());
1331        for n in 1..=8u32 {
1332            store
1333                .apply(&PaneOperation::SetSplitRatio {
1334                    split: PaneId::new(4).unwrap(),
1335                    ratio: ratio(n, 1),
1336                })
1337                .expect("set ratio");
1338        }
1339        store
1340    }
1341
1342    #[test]
1343    fn retention_model_is_deterministic() {
1344        // Two independently constructed stores over the identical workload must
1345        // yield byte-identical retention models (acceptance criterion: telemetry
1346        // is deterministic enough to compare strategies meaningfully).
1347        assert_eq!(
1348            ratio_storm_store().retention(),
1349            ratio_storm_store().retention()
1350        );
1351    }
1352
1353    #[test]
1354    fn retention_total_is_faithful_sum_of_classes() {
1355        let store = ratio_storm_store();
1356        let r = store.retention();
1357        let class_sum = std::mem::size_of::<PaneVersionStore>()
1358            + r.distinct_struct_bytes
1359            + r.distinct_leaf_payload_bytes
1360            + r.distinct_extension_payload_bytes
1361            + r.version_metadata_bytes;
1362        assert_eq!(r.estimated_total_retained_bytes, class_sum);
1363        assert_eq!(r.version_count, 9);
1364        assert!(r.distinct_node_count < r.total_logical_node_count);
1365    }
1366
1367    #[test]
1368    fn retention_node_bytes_scale_with_distinct_not_logical_nodes() {
1369        // The economic claim of structural sharing: node struct bytes are paid
1370        // per physically distinct node, far below the naive snapshot-per-version
1371        // cost of `total_logical_node_count` nodes.
1372        let store = ratio_storm_store();
1373        let r = store.retention();
1374        let per_node = std::mem::size_of::<PersistentNode>() + ARC_HEADER_BYTES;
1375        assert_eq!(r.distinct_struct_bytes, r.distinct_node_count * per_node);
1376        let naive_struct_bytes = r.total_logical_node_count * per_node;
1377        assert!(
1378            r.distinct_struct_bytes < naive_struct_bytes,
1379            "sharing must cost fewer node-struct bytes than naive per-version snapshots"
1380        );
1381        assert!(r.sharing_ratio > 0.0 && r.sharing_ratio < 1.0);
1382    }
1383
1384    #[test]
1385    fn rebuild_fallback_normalize_ratios_matches_baseline() {
1386        let base = build_demo();
1387        let op = PaneOperation::NormalizeRatios;
1388        let next = base.apply_operation(&op).expect("normalize");
1389        let mut canonical = base.to_pane_tree().expect("flatten");
1390        canonical
1391            .apply_operation_conservative(11, op)
1392            .expect("normalize baseline");
1393        assert_eq!(next.state_hash().expect("hash"), canonical.state_hash());
1394        assert_eq!(
1395            VersionedPaneTree::operation_strategy(PaneOperationKind::NormalizeRatios),
1396            PersistentApplyStrategy::Rebuild
1397        );
1398    }
1399}