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 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
306pub 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 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 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 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 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 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 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 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 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 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 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 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 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}