Skip to main content

ftui_layout/
pane_retention.rs

1//! Bounded retention & pruning policy for pane history (`bd-25wj7.2`).
2//!
3//! The asymptotic replay-speed work ([`pane_persistent`](crate::pane_persistent))
4//! and the production checkpointed timeline
5//! ([`PaneInteractionTimeline`](crate::pane::PaneInteractionTimeline)) both buy
6//! undo/redo by *retaining state* — versions, operation-log entries, and
7//! checkpoint snapshots. Left unbounded that retained state grows without limit
8//! (the memory telemetry in [`pane_memory`](crate::pane_memory) quantifies the
9//! drivers: node structs dominate every strategy). This module turns "memory is
10//! bounded by hope" into "memory is bounded by an explicit, deterministic,
11//! observable policy".
12//!
13//! # Policy
14//!
15//! A [`PaneRetentionPolicy`] is an explicit ceiling on two axes:
16//!
17//! * `max_retained_bytes` — a modeled-byte budget (using the same retained-state
18//!   byte model as [`pane_memory`](crate::pane_memory)), and
19//! * `max_retained_units` — a hard cap on retained *units* (persistent versions
20//!   or timeline entries).
21//!
22//! `0` on either axis means unbounded on that axis. The policy applies both: it
23//! first installs the unit cap, then prunes the oldest history one unit at a
24//! time until the byte budget is met — always keeping the newest unit, so the
25//! **current state is never discarded** (its state hash is preserved and carried
26//! in the [`PaneRetentionDecision`]).
27//!
28//! # Determinism & fallback
29//!
30//! Pruning is a pure function of the policy and the current retained state, so it
31//! is deterministic and reproducible. Two fallbacks are explicit and observable:
32//!
33//! * **Conservative debugging** ([`PaneRetentionPolicy::conservative`]) disables
34//!   pruning entirely. Over-budget state is *held*, not discarded, and the
35//!   decision reports [`PaneRetentionOutcome::ConservativeHold`] so an operator
36//!   knows exactly what would otherwise be dropped.
37//! * **Floor** — when even a single retained unit exceeds the byte budget (e.g.
38//!   the timeline's irreducible baseline snapshot), pruning stops at one unit and
39//!   reports [`PaneRetentionOutcome::FloorReached`]: the live state is never
40//!   sacrificed to a byte budget.
41//!
42//! Every application returns a [`PaneRetentionDecision`] — a serializable
43//! telemetry record plus a human-readable `log` line — covering the retained-state
44//! totals before/after, units pruned, the preserved current-state hash, and the
45//! outcome.
46
47use crate::pane::PaneInteractionTimeline;
48use crate::pane_memory::PaneMemoryStrategy;
49use crate::pane_persistent::PaneVersionStore;
50
51/// Explicit memory ceiling for retained pane history. `0` on an axis is
52/// unbounded for that axis.
53#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
54pub struct PaneRetentionBudget {
55    /// Modeled-byte ceiling on total retained state (`0` = unbounded).
56    pub max_retained_bytes: usize,
57    /// Hard cap on retained units — versions (store) or entries (timeline)
58    /// (`0` = unbounded).
59    pub max_retained_units: usize,
60}
61
62impl PaneRetentionBudget {
63    /// A budget bounded on both axes.
64    #[must_use]
65    pub const fn new(max_retained_bytes: usize, max_retained_units: usize) -> Self {
66        Self {
67            max_retained_bytes,
68            max_retained_units,
69        }
70    }
71
72    /// An unbounded budget (no ceiling on either axis).
73    #[must_use]
74    pub const fn unbounded() -> Self {
75        Self::new(0, 0)
76    }
77
78    /// Whether `bytes`/`units` exceed this budget on any bounded axis.
79    #[must_use]
80    pub const fn is_exceeded_by(self, bytes: usize, units: usize) -> bool {
81        (self.max_retained_bytes != 0 && bytes > self.max_retained_bytes)
82            || (self.max_retained_units != 0 && units > self.max_retained_units)
83    }
84}
85
86/// A bounded-retention policy: a [`PaneRetentionBudget`] plus a conservative
87/// debugging override that disables pruning.
88#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
89pub struct PaneRetentionPolicy {
90    /// The memory ceiling.
91    pub budget: PaneRetentionBudget,
92    /// When `true`, pruning is disabled: over-budget state is retained (held)
93    /// for debugging rather than discarded, and the decision reports
94    /// [`PaneRetentionOutcome::ConservativeHold`].
95    pub conservative_debug: bool,
96}
97
98impl PaneRetentionPolicy {
99    /// A pruning policy bounded by `max_retained_bytes` and `max_retained_units`.
100    #[must_use]
101    pub const fn bounded(max_retained_bytes: usize, max_retained_units: usize) -> Self {
102        Self {
103            budget: PaneRetentionBudget::new(max_retained_bytes, max_retained_units),
104            conservative_debug: false,
105        }
106    }
107
108    /// An unbounded policy — never prunes (the implicit debug default).
109    #[must_use]
110    pub const fn unbounded() -> Self {
111        Self {
112            budget: PaneRetentionBudget::unbounded(),
113            conservative_debug: false,
114        }
115    }
116
117    /// Return this policy in conservative-debug mode: pruning is disabled and
118    /// over-budget state is held rather than discarded.
119    #[must_use]
120    pub const fn conservative(mut self) -> Self {
121        self.conservative_debug = true;
122        self
123    }
124}
125
126/// The outcome of applying a [`PaneRetentionPolicy`].
127#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
128pub enum PaneRetentionOutcome {
129    /// Retained state was already within budget; nothing pruned.
130    WithinBudget,
131    /// Oldest history was pruned to bring retained state within budget.
132    PrunedToFit,
133    /// Over budget, but conservative-debug mode held all state (nothing pruned).
134    ConservativeHold,
135    /// Pruned to the minimum single retained unit, yet still over the byte
136    /// budget — the deterministic floor. The live state is never discarded.
137    FloorReached,
138}
139
140impl PaneRetentionOutcome {
141    /// Stable identifier for logs/artifacts.
142    #[must_use]
143    pub const fn as_str(self) -> &'static str {
144        match self {
145            Self::WithinBudget => "within_budget",
146            Self::PrunedToFit => "pruned_to_fit",
147            Self::ConservativeHold => "conservative_hold",
148            Self::FloorReached => "floor_reached",
149        }
150    }
151}
152
153/// Serializable record of one [`PaneRetentionPolicy`] application.
154#[derive(Debug, Clone, PartialEq, serde::Serialize)]
155pub struct PaneRetentionDecision {
156    /// Which retained-state substrate the policy was applied to.
157    pub strategy: PaneMemoryStrategy,
158    /// The budget that was enforced.
159    pub budget: PaneRetentionBudget,
160    /// Whether conservative-debug mode was in effect.
161    pub conservative_debug: bool,
162    /// Retained units before the decision.
163    pub units_before: usize,
164    /// Retained units after the decision.
165    pub units_after: usize,
166    /// Units pruned (`units_before - units_after`).
167    pub units_pruned: usize,
168    /// Modeled retained bytes before the decision.
169    pub bytes_before: usize,
170    /// Modeled retained bytes after the decision.
171    pub bytes_after: usize,
172    /// The current-state hash, preserved across the decision (proof that
173    /// pruning discarded only history, never the live state).
174    pub current_state_hash: u64,
175    /// The classified outcome.
176    pub outcome: PaneRetentionOutcome,
177    /// Human-readable one-line log.
178    pub log: String,
179}
180
181impl PaneRetentionDecision {
182    #[allow(clippy::too_many_arguments)]
183    fn build(
184        strategy: PaneMemoryStrategy,
185        policy: &PaneRetentionPolicy,
186        units_before: usize,
187        units_after: usize,
188        bytes_before: usize,
189        bytes_after: usize,
190        current_state_hash: u64,
191        outcome: PaneRetentionOutcome,
192    ) -> Self {
193        let units_pruned = units_before.saturating_sub(units_after);
194        let log = format!(
195            "retention[{}] {}: units {}->{} (pruned {}), bytes {}->{} (budget bytes={} units={}{}); head_hash={:#018x}",
196            strategy.as_str(),
197            outcome.as_str(),
198            units_before,
199            units_after,
200            units_pruned,
201            bytes_before,
202            bytes_after,
203            policy.budget.max_retained_bytes,
204            policy.budget.max_retained_units,
205            if policy.conservative_debug {
206                ", conservative-debug"
207            } else {
208                ""
209            },
210            current_state_hash,
211        );
212        Self {
213            strategy,
214            budget: policy.budget,
215            conservative_debug: policy.conservative_debug,
216            units_before,
217            units_after,
218            units_pruned,
219            bytes_before,
220            bytes_after,
221            current_state_hash,
222            outcome,
223            log,
224        }
225    }
226}
227
228/// Classify the outcome after pruning has run (or been skipped).
229fn classify(
230    units_pruned: usize,
231    units_after: usize,
232    bytes_after: usize,
233    budget: PaneRetentionBudget,
234) -> PaneRetentionOutcome {
235    let byte_over = budget.max_retained_bytes != 0 && bytes_after > budget.max_retained_bytes;
236    if byte_over && units_after <= 1 {
237        PaneRetentionOutcome::FloorReached
238    } else if units_pruned > 0 {
239        PaneRetentionOutcome::PrunedToFit
240    } else {
241        PaneRetentionOutcome::WithinBudget
242    }
243}
244
245/// Apply a retention policy to a persistent [`PaneVersionStore`], pruning the
246/// oldest versions to fit the budget while preserving the current version.
247pub fn apply_to_version_store(
248    store: &mut PaneVersionStore,
249    policy: &PaneRetentionPolicy,
250) -> PaneRetentionDecision {
251    let strategy = PaneMemoryStrategy::Persistent;
252    let bytes_before = store.retention().estimated_total_retained_bytes;
253    let units_before = store.version_count();
254    let current_state_hash = store.current().state_hash().unwrap_or(0);
255
256    if policy.conservative_debug {
257        let outcome = if policy.budget.is_exceeded_by(bytes_before, units_before) {
258            PaneRetentionOutcome::ConservativeHold
259        } else {
260            PaneRetentionOutcome::WithinBudget
261        };
262        return PaneRetentionDecision::build(
263            strategy,
264            policy,
265            units_before,
266            units_before,
267            bytes_before,
268            bytes_before,
269            current_state_hash,
270            outcome,
271        );
272    }
273
274    if policy.budget.max_retained_units != 0 {
275        store.set_max_versions(policy.budget.max_retained_units);
276    }
277    if policy.budget.max_retained_bytes != 0 {
278        while store.version_count() > 1
279            && store.retention().estimated_total_retained_bytes > policy.budget.max_retained_bytes
280        {
281            let keep = store.version_count() - 1;
282            if store.set_max_versions(keep) == 0 {
283                // No progress: the cursor pins every remaining version (the
284                // user has undone to/near the oldest state). Stop — classified
285                // below — rather than spinning forever.
286                break;
287            }
288        }
289    }
290
291    let units_after = store.version_count();
292    let bytes_after = store.retention().estimated_total_retained_bytes;
293    let outcome = classify(
294        units_before.saturating_sub(units_after),
295        units_after,
296        bytes_after,
297        policy.budget,
298    );
299    PaneRetentionDecision::build(
300        strategy,
301        policy,
302        units_before,
303        units_after,
304        bytes_before,
305        bytes_after,
306        current_state_hash,
307        outcome,
308    )
309}
310
311/// Apply a retention policy to a checkpointed [`PaneInteractionTimeline`],
312/// pruning the oldest entries (advancing the replay baseline and re-basing
313/// checkpoints) to fit the budget while preserving the head state.
314///
315/// Note the timeline's irreducible floor is its baseline snapshot: pruning
316/// entries advances the baseline, so a byte budget below the baseline-snapshot
317/// cost reports [`PaneRetentionOutcome::FloorReached`] rather than discarding
318/// the head.
319pub fn apply_to_timeline(
320    timeline: &mut PaneInteractionTimeline,
321    policy: &PaneRetentionPolicy,
322) -> PaneRetentionDecision {
323    let strategy = PaneMemoryStrategy::Checkpointed;
324    let bytes_before = timeline
325        .retention_diagnostics()
326        .estimated_total_retained_bytes;
327    let units_before = timeline.entries.len();
328    // The current state is baseline + entries[..cursor], so the preserved hash
329    // must come from the cursor position — `entries.last()` is the head of the
330    // full history, which is NOT the current state after an undo.
331    let current_state_hash = if timeline.cursor == 0 {
332        timeline
333            .entries
334            .first()
335            .map_or(0, |entry| entry.before_hash)
336    } else {
337        timeline
338            .entries
339            .get(timeline.cursor - 1)
340            .map_or(0, |entry| entry.after_hash)
341    };
342
343    if policy.conservative_debug {
344        let outcome = if policy.budget.is_exceeded_by(bytes_before, units_before) {
345            PaneRetentionOutcome::ConservativeHold
346        } else {
347            PaneRetentionOutcome::WithinBudget
348        };
349        return PaneRetentionDecision::build(
350            strategy,
351            policy,
352            units_before,
353            units_before,
354            bytes_before,
355            bytes_before,
356            current_state_hash,
357            outcome,
358        );
359    }
360
361    if policy.budget.max_retained_units != 0 {
362        timeline.set_max_entries(policy.budget.max_retained_units);
363    }
364    if policy.budget.max_retained_bytes != 0 {
365        while timeline.entries.len() > 1
366            && timeline
367                .retention_diagnostics()
368                .estimated_total_retained_bytes
369                > policy.budget.max_retained_bytes
370        {
371            let keep = timeline.entries.len() - 1;
372            if timeline.set_max_entries(keep) == 0 {
373                // No progress: the baseline is missing/unreplayable, or the
374                // cursor pins every remaining entry. Stop (classified below)
375                // rather than spinning forever.
376                break;
377            }
378        }
379    }
380
381    let units_after = timeline.entries.len();
382    let bytes_after = timeline
383        .retention_diagnostics()
384        .estimated_total_retained_bytes;
385    let outcome = classify(
386        units_before.saturating_sub(units_after),
387        units_after,
388        bytes_after,
389        policy.budget,
390    );
391    PaneRetentionDecision::build(
392        strategy,
393        policy,
394        units_before,
395        units_after,
396        bytes_before,
397        bytes_after,
398        current_state_hash,
399        outcome,
400    )
401}
402
403#[cfg(test)]
404mod tests {
405    use super::*;
406    use crate::pane::{
407        PaneId, PaneInteractionTimeline, PaneLeaf, PaneOperation, PanePlacement, PaneSplitRatio,
408        PaneTree, SplitAxis,
409    };
410    use crate::pane_persistent::VersionedPaneTree;
411
412    fn ratio_of(n: u32, d: u32) -> PaneSplitRatio {
413        PaneSplitRatio::new(n, d).expect("valid ratio")
414    }
415
416    /// A workload: two splits then `storm` resize ops on split id 4.
417    fn workload(storm: u32) -> Vec<PaneOperation> {
418        let mut ops = vec![
419            PaneOperation::SplitLeaf {
420                target: PaneId::MIN,
421                axis: SplitAxis::Horizontal,
422                ratio: ratio_of(1, 1),
423                placement: PanePlacement::ExistingFirst,
424                new_leaf: PaneLeaf::new("b"),
425            },
426            PaneOperation::SplitLeaf {
427                target: PaneId::MIN,
428                axis: SplitAxis::Vertical,
429                ratio: ratio_of(2, 1),
430                placement: PanePlacement::ExistingFirst,
431                new_leaf: PaneLeaf::new("c"),
432            },
433        ];
434        let split = PaneId::new(4).expect("valid id");
435        for n in 1..=storm {
436            ops.push(PaneOperation::SetSplitRatio {
437                split,
438                ratio: ratio_of(n % 7 + 1, 1),
439            });
440        }
441        ops
442    }
443
444    fn build_store(storm: u32) -> PaneVersionStore {
445        let mut store = PaneVersionStore::new(VersionedPaneTree::singleton("root"));
446        for op in &workload(storm) {
447            store.apply(op).expect("store apply");
448        }
449        store
450    }
451
452    #[test]
453    fn version_store_prune_after_undo_preserves_current() {
454        // 2 splits + 30 resizes = 32 ops -> 33 versions; undo back to cursor 2.
455        let mut store = build_store(30);
456        for _ in 0..30 {
457            assert!(store.undo());
458        }
459        let current_hash = store.current().state_hash().expect("hash");
460        let pruned = store.set_max_versions(8);
461        // Only versions strictly before the cursor may be pruned.
462        assert_eq!(pruned, 2);
463        assert_eq!(
464            store.current().state_hash().expect("hash"),
465            current_hash,
466            "pruning must never discard the current (undone-to) version"
467        );
468        // The redo tail survives intact.
469        let mut redos = 0;
470        while store.redo() {
471            redos += 1;
472        }
473        assert_eq!(redos, 30);
474    }
475
476    #[test]
477    fn timeline_prune_after_undo_preserves_current_state() {
478        let mut tree = PaneTree::singleton("root");
479        let mut timeline = PaneInteractionTimeline::default();
480        for (i, op) in workload(20).iter().enumerate() {
481            let id = i as u64;
482            timeline
483                .apply_and_record(&mut tree, id, id, op.clone())
484                .expect("apply");
485        }
486        for _ in 0..20 {
487            timeline.undo(&mut tree).expect("undo");
488        }
489        let current_hash = tree.state_hash();
490        let decision = apply_to_timeline(&mut timeline, &PaneRetentionPolicy::bounded(0, 4));
491        assert_eq!(
492            decision.current_state_hash, current_hash,
493            "preserved hash must be the cursor state, not the head of history"
494        );
495        // Cursor-pinned entries survive: redo back to the head still works.
496        let mut redos = 0;
497        while timeline.redo(&mut tree).expect("redo") {
498            redos += 1;
499        }
500        assert_eq!(redos, 20);
501    }
502
503    #[test]
504    fn version_store_byte_budget_terminates_when_cursor_pins_history() {
505        // Undone to cursor 0, every version is at/after the cursor, so the
506        // byte-budget loop can make no progress and must stop, not spin.
507        let mut store = build_store(12);
508        while store.undo() {}
509        let current_hash = store.current().state_hash().expect("hash");
510        let before = store.version_count();
511        let decision = apply_to_version_store(&mut store, &PaneRetentionPolicy::bounded(1, 0));
512        assert_eq!(store.version_count(), before);
513        assert_eq!(decision.units_pruned, 0);
514        assert_eq!(store.current().state_hash().expect("hash"), current_hash);
515    }
516
517    #[test]
518    fn timeline_byte_budget_terminates_when_pruning_cannot_progress() {
519        let mut timeline = build_timeline(10);
520        // A hand-assembled/deserialized timeline can lack a replayable
521        // baseline; the byte-budget loop must stop rather than spin forever.
522        timeline.baseline = None;
523        let before = timeline.entries.len();
524        let decision = apply_to_timeline(&mut timeline, &PaneRetentionPolicy::bounded(1, 0));
525        assert_eq!(timeline.entries.len(), before);
526        assert_eq!(decision.units_pruned, 0);
527    }
528
529    fn build_timeline(storm: u32) -> PaneInteractionTimeline {
530        let mut tree = PaneTree::singleton("root");
531        let mut timeline = PaneInteractionTimeline::default();
532        for (i, op) in workload(storm).iter().enumerate() {
533            let id = i as u64;
534            timeline
535                .apply_and_record(&mut tree, id, id, op.clone())
536                .expect("timeline apply");
537        }
538        timeline
539    }
540
541    #[test]
542    fn within_budget_is_a_noop() {
543        let mut store = build_store(20);
544        let before = store.version_count();
545        let hash = store.current().state_hash().unwrap();
546        let d = apply_to_version_store(&mut store, &PaneRetentionPolicy::bounded(usize::MAX, 0));
547        assert_eq!(d.outcome, PaneRetentionOutcome::WithinBudget);
548        assert_eq!(d.units_pruned, 0);
549        assert_eq!(store.version_count(), before);
550        assert_eq!(d.current_state_hash, hash);
551    }
552
553    #[test]
554    fn unit_budget_caps_versions_and_preserves_head() {
555        let mut store = build_store(40);
556        let head = store.current().state_hash().unwrap();
557        let d = apply_to_version_store(&mut store, &PaneRetentionPolicy::bounded(0, 8));
558        assert!(store.version_count() <= 8);
559        assert_eq!(d.outcome, PaneRetentionOutcome::PrunedToFit);
560        assert!(d.units_pruned > 0);
561        // The live state is untouched — only undo history was discarded.
562        assert_eq!(store.current().state_hash().unwrap(), head);
563        assert_eq!(d.current_state_hash, head);
564    }
565
566    #[test]
567    fn byte_budget_prunes_to_fit_and_preserves_head() {
568        let mut store = build_store(40);
569        let head = store.current().state_hash().unwrap();
570        let full = store.retention().estimated_total_retained_bytes;
571        // Budget at a quarter of the unbounded footprint forces pruning.
572        let d = apply_to_version_store(&mut store, &PaneRetentionPolicy::bounded(full / 4, 0));
573        assert_eq!(d.outcome, PaneRetentionOutcome::PrunedToFit);
574        assert!(d.bytes_after <= full / 4);
575        assert!(d.bytes_after < d.bytes_before);
576        assert!(store.version_count() >= 1);
577        assert_eq!(store.current().state_hash().unwrap(), head);
578    }
579
580    #[test]
581    fn conservative_debug_holds_over_budget_state() {
582        let mut store = build_store(30);
583        let before = store.version_count();
584        let bytes = store.retention().estimated_total_retained_bytes;
585        // Tiny budget that would normally prune hard, but conservative mode holds.
586        let policy = PaneRetentionPolicy::bounded(1, 1).conservative();
587        let d = apply_to_version_store(&mut store, &policy);
588        assert_eq!(d.outcome, PaneRetentionOutcome::ConservativeHold);
589        assert_eq!(d.units_pruned, 0);
590        assert_eq!(store.version_count(), before);
591        assert_eq!(d.bytes_after, bytes);
592    }
593
594    #[test]
595    fn floor_is_never_breached() {
596        let mut store = build_store(30);
597        let head = store.current().state_hash().unwrap();
598        // An impossible 1-byte budget prunes to the single live version, no more.
599        let d = apply_to_version_store(&mut store, &PaneRetentionPolicy::bounded(1, 0));
600        assert_eq!(d.outcome, PaneRetentionOutcome::FloorReached);
601        assert_eq!(store.version_count(), 1);
602        assert_eq!(store.current().state_hash().unwrap(), head);
603        assert_eq!(d.current_state_hash, head);
604    }
605
606    #[test]
607    fn timeline_policy_prunes_entries_and_preserves_head() {
608        let mut timeline = build_timeline(40);
609        let head = timeline.entries.last().unwrap().after_hash;
610        let d = apply_to_timeline(&mut timeline, &PaneRetentionPolicy::bounded(0, 8));
611        assert!(timeline.entries.len() <= 8);
612        assert!(d.units_pruned > 0);
613        assert_eq!(timeline.entries.last().unwrap().after_hash, head);
614        assert_eq!(d.current_state_hash, head);
615        assert_eq!(d.strategy, PaneMemoryStrategy::Checkpointed);
616    }
617
618    #[test]
619    fn decisions_are_deterministic() {
620        let mut a = build_store(40);
621        let mut b = build_store(40);
622        let policy = PaneRetentionPolicy::bounded(50_000, 16);
623        assert_eq!(
624            apply_to_version_store(&mut a, &policy),
625            apply_to_version_store(&mut b, &policy)
626        );
627    }
628
629    #[test]
630    fn unbounded_policy_never_prunes() {
631        let mut store = build_store(25);
632        let before = store.version_count();
633        let d = apply_to_version_store(&mut store, &PaneRetentionPolicy::unbounded());
634        assert_eq!(d.outcome, PaneRetentionOutcome::WithinBudget);
635        assert_eq!(store.version_count(), before);
636    }
637}