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            store.set_max_versions(keep);
283        }
284    }
285
286    let units_after = store.version_count();
287    let bytes_after = store.retention().estimated_total_retained_bytes;
288    let outcome = classify(
289        units_before.saturating_sub(units_after),
290        units_after,
291        bytes_after,
292        policy.budget,
293    );
294    PaneRetentionDecision::build(
295        strategy,
296        policy,
297        units_before,
298        units_after,
299        bytes_before,
300        bytes_after,
301        current_state_hash,
302        outcome,
303    )
304}
305
306/// Apply a retention policy to a checkpointed [`PaneInteractionTimeline`],
307/// pruning the oldest entries (advancing the replay baseline and re-basing
308/// checkpoints) to fit the budget while preserving the head state.
309///
310/// Note the timeline's irreducible floor is its baseline snapshot: pruning
311/// entries advances the baseline, so a byte budget below the baseline-snapshot
312/// cost reports [`PaneRetentionOutcome::FloorReached`] rather than discarding
313/// the head.
314pub fn apply_to_timeline(
315    timeline: &mut PaneInteractionTimeline,
316    policy: &PaneRetentionPolicy,
317) -> PaneRetentionDecision {
318    let strategy = PaneMemoryStrategy::Checkpointed;
319    let bytes_before = timeline
320        .retention_diagnostics()
321        .estimated_total_retained_bytes;
322    let units_before = timeline.entries.len();
323    // The current state is baseline + entries[..cursor], so the preserved hash
324    // must come from the cursor position — `entries.last()` is the head of the
325    // full history, which is NOT the current state after an undo.
326    let current_state_hash = if timeline.cursor == 0 {
327        timeline
328            .entries
329            .first()
330            .map_or(0, |entry| entry.before_hash)
331    } else {
332        timeline
333            .entries
334            .get(timeline.cursor - 1)
335            .map_or(0, |entry| entry.after_hash)
336    };
337
338    if policy.conservative_debug {
339        let outcome = if policy.budget.is_exceeded_by(bytes_before, units_before) {
340            PaneRetentionOutcome::ConservativeHold
341        } else {
342            PaneRetentionOutcome::WithinBudget
343        };
344        return PaneRetentionDecision::build(
345            strategy,
346            policy,
347            units_before,
348            units_before,
349            bytes_before,
350            bytes_before,
351            current_state_hash,
352            outcome,
353        );
354    }
355
356    if policy.budget.max_retained_units != 0 {
357        timeline.set_max_entries(policy.budget.max_retained_units);
358    }
359    if policy.budget.max_retained_bytes != 0 {
360        while timeline.entries.len() > 1
361            && timeline
362                .retention_diagnostics()
363                .estimated_total_retained_bytes
364                > policy.budget.max_retained_bytes
365        {
366            let keep = timeline.entries.len() - 1;
367            if timeline.set_max_entries(keep) == 0 {
368                // No progress: the baseline is missing/unreplayable, or the
369                // cursor pins every remaining entry. Stop (classified below)
370                // rather than spinning forever.
371                break;
372            }
373        }
374    }
375
376    let units_after = timeline.entries.len();
377    let bytes_after = timeline
378        .retention_diagnostics()
379        .estimated_total_retained_bytes;
380    let outcome = classify(
381        units_before.saturating_sub(units_after),
382        units_after,
383        bytes_after,
384        policy.budget,
385    );
386    PaneRetentionDecision::build(
387        strategy,
388        policy,
389        units_before,
390        units_after,
391        bytes_before,
392        bytes_after,
393        current_state_hash,
394        outcome,
395    )
396}
397
398#[cfg(test)]
399mod tests {
400    use super::*;
401    use crate::pane::{
402        PaneId, PaneInteractionTimeline, PaneLeaf, PaneOperation, PanePlacement, PaneSplitRatio,
403        PaneTree, SplitAxis,
404    };
405    use crate::pane_persistent::VersionedPaneTree;
406
407    fn ratio_of(n: u32, d: u32) -> PaneSplitRatio {
408        PaneSplitRatio::new(n, d).expect("valid ratio")
409    }
410
411    /// A workload: two splits then `storm` resize ops on split id 4.
412    fn workload(storm: u32) -> Vec<PaneOperation> {
413        let mut ops = vec![
414            PaneOperation::SplitLeaf {
415                target: PaneId::MIN,
416                axis: SplitAxis::Horizontal,
417                ratio: ratio_of(1, 1),
418                placement: PanePlacement::ExistingFirst,
419                new_leaf: PaneLeaf::new("b"),
420            },
421            PaneOperation::SplitLeaf {
422                target: PaneId::MIN,
423                axis: SplitAxis::Vertical,
424                ratio: ratio_of(2, 1),
425                placement: PanePlacement::ExistingFirst,
426                new_leaf: PaneLeaf::new("c"),
427            },
428        ];
429        let split = PaneId::new(4).expect("valid id");
430        for n in 1..=storm {
431            ops.push(PaneOperation::SetSplitRatio {
432                split,
433                ratio: ratio_of(n % 7 + 1, 1),
434            });
435        }
436        ops
437    }
438
439    fn build_store(storm: u32) -> PaneVersionStore {
440        let mut store = PaneVersionStore::new(VersionedPaneTree::singleton("root"));
441        for op in &workload(storm) {
442            store.apply(op).expect("store apply");
443        }
444        store
445    }
446
447    #[test]
448    fn version_store_prune_after_undo_preserves_current() {
449        // 2 splits + 30 resizes = 32 ops -> 33 versions; undo back to cursor 2.
450        let mut store = build_store(30);
451        for _ in 0..30 {
452            assert!(store.undo());
453        }
454        let current_hash = store.current().state_hash().expect("hash");
455        let pruned = store.set_max_versions(8);
456        // Only versions strictly before the cursor may be pruned.
457        assert_eq!(pruned, 2);
458        assert_eq!(
459            store.current().state_hash().expect("hash"),
460            current_hash,
461            "pruning must never discard the current (undone-to) version"
462        );
463        // The redo tail survives intact.
464        let mut redos = 0;
465        while store.redo() {
466            redos += 1;
467        }
468        assert_eq!(redos, 30);
469    }
470
471    #[test]
472    fn timeline_prune_after_undo_preserves_current_state() {
473        let mut tree = PaneTree::singleton("root");
474        let mut timeline = PaneInteractionTimeline::default();
475        for (i, op) in workload(20).iter().enumerate() {
476            let id = i as u64;
477            timeline
478                .apply_and_record(&mut tree, id, id, op.clone())
479                .expect("apply");
480        }
481        for _ in 0..20 {
482            timeline.undo(&mut tree).expect("undo");
483        }
484        let current_hash = tree.state_hash();
485        let decision = apply_to_timeline(&mut timeline, &PaneRetentionPolicy::bounded(0, 4));
486        assert_eq!(
487            decision.current_state_hash, current_hash,
488            "preserved hash must be the cursor state, not the head of history"
489        );
490        // Cursor-pinned entries survive: redo back to the head still works.
491        let mut redos = 0;
492        while timeline.redo(&mut tree).expect("redo") {
493            redos += 1;
494        }
495        assert_eq!(redos, 20);
496    }
497
498    #[test]
499    fn timeline_byte_budget_terminates_when_pruning_cannot_progress() {
500        let mut timeline = build_timeline(10);
501        // A hand-assembled/deserialized timeline can lack a replayable
502        // baseline; the byte-budget loop must stop rather than spin forever.
503        timeline.baseline = None;
504        let before = timeline.entries.len();
505        let decision = apply_to_timeline(&mut timeline, &PaneRetentionPolicy::bounded(1, 0));
506        assert_eq!(timeline.entries.len(), before);
507        assert_eq!(decision.units_pruned, 0);
508    }
509
510    fn build_timeline(storm: u32) -> PaneInteractionTimeline {
511        let mut tree = PaneTree::singleton("root");
512        let mut timeline = PaneInteractionTimeline::default();
513        for (i, op) in workload(storm).iter().enumerate() {
514            let id = i as u64;
515            timeline
516                .apply_and_record(&mut tree, id, id, op.clone())
517                .expect("timeline apply");
518        }
519        timeline
520    }
521
522    #[test]
523    fn within_budget_is_a_noop() {
524        let mut store = build_store(20);
525        let before = store.version_count();
526        let hash = store.current().state_hash().unwrap();
527        let d = apply_to_version_store(&mut store, &PaneRetentionPolicy::bounded(usize::MAX, 0));
528        assert_eq!(d.outcome, PaneRetentionOutcome::WithinBudget);
529        assert_eq!(d.units_pruned, 0);
530        assert_eq!(store.version_count(), before);
531        assert_eq!(d.current_state_hash, hash);
532    }
533
534    #[test]
535    fn unit_budget_caps_versions_and_preserves_head() {
536        let mut store = build_store(40);
537        let head = store.current().state_hash().unwrap();
538        let d = apply_to_version_store(&mut store, &PaneRetentionPolicy::bounded(0, 8));
539        assert!(store.version_count() <= 8);
540        assert_eq!(d.outcome, PaneRetentionOutcome::PrunedToFit);
541        assert!(d.units_pruned > 0);
542        // The live state is untouched — only undo history was discarded.
543        assert_eq!(store.current().state_hash().unwrap(), head);
544        assert_eq!(d.current_state_hash, head);
545    }
546
547    #[test]
548    fn byte_budget_prunes_to_fit_and_preserves_head() {
549        let mut store = build_store(40);
550        let head = store.current().state_hash().unwrap();
551        let full = store.retention().estimated_total_retained_bytes;
552        // Budget at a quarter of the unbounded footprint forces pruning.
553        let d = apply_to_version_store(&mut store, &PaneRetentionPolicy::bounded(full / 4, 0));
554        assert_eq!(d.outcome, PaneRetentionOutcome::PrunedToFit);
555        assert!(d.bytes_after <= full / 4);
556        assert!(d.bytes_after < d.bytes_before);
557        assert!(store.version_count() >= 1);
558        assert_eq!(store.current().state_hash().unwrap(), head);
559    }
560
561    #[test]
562    fn conservative_debug_holds_over_budget_state() {
563        let mut store = build_store(30);
564        let before = store.version_count();
565        let bytes = store.retention().estimated_total_retained_bytes;
566        // Tiny budget that would normally prune hard, but conservative mode holds.
567        let policy = PaneRetentionPolicy::bounded(1, 1).conservative();
568        let d = apply_to_version_store(&mut store, &policy);
569        assert_eq!(d.outcome, PaneRetentionOutcome::ConservativeHold);
570        assert_eq!(d.units_pruned, 0);
571        assert_eq!(store.version_count(), before);
572        assert_eq!(d.bytes_after, bytes);
573    }
574
575    #[test]
576    fn floor_is_never_breached() {
577        let mut store = build_store(30);
578        let head = store.current().state_hash().unwrap();
579        // An impossible 1-byte budget prunes to the single live version, no more.
580        let d = apply_to_version_store(&mut store, &PaneRetentionPolicy::bounded(1, 0));
581        assert_eq!(d.outcome, PaneRetentionOutcome::FloorReached);
582        assert_eq!(store.version_count(), 1);
583        assert_eq!(store.current().state_hash().unwrap(), head);
584        assert_eq!(d.current_state_hash, head);
585    }
586
587    #[test]
588    fn timeline_policy_prunes_entries_and_preserves_head() {
589        let mut timeline = build_timeline(40);
590        let head = timeline.entries.last().unwrap().after_hash;
591        let d = apply_to_timeline(&mut timeline, &PaneRetentionPolicy::bounded(0, 8));
592        assert!(timeline.entries.len() <= 8);
593        assert!(d.units_pruned > 0);
594        assert_eq!(timeline.entries.last().unwrap().after_hash, head);
595        assert_eq!(d.current_state_hash, head);
596        assert_eq!(d.strategy, PaneMemoryStrategy::Checkpointed);
597    }
598
599    #[test]
600    fn decisions_are_deterministic() {
601        let mut a = build_store(40);
602        let mut b = build_store(40);
603        let policy = PaneRetentionPolicy::bounded(50_000, 16);
604        assert_eq!(
605            apply_to_version_store(&mut a, &policy),
606            apply_to_version_store(&mut b, &policy)
607        );
608    }
609
610    #[test]
611    fn unbounded_policy_never_prunes() {
612        let mut store = build_store(25);
613        let before = store.version_count();
614        let d = apply_to_version_store(&mut store, &PaneRetentionPolicy::unbounded());
615        assert_eq!(d.outcome, PaneRetentionOutcome::WithinBudget);
616        assert_eq!(store.version_count(), before);
617    }
618}