Skip to main content

ftui_layout/
pane_memory.rs

1//! Cross-strategy pane-memory telemetry (`bd-25wj7.1`).
2//!
3//! Pane history can be retained three ways, each buying undo/redo at a different
4//! memory price:
5//!
6//! * **Baseline** — keep only the single live [`PaneTree`]; no undo history at
7//!   all. The irreducible floor.
8//! * **Checkpointed** — the production [`PaneInteractionTimeline`]: a baseline
9//!   snapshot, a periodic checkpoint snapshot, an operation log with per-entry
10//!   before/after state hashes, and replay to reconstruct historical states.
11//! * **Persistent** — the structurally shared [`PaneVersionStore`]: a `Vec` of
12//!   `Arc`-shared version roots where unchanged subtrees are physically reused,
13//!   so navigation is `O(1)` index moves with no replay and no hashes.
14//!
15//! This module turns those three into directly comparable [`PaneMemoryStrategyFootprint`]s
16//! and a single [`PaneMemoryComparison`] artifact. Every footprint is decomposed
17//! into the *same* retained-state classes — node structs, leaf payload,
18//! extension payload, operation log, state hashes, container/metadata — so the
19//! dominant memory driver of each strategy is explicit, not buried in one
20//! aggregate number. The byte models reuse the canonical timeline methodology
21//! (`size_of` struct estimates plus measured string payload bytes), so the
22//! comparison is deterministic and reproducible: identical inputs yield
23//! byte-identical reports, suitable for CI regression artifacts and as concrete
24//! baselines for the bounded-retention (`bd-25wj7.2`) and churn-reduction
25//! (`bd-25wj7.3`) work that depends on this bead.
26//!
27//! The retained-state model lives here in the library so it is pure and
28//! unit-testable; *transient* allocation counts (which need allocator
29//! instrumentation) are captured by the `pane_memory_telemetry` bench, which
30//! embeds this comparison into its emitted JSON manifest.
31
32use crate::pane::{
33    PaneInteractionTimeline, PaneNodeKind, PaneNodeRecord, PaneTree, PaneTreeSnapshot,
34};
35use crate::pane_persistent::{PaneVersionStore, string_map_payload_bytes};
36
37/// Schema version for the emitted memory-telemetry artifact. Bump on any
38/// breaking change to the footprint/comparison field layout.
39pub const PANE_MEMORY_TELEMETRY_SCHEMA_VERSION: u16 = 1;
40
41/// The history-retention strategy a [`PaneMemoryStrategyFootprint`] describes.
42#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
43pub enum PaneMemoryStrategy {
44    /// Keep only the single live tree; no undo history.
45    Baseline,
46    /// Checkpointed operation-log timeline with replay-based navigation.
47    Checkpointed,
48    /// Structurally shared persistent version store with `O(1)` navigation.
49    Persistent,
50}
51
52impl PaneMemoryStrategy {
53    /// Stable lower-case identifier for logs and artifact keys.
54    #[must_use]
55    pub const fn as_str(self) -> &'static str {
56        match self {
57            Self::Baseline => "baseline",
58            Self::Checkpointed => "checkpointed",
59            Self::Persistent => "persistent",
60        }
61    }
62}
63
64/// The single retained-state class that dominates a strategy's footprint.
65#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
66pub enum PaneMemoryDriver {
67    /// Tree node structs (records / `Arc` nodes) retained across versions.
68    NodeStructs,
69    /// Leaf surface-key payload bytes.
70    LeafPayload,
71    /// Node + leaf extension-map payload bytes.
72    ExtensionPayload,
73    /// Operation-log entry structs and operation heap payloads.
74    OperationLog,
75    /// Per-entry before/after state-hash bytes (replay verification cost).
76    StateHashes,
77    /// Container struct plus version/checkpoint metadata handles.
78    ContainerMetadata,
79}
80
81impl PaneMemoryDriver {
82    /// Human-readable phrase for the dominant driver.
83    #[must_use]
84    pub const fn as_str(self) -> &'static str {
85        match self {
86            Self::NodeStructs => "node structs",
87            Self::LeafPayload => "leaf payload",
88            Self::ExtensionPayload => "extension payload",
89            Self::OperationLog => "operation log",
90            Self::StateHashes => "state hashes",
91            Self::ContainerMetadata => "container/metadata",
92        }
93    }
94}
95
96/// Retained-memory footprint of one strategy, decomposed by retained-state class.
97///
98/// `total_retained_bytes` is authoritative; the per-class fields sum to it
99/// exactly (the last class, `container_and_metadata_bytes`, absorbs the
100/// remainder so the decomposition is always faithful).
101#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize)]
102pub struct PaneMemoryStrategyFootprint {
103    /// Which strategy this footprint describes.
104    pub strategy: PaneMemoryStrategy,
105    /// Count of retained heavy units: live tree (`1`) for baseline, retained
106    /// snapshots (baseline + checkpoints) for checkpointed, versions for
107    /// persistent.
108    pub retained_unit_count: usize,
109    /// Total retained tree nodes (logical for checkpointed snapshots; physically
110    /// distinct for the persistent store).
111    pub retained_node_count: usize,
112    /// Tree node struct bytes.
113    pub node_struct_bytes: usize,
114    /// Leaf surface-key payload bytes.
115    pub leaf_payload_bytes: usize,
116    /// Node + leaf extension-map payload bytes.
117    pub extension_payload_bytes: usize,
118    /// Operation-log entry struct + operation heap payload bytes (`0` unless
119    /// checkpointed).
120    pub operation_payload_bytes: usize,
121    /// Per-entry before/after state-hash bytes (`0` unless checkpointed).
122    pub state_hash_overhead_bytes: usize,
123    /// Container struct plus version/checkpoint metadata bytes (remainder).
124    pub container_and_metadata_bytes: usize,
125    /// Authoritative total retained bytes.
126    pub total_retained_bytes: usize,
127    /// The single largest retained-state class.
128    pub dominant_driver: PaneMemoryDriver,
129}
130
131impl PaneMemoryStrategyFootprint {
132    #[allow(clippy::too_many_arguments)]
133    fn assemble(
134        strategy: PaneMemoryStrategy,
135        retained_unit_count: usize,
136        retained_node_count: usize,
137        node_struct_bytes: usize,
138        leaf_payload_bytes: usize,
139        extension_payload_bytes: usize,
140        operation_payload_bytes: usize,
141        state_hash_overhead_bytes: usize,
142        total_retained_bytes: usize,
143    ) -> Self {
144        // The container/metadata class absorbs whatever the authoritative total
145        // does not attribute to a concrete class, so the decomposition always
146        // sums to `total_retained_bytes`.
147        let attributed = node_struct_bytes
148            .saturating_add(leaf_payload_bytes)
149            .saturating_add(extension_payload_bytes)
150            .saturating_add(operation_payload_bytes)
151            .saturating_add(state_hash_overhead_bytes);
152        let container_and_metadata_bytes = total_retained_bytes.saturating_sub(attributed);
153        let dominant_driver = dominant_driver(
154            node_struct_bytes,
155            leaf_payload_bytes,
156            extension_payload_bytes,
157            operation_payload_bytes,
158            state_hash_overhead_bytes,
159            container_and_metadata_bytes,
160        );
161        Self {
162            strategy,
163            retained_unit_count,
164            retained_node_count,
165            node_struct_bytes,
166            leaf_payload_bytes,
167            extension_payload_bytes,
168            operation_payload_bytes,
169            state_hash_overhead_bytes,
170            container_and_metadata_bytes,
171            total_retained_bytes,
172            dominant_driver,
173        }
174    }
175}
176
177/// Side-by-side retained-memory comparison across the three strategies.
178#[derive(Debug, Clone, PartialEq, serde::Serialize)]
179pub struct PaneMemoryComparison {
180    /// Artifact schema version.
181    pub schema_version: u16,
182    /// No-history floor: a single live tree.
183    pub baseline: PaneMemoryStrategyFootprint,
184    /// Production checkpointed timeline.
185    pub checkpointed: PaneMemoryStrategyFootprint,
186    /// Structurally shared persistent store.
187    pub persistent: PaneMemoryStrategyFootprint,
188    /// `persistent.total / checkpointed.total` (`<1` ⇒ persistent is leaner).
189    pub persistent_over_checkpointed: f64,
190    /// `checkpointed.total / baseline.total` (the multiplicative cost of history).
191    pub checkpointed_over_baseline: f64,
192    /// Strategy retaining the fewest total bytes.
193    pub most_compact_strategy: PaneMemoryStrategy,
194    /// One-line human-readable takeaway naming each strategy's dominant driver.
195    pub summary: String,
196}
197
198/// Build a deterministic cross-strategy retained-memory comparison.
199///
200/// `live` is the current tree the caller would keep with no undo history
201/// (the baseline floor). `timeline` and `store` must have been driven through
202/// the *same* operation history as `live` for the comparison to be meaningful;
203/// the function itself is pure and simply reads each strategy's retained-state
204/// model.
205#[must_use]
206pub fn pane_memory_comparison(
207    live: &PaneTree,
208    timeline: &PaneInteractionTimeline,
209    store: &PaneVersionStore,
210) -> PaneMemoryComparison {
211    let baseline = baseline_footprint(live);
212    let checkpointed = checkpointed_footprint(timeline);
213    let persistent = persistent_footprint(store);
214
215    let persistent_over_checkpointed = ratio(
216        persistent.total_retained_bytes,
217        checkpointed.total_retained_bytes,
218    );
219    let checkpointed_over_baseline = ratio(
220        checkpointed.total_retained_bytes,
221        baseline.total_retained_bytes,
222    );
223    let most_compact_strategy = most_compact(&baseline, &checkpointed, &persistent);
224
225    let summary = format!(
226        "baseline: {} tree, {}B (driver {}); checkpointed: {} snapshots / {} entries, {}B (driver {}); \
227         persistent: {} versions, {}B (driver {}). persistent/checkpointed={:.2}x, checkpointed/baseline={:.2}x; \
228         most compact: {}.",
229        baseline.retained_unit_count,
230        baseline.total_retained_bytes,
231        baseline.dominant_driver.as_str(),
232        checkpointed.retained_unit_count,
233        timeline.entries.len(),
234        checkpointed.total_retained_bytes,
235        checkpointed.dominant_driver.as_str(),
236        persistent.retained_unit_count,
237        persistent.total_retained_bytes,
238        persistent.dominant_driver.as_str(),
239        persistent_over_checkpointed,
240        checkpointed_over_baseline,
241        most_compact_strategy.as_str(),
242    );
243
244    PaneMemoryComparison {
245        schema_version: PANE_MEMORY_TELEMETRY_SCHEMA_VERSION,
246        baseline,
247        checkpointed,
248        persistent,
249        persistent_over_checkpointed,
250        checkpointed_over_baseline,
251        most_compact_strategy,
252        summary,
253    }
254}
255
256/// Bytes of one `u64` state hash; the timeline stores a before/after pair per entry.
257const STATE_HASH_BYTES: usize = std::mem::size_of::<u64>();
258
259fn baseline_footprint(live: &PaneTree) -> PaneMemoryStrategyFootprint {
260    let snapshot = live.to_snapshot();
261    let (leaf_payload_bytes, extension_payload_bytes) = snapshot_payload_bytes(&snapshot);
262    let node_struct_bytes = snapshot
263        .nodes
264        .len()
265        .saturating_mul(std::mem::size_of::<PaneNodeRecord>());
266    let container = std::mem::size_of::<PaneTreeSnapshot>();
267    let total = node_struct_bytes
268        .saturating_add(leaf_payload_bytes)
269        .saturating_add(extension_payload_bytes)
270        .saturating_add(container);
271    PaneMemoryStrategyFootprint::assemble(
272        PaneMemoryStrategy::Baseline,
273        1,
274        snapshot.nodes.len(),
275        node_struct_bytes,
276        leaf_payload_bytes,
277        extension_payload_bytes,
278        0,
279        0,
280        total,
281    )
282}
283
284fn checkpointed_footprint(timeline: &PaneInteractionTimeline) -> PaneMemoryStrategyFootprint {
285    let diag = timeline.retention_diagnostics();
286    // The entry struct stores the two state hashes inline; surface that cost as
287    // its own class and attribute the rest of the entry struct to the op log.
288    let state_hash_overhead_bytes = diag.entry_count.saturating_mul(2 * STATE_HASH_BYTES);
289    let operation_payload_bytes = diag.retained_operation_payload_bytes.saturating_add(
290        diag.estimated_entry_struct_bytes
291            .saturating_sub(state_hash_overhead_bytes),
292    );
293    PaneMemoryStrategyFootprint::assemble(
294        PaneMemoryStrategy::Checkpointed,
295        diag.retained_snapshot_count,
296        diag.retained_snapshot_node_count,
297        diag.estimated_snapshot_struct_bytes,
298        diag.retained_leaf_payload_bytes,
299        diag.retained_extension_payload_bytes,
300        operation_payload_bytes,
301        state_hash_overhead_bytes,
302        diag.estimated_total_retained_bytes,
303    )
304}
305
306fn persistent_footprint(store: &PaneVersionStore) -> PaneMemoryStrategyFootprint {
307    let r = store.retention();
308    PaneMemoryStrategyFootprint::assemble(
309        PaneMemoryStrategy::Persistent,
310        r.version_count,
311        r.distinct_node_count,
312        r.distinct_struct_bytes,
313        r.distinct_leaf_payload_bytes,
314        r.distinct_extension_payload_bytes,
315        0,
316        0,
317        r.estimated_total_retained_bytes,
318    )
319}
320
321/// Sum measured leaf-surface and extension payload bytes over a snapshot's nodes.
322fn snapshot_payload_bytes(snapshot: &PaneTreeSnapshot) -> (usize, usize) {
323    let mut leaf_payload = 0usize;
324    let mut extension_payload = 0usize;
325    for record in &snapshot.nodes {
326        extension_payload =
327            extension_payload.saturating_add(string_map_payload_bytes(&record.extensions));
328        if let PaneNodeKind::Leaf(leaf) = &record.kind {
329            leaf_payload = leaf_payload.saturating_add(leaf.surface_key.len());
330            extension_payload =
331                extension_payload.saturating_add(string_map_payload_bytes(&leaf.extensions));
332        }
333    }
334    (leaf_payload, extension_payload)
335}
336
337/// Pick the dominant retained-state class. Ties resolve to the earlier class in
338/// declaration order (node structs first) for deterministic output.
339fn dominant_driver(
340    node: usize,
341    leaf: usize,
342    ext: usize,
343    op: usize,
344    hash: usize,
345    container: usize,
346) -> PaneMemoryDriver {
347    let classes = [
348        (PaneMemoryDriver::NodeStructs, node),
349        (PaneMemoryDriver::LeafPayload, leaf),
350        (PaneMemoryDriver::ExtensionPayload, ext),
351        (PaneMemoryDriver::OperationLog, op),
352        (PaneMemoryDriver::StateHashes, hash),
353        (PaneMemoryDriver::ContainerMetadata, container),
354    ];
355    let mut best = classes[0];
356    for &candidate in &classes[1..] {
357        if candidate.1 > best.1 {
358            best = candidate;
359        }
360    }
361    best.0
362}
363
364fn ratio(numerator: usize, denominator: usize) -> f64 {
365    if denominator == 0 {
366        0.0
367    } else {
368        numerator as f64 / denominator as f64
369    }
370}
371
372fn most_compact(
373    baseline: &PaneMemoryStrategyFootprint,
374    checkpointed: &PaneMemoryStrategyFootprint,
375    persistent: &PaneMemoryStrategyFootprint,
376) -> PaneMemoryStrategy {
377    let mut best = baseline;
378    if checkpointed.total_retained_bytes < best.total_retained_bytes {
379        best = checkpointed;
380    }
381    if persistent.total_retained_bytes < best.total_retained_bytes {
382        best = persistent;
383    }
384    best.strategy
385}
386
387#[cfg(test)]
388mod tests {
389    use super::*;
390    use crate::pane::{PaneId, PaneLeaf, PaneOperation, PanePlacement, PaneSplitRatio, SplitAxis};
391    use crate::pane_persistent::VersionedPaneTree;
392
393    fn ratio_of(n: u32, d: u32) -> PaneSplitRatio {
394        PaneSplitRatio::new(n, d).expect("valid ratio")
395    }
396
397    /// Drive all three strategies through an identical operation history and
398    /// return `(live_tree, timeline, store)` ready to compare.
399    fn drive_workload() -> (PaneTree, PaneInteractionTimeline, PaneVersionStore) {
400        let mut ops: Vec<PaneOperation> = vec![
401            PaneOperation::SplitLeaf {
402                target: PaneId::MIN,
403                axis: SplitAxis::Horizontal,
404                ratio: ratio_of(1, 1),
405                placement: PanePlacement::ExistingFirst,
406                new_leaf: PaneLeaf::new("b"),
407            },
408            PaneOperation::SplitLeaf {
409                target: PaneId::MIN,
410                axis: SplitAxis::Vertical,
411                ratio: ratio_of(2, 1),
412                placement: PanePlacement::ExistingFirst,
413                new_leaf: PaneLeaf::new("c"),
414            },
415        ];
416        // Resize storm on the split created by the second op (id 4).
417        let split = PaneId::new(4).expect("valid id");
418        for n in 1..=6u32 {
419            ops.push(PaneOperation::SetSplitRatio {
420                split,
421                ratio: ratio_of(n, 1),
422            });
423        }
424
425        let mut tree = PaneTree::singleton("root");
426        let mut timeline = PaneInteractionTimeline::default();
427        let mut store = PaneVersionStore::new(VersionedPaneTree::singleton("root"));
428        for (i, op) in ops.iter().enumerate() {
429            let id = i as u64;
430            timeline
431                .apply_and_record(&mut tree, id, id, op.clone())
432                .expect("timeline apply");
433            store.apply(op).expect("store apply");
434        }
435        (tree, timeline, store)
436    }
437
438    fn assert_footprint_sums(f: &PaneMemoryStrategyFootprint) {
439        let class_sum = f.node_struct_bytes
440            + f.leaf_payload_bytes
441            + f.extension_payload_bytes
442            + f.operation_payload_bytes
443            + f.state_hash_overhead_bytes
444            + f.container_and_metadata_bytes;
445        assert_eq!(
446            class_sum, f.total_retained_bytes,
447            "{:?} class decomposition must sum to the authoritative total",
448            f.strategy
449        );
450    }
451
452    #[test]
453    fn comparison_is_deterministic() {
454        let (t1, tl1, s1) = drive_workload();
455        let (t2, tl2, s2) = drive_workload();
456        assert_eq!(
457            pane_memory_comparison(&t1, &tl1, &s1),
458            pane_memory_comparison(&t2, &tl2, &s2)
459        );
460    }
461
462    #[test]
463    fn every_footprint_decomposition_is_faithful() {
464        let (tree, timeline, store) = drive_workload();
465        let cmp = pane_memory_comparison(&tree, &timeline, &store);
466        assert_footprint_sums(&cmp.baseline);
467        assert_footprint_sums(&cmp.checkpointed);
468        assert_footprint_sums(&cmp.persistent);
469        assert_eq!(cmp.schema_version, PANE_MEMORY_TELEMETRY_SCHEMA_VERSION);
470    }
471
472    #[test]
473    fn baseline_is_the_floor_and_history_costs_more() {
474        let (tree, timeline, store) = drive_workload();
475        let cmp = pane_memory_comparison(&tree, &timeline, &store);
476        // Retaining history (either way) never costs less than one live tree.
477        assert!(cmp.checkpointed.total_retained_bytes > cmp.baseline.total_retained_bytes);
478        assert!(cmp.persistent.total_retained_bytes > cmp.baseline.total_retained_bytes);
479        assert_eq!(cmp.most_compact_strategy, PaneMemoryStrategy::Baseline);
480        assert!(cmp.checkpointed_over_baseline > 1.0);
481    }
482
483    #[test]
484    fn only_checkpointed_pays_state_hash_and_op_log_overhead() {
485        let (tree, timeline, store) = drive_workload();
486        let cmp = pane_memory_comparison(&tree, &timeline, &store);
487        // The persistent store navigates by index: no operation log, no hashes.
488        assert_eq!(cmp.persistent.state_hash_overhead_bytes, 0);
489        assert_eq!(cmp.persistent.operation_payload_bytes, 0);
490        assert_eq!(cmp.baseline.state_hash_overhead_bytes, 0);
491        assert_eq!(cmp.baseline.operation_payload_bytes, 0);
492        // 8 ops × 2 hashes × 8 bytes.
493        assert_eq!(
494            cmp.checkpointed.state_hash_overhead_bytes,
495            timeline.entries.len() * 2 * STATE_HASH_BYTES
496        );
497        assert!(cmp.checkpointed.state_hash_overhead_bytes > 0);
498    }
499
500    #[test]
501    fn checkpointed_defers_nodes_while_persistent_materializes_versions() {
502        let (tree, timeline, store) = drive_workload();
503        let cmp = pane_memory_comparison(&tree, &timeline, &store);
504        // The two history strategies sit on opposite sides of the memory/replay
505        // trade. For a short history (below the checkpoint interval) the
506        // checkpointed path retains almost no snapshot nodes — it leans on its
507        // operation log + state hashes and reconstructs by replay — whereas the
508        // persistent store materializes every version's distinct nodes so it can
509        // navigate in O(1).
510        assert!(cmp.persistent.retained_node_count > cmp.checkpointed.retained_node_count);
511        assert!(cmp.checkpointed.operation_payload_bytes > 0);
512        assert_eq!(cmp.persistent.operation_payload_bytes, 0);
513        // The comparison ratios are populated and finite.
514        assert!(cmp.persistent_over_checkpointed > 0.0);
515        assert!(cmp.persistent_over_checkpointed.is_finite());
516    }
517
518    #[test]
519    fn dominant_driver_prefers_earlier_class_on_ties() {
520        // Equal node and leaf bytes ⇒ node structs win (earlier in order).
521        assert_eq!(
522            dominant_driver(10, 10, 0, 0, 0, 0),
523            PaneMemoryDriver::NodeStructs
524        );
525        // A strictly larger later class wins outright.
526        assert_eq!(
527            dominant_driver(1, 1, 1, 99, 1, 1),
528            PaneMemoryDriver::OperationLog
529        );
530    }
531}