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.
497    /// Pruning never reaches the cursor: the current version — and therefore
498    /// [`current`](Self::current)'s state hash — plus the redo tail always
499    /// survive, so after a deep undo the store may retain more than
500    /// `max_versions` until the cursor advances again. Returns the number of
501    /// versions pruned by this call (possibly `0` when the cursor pins all
502    /// history). `0` is unbounded.
503    pub fn set_max_versions(&mut self, max_versions: usize) -> usize {
504        let pruned_before = self.pruned;
505        self.max_versions = max_versions;
506        self.enforce_retention();
507        self.pruned.saturating_sub(pruned_before)
508    }
509
510    /// Apply an operation to the current version, appending a new version.
511    ///
512    /// # Errors
513    ///
514    /// Propagates the [`PersistentApplyError`] from the underlying apply.
515    pub fn apply(
516        &mut self,
517        operation: &PaneOperation,
518    ) -> Result<&VersionedPaneTree, PersistentApplyError> {
519        let next = self.current().apply_operation(operation)?;
520        // Drop any redo branch, then append.
521        self.versions.truncate(self.cursor + 1);
522        self.versions.push(next);
523        self.cursor = self.versions.len() - 1;
524        self.enforce_retention();
525        Ok(self.current())
526    }
527
528    /// Move to the previous version. Returns `false` at the oldest retained version.
529    pub fn undo(&mut self) -> bool {
530        if self.cursor == 0 {
531            return false;
532        }
533        self.cursor -= 1;
534        true
535    }
536
537    /// Move to the next version. Returns `false` at the newest version.
538    pub fn redo(&mut self) -> bool {
539        if self.cursor + 1 >= self.versions.len() {
540            return false;
541        }
542        self.cursor += 1;
543        true
544    }
545
546    /// The current version.
547    #[must_use]
548    pub fn current(&self) -> &VersionedPaneTree {
549        &self.versions[self.cursor]
550    }
551
552    /// Number of retained versions.
553    #[must_use]
554    pub fn version_count(&self) -> usize {
555        self.versions.len()
556    }
557
558    /// Current cursor index.
559    #[must_use]
560    pub const fn cursor(&self) -> usize {
561        self.cursor
562    }
563
564    /// Whether [`undo`](Self::undo) would succeed.
565    #[must_use]
566    pub const fn can_undo(&self) -> bool {
567        self.cursor > 0
568    }
569
570    /// Whether [`redo`](Self::redo) would succeed.
571    #[must_use]
572    pub fn can_redo(&self) -> bool {
573        self.cursor + 1 < self.versions.len()
574    }
575
576    /// Number of versions pruned by the retention bound so far.
577    #[must_use]
578    pub const fn pruned(&self) -> usize {
579        self.pruned
580    }
581
582    /// Compute a structural-sharing and memory-retention report.
583    #[must_use]
584    pub fn report(&self) -> PaneVersioningReport {
585        let mut distinct: HashSet<usize> = HashSet::new();
586        collect_distinct(
587            self.versions.iter().map(VersionedPaneTree::root),
588            &mut distinct,
589        );
590        let total_logical_nodes: usize = self
591            .versions
592            .iter()
593            .map(VersionedPaneTree::node_count)
594            .sum();
595        let distinct_nodes = distinct.len();
596        let shared_nodes = total_logical_nodes.saturating_sub(distinct_nodes);
597        let sharing_ratio = if total_logical_nodes == 0 {
598            0.0
599        } else {
600            shared_nodes as f64 / total_logical_nodes as f64
601        };
602        let current = self.current();
603        PaneVersioningReport {
604            version_count: self.versions.len(),
605            cursor: self.cursor,
606            distinct_nodes,
607            total_logical_nodes,
608            shared_nodes,
609            sharing_ratio,
610            current_node_count: current.node_count(),
611            current_depth: current.depth(),
612            pruned_versions: self.pruned,
613        }
614    }
615
616    /// Compute a deterministic retained-memory byte model over the distinct
617    /// nodes physically held by all retained versions.
618    ///
619    /// The model mirrors the canonical timeline's
620    /// [`retention_diagnostics`](crate::pane::PaneInteractionTimeline::retention_diagnostics)
621    /// methodology — `size_of` struct estimates plus measured string payload
622    /// bytes — so the two strategies are directly comparable. Because shared
623    /// subtrees are counted once (via `Arc` pointer identity), node bytes scale
624    /// with *distinct* nodes, not the logical node total.
625    #[must_use]
626    pub fn retention(&self) -> PaneVersionRetention {
627        // Per-distinct-node byte accounting in a single traversal.
628        let mut seen: HashSet<usize> = HashSet::new();
629        let mut distinct_node_count = 0usize;
630        let mut distinct_leaf_payload_bytes = 0usize;
631        let mut distinct_extension_payload_bytes = 0usize;
632        let mut stack: Vec<&Arc<PersistentNode>> =
633            self.versions.iter().map(VersionedPaneTree::root).collect();
634        while let Some(node) = stack.pop() {
635            if !seen.insert(Arc::as_ptr(node).addr()) {
636                continue;
637            }
638            distinct_node_count += 1;
639            match &**node {
640                PersistentNode::Leaf {
641                    node_extensions,
642                    leaf,
643                    ..
644                } => {
645                    distinct_leaf_payload_bytes += leaf.surface_key.len();
646                    distinct_extension_payload_bytes += string_map_payload_bytes(&leaf.extensions)
647                        .saturating_add(string_map_payload_bytes(node_extensions));
648                }
649                PersistentNode::Split {
650                    node_extensions,
651                    first,
652                    second,
653                    ..
654                } => {
655                    distinct_extension_payload_bytes += string_map_payload_bytes(node_extensions);
656                    stack.push(first);
657                    stack.push(second);
658                }
659            }
660        }
661
662        let total_logical_node_count: usize = self
663            .versions
664            .iter()
665            .map(VersionedPaneTree::node_count)
666            .sum();
667        let arc_node_bytes = std::mem::size_of::<PersistentNode>().saturating_add(ARC_HEADER_BYTES);
668        let distinct_struct_bytes = distinct_node_count.saturating_mul(arc_node_bytes);
669        let version_metadata_bytes = self
670            .versions
671            .len()
672            .saturating_mul(std::mem::size_of::<VersionedPaneTree>());
673        let estimated_total_retained_bytes = std::mem::size_of::<Self>()
674            .saturating_add(distinct_struct_bytes)
675            .saturating_add(distinct_leaf_payload_bytes)
676            .saturating_add(distinct_extension_payload_bytes)
677            .saturating_add(version_metadata_bytes);
678        let shared_nodes = total_logical_node_count.saturating_sub(distinct_node_count);
679        let sharing_ratio = if total_logical_node_count == 0 {
680            0.0
681        } else {
682            shared_nodes as f64 / total_logical_node_count as f64
683        };
684
685        PaneVersionRetention {
686            version_count: self.versions.len(),
687            distinct_node_count,
688            total_logical_node_count,
689            sharing_ratio,
690            distinct_struct_bytes,
691            distinct_leaf_payload_bytes,
692            distinct_extension_payload_bytes,
693            version_metadata_bytes,
694            estimated_total_retained_bytes,
695        }
696    }
697
698    fn enforce_retention(&mut self) {
699        if self.max_versions == 0 || self.versions.len() <= self.max_versions {
700            return;
701        }
702        // Never prune at or after the cursor: the version `current()` points
703        // at (and everything the user can still redo into) must survive, even
704        // when the user has undone deep into history. Any excess beyond
705        // `max_versions` is tolerated until the cursor advances again.
706        let drop_count = (self.versions.len() - self.max_versions).min(self.cursor);
707        if drop_count == 0 {
708            return;
709        }
710        let _ = self.versions.drain(0..drop_count);
711        self.pruned += drop_count;
712        self.cursor -= drop_count;
713    }
714}
715
716/// Structural-sharing and memory-retention diagnostics for a [`PaneVersionStore`].
717#[derive(Debug, Clone, PartialEq, serde::Serialize)]
718pub struct PaneVersioningReport {
719    /// Number of retained versions.
720    pub version_count: usize,
721    /// Current cursor index.
722    pub cursor: usize,
723    /// Physically distinct `Arc` node allocations across all retained versions.
724    pub distinct_nodes: usize,
725    /// Sum of per-version node counts (what naive snapshot-per-version stores).
726    pub total_logical_nodes: usize,
727    /// `total_logical_nodes - distinct_nodes`: nodes reused via sharing.
728    pub shared_nodes: usize,
729    /// `shared_nodes / total_logical_nodes` in `[0, 1]`.
730    pub sharing_ratio: f64,
731    /// Node count of the current version.
732    pub current_node_count: usize,
733    /// Depth of the current version.
734    pub current_depth: usize,
735    /// Versions pruned by the retention bound.
736    pub pruned_versions: usize,
737}
738
739/// Deterministic retained-memory byte model for a [`PaneVersionStore`].
740///
741/// Mirrors the canonical timeline's
742/// [`PaneInteractionTimelineRetentionDiagnostics`](crate::pane::PaneInteractionTimelineRetentionDiagnostics)
743/// methodology (`size_of` struct estimates plus measured string payload bytes)
744/// so the persistent and checkpointed strategies are directly comparable. The
745/// defining economic property of structural sharing is captured here: node
746/// struct bytes scale with *physically distinct* (`Arc`-shared) nodes, not the
747/// logical node total summed over every retained version.
748#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize)]
749pub struct PaneVersionRetention {
750    /// Number of retained versions held by the store.
751    pub version_count: usize,
752    /// Physically distinct `Arc` node allocations across all retained versions.
753    pub distinct_node_count: usize,
754    /// Sum of per-version node counts (what naive snapshot-per-version stores).
755    pub total_logical_node_count: usize,
756    /// `(total_logical - distinct) / total_logical` in `[0, 1]`.
757    pub sharing_ratio: f64,
758    /// `distinct_node_count × (size_of::<PersistentNode>() + ARC_HEADER_BYTES)`.
759    pub distinct_struct_bytes: usize,
760    /// Measured leaf surface-key payload bytes over distinct nodes.
761    pub distinct_leaf_payload_bytes: usize,
762    /// Measured node + leaf extension-map payload bytes over distinct nodes.
763    pub distinct_extension_payload_bytes: usize,
764    /// `version_count × size_of::<VersionedPaneTree>()` (the per-version handles).
765    pub version_metadata_bytes: usize,
766    /// Estimated total retained bytes (container + structs + payload + metadata).
767    pub estimated_total_retained_bytes: usize,
768}
769
770// ---------------------------------------------------------------------------
771// Path-copying primitives (free functions over `Arc<PersistentNode>`).
772//
773// Convention for `rebuild_*`: `Ok(None)` means "target not found in this
774// subtree" (caller keeps searching / surfaces `MissingNode`); `Ok(Some(node))`
775// means "found and rebuilt"; `Err(_)` means "found but the operation is
776// illegal here". Off-path subtrees are returned via `Arc::clone`, preserving
777// structural sharing.
778// ---------------------------------------------------------------------------
779
780fn allocate_id(next_id: &mut PaneId) -> Result<PaneId, PersistentApplyError> {
781    let current = *next_id;
782    *next_id = current
783        .checked_next()
784        .map_err(|_| PersistentApplyError::IdOverflow { current })?;
785    Ok(current)
786}
787
788fn rebuild_split_with(
789    template: &Arc<PersistentNode>,
790    first: Arc<PersistentNode>,
791    second: Arc<PersistentNode>,
792) -> Arc<PersistentNode> {
793    match &**template {
794        PersistentNode::Split {
795            id,
796            constraints,
797            node_extensions,
798            axis,
799            ratio,
800            ..
801        } => Arc::new(PersistentNode::Split {
802            id: *id,
803            constraints: *constraints,
804            node_extensions: node_extensions.clone(),
805            axis: *axis,
806            ratio: *ratio,
807            first,
808            second,
809        }),
810        // Only ever called with a split template.
811        PersistentNode::Leaf { .. } => template.clone(),
812    }
813}
814
815fn rebuild_set_ratio(
816    node: &Arc<PersistentNode>,
817    target: PaneId,
818    ratio: PaneSplitRatio,
819) -> Result<Option<Arc<PersistentNode>>, PersistentApplyError> {
820    match &**node {
821        PersistentNode::Leaf { id, .. } => {
822            if *id == target {
823                Err(PersistentApplyError::NotSplit { node_id: target })
824            } else {
825                Ok(None)
826            }
827        }
828        PersistentNode::Split {
829            id,
830            constraints,
831            node_extensions,
832            axis,
833            first,
834            second,
835            ..
836        } => {
837            if *id == target {
838                return Ok(Some(Arc::new(PersistentNode::Split {
839                    id: *id,
840                    constraints: *constraints,
841                    node_extensions: node_extensions.clone(),
842                    axis: *axis,
843                    ratio,
844                    first: first.clone(),
845                    second: second.clone(),
846                })));
847            }
848            if let Some(new_first) = rebuild_set_ratio(first, target, ratio)? {
849                return Ok(Some(rebuild_split_with(node, new_first, second.clone())));
850            }
851            if let Some(new_second) = rebuild_set_ratio(second, target, ratio)? {
852                return Ok(Some(rebuild_split_with(node, first.clone(), new_second)));
853            }
854            Ok(None)
855        }
856    }
857}
858
859#[allow(clippy::too_many_arguments)]
860fn rebuild_split_leaf(
861    node: &Arc<PersistentNode>,
862    target: PaneId,
863    axis: SplitAxis,
864    ratio: PaneSplitRatio,
865    placement: PanePlacement,
866    new_leaf: &PaneLeaf,
867    next_id: &mut PaneId,
868) -> Result<Option<Arc<PersistentNode>>, PersistentApplyError> {
869    match &**node {
870        PersistentNode::Leaf { id, .. } => {
871            if *id != target {
872                return Ok(None);
873            }
874            // Allocation order matches the baseline: split id, then leaf id.
875            let split_id = allocate_id(next_id)?;
876            let new_leaf_id = allocate_id(next_id)?;
877            let existing = node.clone();
878            let incoming = Arc::new(PersistentNode::Leaf {
879                id: new_leaf_id,
880                constraints: PaneConstraints::default(),
881                node_extensions: BTreeMap::new(),
882                leaf: new_leaf.clone(),
883            });
884            let (first, second) = match placement {
885                PanePlacement::ExistingFirst => (existing, incoming),
886                PanePlacement::IncomingFirst => (incoming, existing),
887            };
888            Ok(Some(Arc::new(PersistentNode::Split {
889                id: split_id,
890                constraints: PaneConstraints::default(),
891                node_extensions: BTreeMap::new(),
892                axis,
893                ratio,
894                first,
895                second,
896            })))
897        }
898        PersistentNode::Split {
899            id, first, second, ..
900        } => {
901            if *id == target {
902                return Err(PersistentApplyError::NotLeaf { node_id: target });
903            }
904            if let Some(new_first) =
905                rebuild_split_leaf(first, target, axis, ratio, placement, new_leaf, next_id)?
906            {
907                return Ok(Some(rebuild_split_with(node, new_first, second.clone())));
908            }
909            if let Some(new_second) =
910                rebuild_split_leaf(second, target, axis, ratio, placement, new_leaf, next_id)?
911            {
912                return Ok(Some(rebuild_split_with(node, first.clone(), new_second)));
913            }
914            Ok(None)
915        }
916    }
917}
918
919/// Close `target` by promoting its sibling. Returns the rebuilt parent path, or
920/// `None` if `target` is not a child anywhere in the subtree.
921fn rebuild_close_node(node: &Arc<PersistentNode>, target: PaneId) -> Option<Arc<PersistentNode>> {
922    let PersistentNode::Split { first, second, .. } = &**node else {
923        return None;
924    };
925    if first.id() == target {
926        return Some(second.clone());
927    }
928    if second.id() == target {
929        return Some(first.clone());
930    }
931    if let Some(new_first) = rebuild_close_node(first, target) {
932        return Some(rebuild_split_with(node, new_first, second.clone()));
933    }
934    if let Some(new_second) = rebuild_close_node(second, target) {
935        return Some(rebuild_split_with(node, first.clone(), new_second));
936    }
937    None
938}
939
940/// Find the subtree rooted at `target` (an `Arc` clone), if present.
941fn find_subtree(node: &Arc<PersistentNode>, target: PaneId) -> Option<Arc<PersistentNode>> {
942    if node.id() == target {
943        return Some(node.clone());
944    }
945    if let PersistentNode::Split { first, second, .. } = &**node {
946        if let Some(found) = find_subtree(first, target) {
947            return Some(found);
948        }
949        if let Some(found) = find_subtree(second, target) {
950            return Some(found);
951        }
952    }
953    None
954}
955
956/// Whether `target` appears anywhere in the subtree (including the root).
957fn subtree_contains(node: &Arc<PersistentNode>, target: PaneId) -> bool {
958    if node.id() == target {
959        return true;
960    }
961    match &**node {
962        PersistentNode::Leaf { .. } => false,
963        PersistentNode::Split { first, second, .. } => {
964            subtree_contains(first, target) || subtree_contains(second, target)
965        }
966    }
967}
968
969/// Rebuild `node`, replacing the node with id `a` by `arc_a` and id `b` by
970/// `arc_b`. Subtrees containing neither are returned by `Arc::clone`, so sharing
971/// is preserved everywhere off the two replacement paths.
972fn replace_two(
973    node: &Arc<PersistentNode>,
974    a: PaneId,
975    arc_a: &Arc<PersistentNode>,
976    b: PaneId,
977    arc_b: &Arc<PersistentNode>,
978) -> Arc<PersistentNode> {
979    if node.id() == a {
980        return arc_a.clone();
981    }
982    if node.id() == b {
983        return arc_b.clone();
984    }
985    match &**node {
986        PersistentNode::Leaf { .. } => node.clone(),
987        PersistentNode::Split { first, second, .. } => {
988            let new_first = replace_two(first, a, arc_a, b, arc_b);
989            let new_second = replace_two(second, a, arc_a, b, arc_b);
990            if Arc::ptr_eq(&new_first, first) && Arc::ptr_eq(&new_second, second) {
991                node.clone()
992            } else {
993                rebuild_split_with(node, new_first, new_second)
994            }
995        }
996    }
997}
998
999fn build_persistent(
1000    records: &BTreeMap<PaneId, &PaneNodeRecord>,
1001    id: PaneId,
1002) -> Arc<PersistentNode> {
1003    let record = records
1004        .get(&id)
1005        .copied()
1006        .expect("snapshot references only existing ids");
1007    match &record.kind {
1008        PaneNodeKind::Leaf(leaf) => Arc::new(PersistentNode::Leaf {
1009            id: record.id,
1010            constraints: record.constraints,
1011            node_extensions: record.extensions.clone(),
1012            leaf: leaf.clone(),
1013        }),
1014        PaneNodeKind::Split(split) => Arc::new(PersistentNode::Split {
1015            id: record.id,
1016            constraints: record.constraints,
1017            node_extensions: record.extensions.clone(),
1018            axis: split.axis,
1019            ratio: split.ratio,
1020            first: build_persistent(records, split.first),
1021            second: build_persistent(records, split.second),
1022        }),
1023    }
1024}
1025
1026fn flatten_persistent(
1027    node: &Arc<PersistentNode>,
1028    parent: Option<PaneId>,
1029    out: &mut Vec<PaneNodeRecord>,
1030) {
1031    match &**node {
1032        PersistentNode::Leaf {
1033            id,
1034            constraints,
1035            node_extensions,
1036            leaf,
1037        } => out.push(PaneNodeRecord {
1038            id: *id,
1039            parent,
1040            constraints: *constraints,
1041            kind: PaneNodeKind::Leaf(leaf.clone()),
1042            extensions: node_extensions.clone(),
1043        }),
1044        PersistentNode::Split {
1045            id,
1046            constraints,
1047            node_extensions,
1048            axis,
1049            ratio,
1050            first,
1051            second,
1052        } => {
1053            out.push(PaneNodeRecord {
1054                id: *id,
1055                parent,
1056                constraints: *constraints,
1057                kind: PaneNodeKind::Split(PaneSplit {
1058                    axis: *axis,
1059                    ratio: *ratio,
1060                    first: first.id(),
1061                    second: second.id(),
1062                }),
1063                extensions: node_extensions.clone(),
1064            });
1065            flatten_persistent(first, Some(*id), out);
1066            flatten_persistent(second, Some(*id), out);
1067        }
1068    }
1069}
1070
1071/// Bytes an `Arc<T>` allocation prepends ahead of `T`: the strong and weak
1072/// reference counters (`AtomicUsize` each). Added to `size_of::<PersistentNode>()`
1073/// so the persistent retained-memory model accounts for the real heap cost of
1074/// each physically distinct shared node.
1075const ARC_HEADER_BYTES: usize = 2 * std::mem::size_of::<usize>();
1076
1077/// Measured payload bytes (keys + values) of a string→string extension map.
1078///
1079/// Mirrors the canonical timeline's private helper of the same name (in
1080/// [`crate::pane`]) so the persistent and checkpointed retained-memory models
1081/// are byte-comparable.
1082pub(crate) fn string_map_payload_bytes(map: &BTreeMap<String, String>) -> usize {
1083    map.iter()
1084        .map(|(key, value)| key.len().saturating_add(value.len()))
1085        .sum()
1086}
1087
1088/// Collect physically distinct node allocations reachable from `roots`.
1089///
1090/// A node already in `seen` short-circuits: because sharing is structural, a
1091/// previously visited `Arc` shares its entire subtree, so the subtree is already
1092/// counted. This makes distinct-counting proportional to the number of distinct
1093/// nodes rather than the logical node total.
1094fn collect_distinct<'a>(
1095    roots: impl Iterator<Item = &'a Arc<PersistentNode>>,
1096    seen: &mut HashSet<usize>,
1097) {
1098    let mut stack: Vec<&Arc<PersistentNode>> = roots.collect();
1099    while let Some(node) = stack.pop() {
1100        let key = Arc::as_ptr(node).addr();
1101        if !seen.insert(key) {
1102            continue;
1103        }
1104        if let PersistentNode::Split { first, second, .. } = &**node {
1105            stack.push(first);
1106            stack.push(second);
1107        }
1108    }
1109}
1110
1111#[cfg(test)]
1112mod tests {
1113    use super::*;
1114    use crate::pane::PaneTree;
1115
1116    fn ratio(n: u32, d: u32) -> PaneSplitRatio {
1117        PaneSplitRatio::new(n, d).expect("valid ratio")
1118    }
1119
1120    /// Build a small balanced-ish tree by splitting the root then its children.
1121    /// Returns the version plus the ids of the two interior splits.
1122    fn build_demo() -> VersionedPaneTree {
1123        let v0 = VersionedPaneTree::singleton("root");
1124        // Split root leaf (id 1) → split id 2, new leaf id 3.
1125        let op1 = PaneOperation::SplitLeaf {
1126            target: PaneId::MIN,
1127            axis: SplitAxis::Horizontal,
1128            ratio: ratio(1, 1),
1129            placement: PanePlacement::ExistingFirst,
1130            new_leaf: PaneLeaf::new("b"),
1131        };
1132        let v1 = v0.apply_operation(&op1).expect("split root");
1133        // Split leaf id 1 again → split id 4, new leaf id 5.
1134        let op2 = PaneOperation::SplitLeaf {
1135            target: PaneId::MIN,
1136            axis: SplitAxis::Vertical,
1137            ratio: ratio(2, 1),
1138            placement: PanePlacement::ExistingFirst,
1139            new_leaf: PaneLeaf::new("c"),
1140        };
1141        v1.apply_operation(&op2).expect("split leaf 1")
1142    }
1143
1144    #[test]
1145    fn singleton_round_trips_through_canonical_tree() {
1146        let versioned = VersionedPaneTree::singleton("root");
1147        let canonical = PaneTree::singleton("root");
1148        assert_eq!(
1149            versioned.state_hash().expect("hash"),
1150            canonical.state_hash()
1151        );
1152    }
1153
1154    #[test]
1155    fn split_leaf_matches_canonical_baseline() {
1156        let versioned = build_demo();
1157        let mut canonical = PaneTree::singleton("root");
1158        canonical
1159            .apply_operation_conservative(
1160                1,
1161                PaneOperation::SplitLeaf {
1162                    target: PaneId::MIN,
1163                    axis: SplitAxis::Horizontal,
1164                    ratio: ratio(1, 1),
1165                    placement: PanePlacement::ExistingFirst,
1166                    new_leaf: PaneLeaf::new("b"),
1167                },
1168            )
1169            .expect("split root");
1170        canonical
1171            .apply_operation_conservative(
1172                2,
1173                PaneOperation::SplitLeaf {
1174                    target: PaneId::MIN,
1175                    axis: SplitAxis::Vertical,
1176                    ratio: ratio(2, 1),
1177                    placement: PanePlacement::ExistingFirst,
1178                    new_leaf: PaneLeaf::new("c"),
1179                },
1180            )
1181            .expect("split leaf 1");
1182        assert_eq!(
1183            versioned.state_hash().expect("hash"),
1184            canonical.state_hash()
1185        );
1186        assert_eq!(versioned.next_id(), canonical.next_id());
1187    }
1188
1189    #[test]
1190    fn set_split_ratio_preserves_off_path_sharing() {
1191        let base = build_demo();
1192        // The interior split created first is id 2 (root). Its second child is
1193        // leaf id 3, which must be physically shared after we re-ratio split id 4.
1194        let before_second = match &**base.root() {
1195            PersistentNode::Split { second, .. } => second.clone(),
1196            PersistentNode::Leaf { .. } => unreachable!("root is a split"),
1197        };
1198        let next = base
1199            .apply_operation(&PaneOperation::SetSplitRatio {
1200                split: PaneId::new(4).unwrap(),
1201                ratio: ratio(3, 1),
1202            })
1203            .expect("set ratio");
1204        let after_second = match &**next.root() {
1205            PersistentNode::Split { second, .. } => second.clone(),
1206            PersistentNode::Leaf { .. } => unreachable!("root is a split"),
1207        };
1208        assert!(
1209            Arc::ptr_eq(&before_second, &after_second),
1210            "off-path subtree must be shared, not re-allocated"
1211        );
1212        // And the root itself was re-allocated (the path changed).
1213        assert!(!Arc::ptr_eq(base.root(), next.root()));
1214    }
1215
1216    #[test]
1217    fn set_split_ratio_on_leaf_is_rejected() {
1218        let base = build_demo();
1219        let err = base
1220            .apply_operation(&PaneOperation::SetSplitRatio {
1221                split: PaneId::MIN,
1222                ratio: ratio(1, 1),
1223            })
1224            .expect_err("leaf is not a split");
1225        assert_eq!(
1226            err,
1227            PersistentApplyError::NotSplit {
1228                node_id: PaneId::MIN
1229            }
1230        );
1231    }
1232
1233    #[test]
1234    fn close_node_promotes_sibling_like_baseline() {
1235        let base = build_demo();
1236        let op = PaneOperation::CloseNode {
1237            target: PaneId::new(3).unwrap(),
1238        };
1239        let next = base.apply_operation(&op).expect("close leaf 3");
1240
1241        let mut canonical = base.to_pane_tree().expect("flatten");
1242        canonical
1243            .apply_operation_conservative(99, op)
1244            .expect("close leaf 3");
1245        assert_eq!(next.state_hash().expect("hash"), canonical.state_hash());
1246    }
1247
1248    #[test]
1249    fn close_root_is_rejected() {
1250        let base = build_demo();
1251        let err = base
1252            .apply_operation(&PaneOperation::CloseNode {
1253                target: base.root_id(),
1254            })
1255            .expect_err("cannot close root");
1256        assert!(matches!(err, PersistentApplyError::CannotCloseRoot { .. }));
1257    }
1258
1259    #[test]
1260    fn swap_nodes_matches_baseline_and_shares() {
1261        let base = build_demo();
1262        let op = PaneOperation::SwapNodes {
1263            first: PaneId::new(3).unwrap(),
1264            second: PaneId::new(5).unwrap(),
1265        };
1266        let next = base.apply_operation(&op).expect("swap leaves");
1267
1268        let mut canonical = base.to_pane_tree().expect("flatten");
1269        canonical.apply_operation_conservative(7, op).expect("swap");
1270        assert_eq!(next.state_hash().expect("hash"), canonical.state_hash());
1271    }
1272
1273    #[test]
1274    fn swap_with_self_is_noop() {
1275        let base = build_demo();
1276        let next = base
1277            .apply_operation(&PaneOperation::SwapNodes {
1278                first: PaneId::new(3).unwrap(),
1279                second: PaneId::new(3).unwrap(),
1280            })
1281            .expect("self swap is a no-op");
1282        assert_eq!(next.state_hash().expect("a"), base.state_hash().expect("b"));
1283    }
1284
1285    #[test]
1286    fn version_store_undo_redo_is_o1_and_correct() {
1287        let mut store = PaneVersionStore::new(VersionedPaneTree::singleton("root"));
1288        let h0 = store.current().state_hash().expect("h0");
1289        store
1290            .apply(&PaneOperation::SplitLeaf {
1291                target: PaneId::MIN,
1292                axis: SplitAxis::Horizontal,
1293                ratio: ratio(1, 1),
1294                placement: PanePlacement::ExistingFirst,
1295                new_leaf: PaneLeaf::new("b"),
1296            })
1297            .expect("split");
1298        let h1 = store.current().state_hash().expect("h1");
1299        assert_ne!(h0, h1);
1300
1301        assert!(store.undo());
1302        assert_eq!(store.current().state_hash().expect("u"), h0);
1303        assert!(store.redo());
1304        assert_eq!(store.current().state_hash().expect("r"), h1);
1305        assert!(!store.redo());
1306        assert!(store.undo());
1307        assert!(!store.undo());
1308    }
1309
1310    #[test]
1311    fn report_quantifies_sharing() {
1312        let mut store = PaneVersionStore::new(build_demo());
1313        for n in 1..=8u32 {
1314            store
1315                .apply(&PaneOperation::SetSplitRatio {
1316                    split: PaneId::new(4).unwrap(),
1317                    ratio: ratio(n, 1),
1318                })
1319                .expect("set ratio");
1320        }
1321        let report = store.report();
1322        assert_eq!(report.version_count, 9);
1323        assert!(
1324            report.shared_nodes > 0,
1325            "consecutive versions must share nodes"
1326        );
1327        assert!(report.distinct_nodes < report.total_logical_nodes);
1328        assert!(report.sharing_ratio > 0.0 && report.sharing_ratio < 1.0);
1329    }
1330
1331    fn ratio_storm_store() -> PaneVersionStore {
1332        let mut store = PaneVersionStore::new(build_demo());
1333        for n in 1..=8u32 {
1334            store
1335                .apply(&PaneOperation::SetSplitRatio {
1336                    split: PaneId::new(4).unwrap(),
1337                    ratio: ratio(n, 1),
1338                })
1339                .expect("set ratio");
1340        }
1341        store
1342    }
1343
1344    #[test]
1345    fn retention_model_is_deterministic() {
1346        // Two independently constructed stores over the identical workload must
1347        // yield byte-identical retention models (acceptance criterion: telemetry
1348        // is deterministic enough to compare strategies meaningfully).
1349        assert_eq!(
1350            ratio_storm_store().retention(),
1351            ratio_storm_store().retention()
1352        );
1353    }
1354
1355    #[test]
1356    fn retention_total_is_faithful_sum_of_classes() {
1357        let store = ratio_storm_store();
1358        let r = store.retention();
1359        let class_sum = std::mem::size_of::<PaneVersionStore>()
1360            + r.distinct_struct_bytes
1361            + r.distinct_leaf_payload_bytes
1362            + r.distinct_extension_payload_bytes
1363            + r.version_metadata_bytes;
1364        assert_eq!(r.estimated_total_retained_bytes, class_sum);
1365        assert_eq!(r.version_count, 9);
1366        assert!(r.distinct_node_count < r.total_logical_node_count);
1367    }
1368
1369    #[test]
1370    fn retention_node_bytes_scale_with_distinct_not_logical_nodes() {
1371        // The economic claim of structural sharing: node struct bytes are paid
1372        // per physically distinct node, far below the naive snapshot-per-version
1373        // cost of `total_logical_node_count` nodes.
1374        let store = ratio_storm_store();
1375        let r = store.retention();
1376        let per_node = std::mem::size_of::<PersistentNode>() + ARC_HEADER_BYTES;
1377        assert_eq!(r.distinct_struct_bytes, r.distinct_node_count * per_node);
1378        let naive_struct_bytes = r.total_logical_node_count * per_node;
1379        assert!(
1380            r.distinct_struct_bytes < naive_struct_bytes,
1381            "sharing must cost fewer node-struct bytes than naive per-version snapshots"
1382        );
1383        assert!(r.sharing_ratio > 0.0 && r.sharing_ratio < 1.0);
1384    }
1385
1386    #[test]
1387    fn rebuild_fallback_normalize_ratios_matches_baseline() {
1388        let base = build_demo();
1389        let op = PaneOperation::NormalizeRatios;
1390        let next = base.apply_operation(&op).expect("normalize");
1391        let mut canonical = base.to_pane_tree().expect("flatten");
1392        canonical
1393            .apply_operation_conservative(11, op)
1394            .expect("normalize baseline");
1395        assert_eq!(next.state_hash().expect("hash"), canonical.state_hash());
1396        assert_eq!(
1397            VersionedPaneTree::operation_strategy(PaneOperationKind::NormalizeRatios),
1398            PersistentApplyStrategy::Rebuild
1399        );
1400    }
1401}