1use crate::pane::PaneInteractionTimeline;
48use crate::pane_memory::PaneMemoryStrategy;
49use crate::pane_persistent::PaneVersionStore;
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
54pub struct PaneRetentionBudget {
55 pub max_retained_bytes: usize,
57 pub max_retained_units: usize,
60}
61
62impl PaneRetentionBudget {
63 #[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 #[must_use]
74 pub const fn unbounded() -> Self {
75 Self::new(0, 0)
76 }
77
78 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
89pub struct PaneRetentionPolicy {
90 pub budget: PaneRetentionBudget,
92 pub conservative_debug: bool,
96}
97
98impl PaneRetentionPolicy {
99 #[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 #[must_use]
110 pub const fn unbounded() -> Self {
111 Self {
112 budget: PaneRetentionBudget::unbounded(),
113 conservative_debug: false,
114 }
115 }
116
117 #[must_use]
120 pub const fn conservative(mut self) -> Self {
121 self.conservative_debug = true;
122 self
123 }
124}
125
126#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
128pub enum PaneRetentionOutcome {
129 WithinBudget,
131 PrunedToFit,
133 ConservativeHold,
135 FloorReached,
138}
139
140impl PaneRetentionOutcome {
141 #[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#[derive(Debug, Clone, PartialEq, serde::Serialize)]
155pub struct PaneRetentionDecision {
156 pub strategy: PaneMemoryStrategy,
158 pub budget: PaneRetentionBudget,
160 pub conservative_debug: bool,
162 pub units_before: usize,
164 pub units_after: usize,
166 pub units_pruned: usize,
168 pub bytes_before: usize,
170 pub bytes_after: usize,
172 pub current_state_hash: u64,
175 pub outcome: PaneRetentionOutcome,
177 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
228fn 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
245pub 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 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
311pub 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 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 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 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 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 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 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 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 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 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 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 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 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 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}