use crate::pane::PaneInteractionTimeline;
use crate::pane_memory::PaneMemoryStrategy;
use crate::pane_persistent::PaneVersionStore;
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
pub struct PaneRetentionBudget {
pub max_retained_bytes: usize,
pub max_retained_units: usize,
}
impl PaneRetentionBudget {
#[must_use]
pub const fn new(max_retained_bytes: usize, max_retained_units: usize) -> Self {
Self {
max_retained_bytes,
max_retained_units,
}
}
#[must_use]
pub const fn unbounded() -> Self {
Self::new(0, 0)
}
#[must_use]
pub const fn is_exceeded_by(self, bytes: usize, units: usize) -> bool {
(self.max_retained_bytes != 0 && bytes > self.max_retained_bytes)
|| (self.max_retained_units != 0 && units > self.max_retained_units)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
pub struct PaneRetentionPolicy {
pub budget: PaneRetentionBudget,
pub conservative_debug: bool,
}
impl PaneRetentionPolicy {
#[must_use]
pub const fn bounded(max_retained_bytes: usize, max_retained_units: usize) -> Self {
Self {
budget: PaneRetentionBudget::new(max_retained_bytes, max_retained_units),
conservative_debug: false,
}
}
#[must_use]
pub const fn unbounded() -> Self {
Self {
budget: PaneRetentionBudget::unbounded(),
conservative_debug: false,
}
}
#[must_use]
pub const fn conservative(mut self) -> Self {
self.conservative_debug = true;
self
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
pub enum PaneRetentionOutcome {
WithinBudget,
PrunedToFit,
ConservativeHold,
FloorReached,
}
impl PaneRetentionOutcome {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::WithinBudget => "within_budget",
Self::PrunedToFit => "pruned_to_fit",
Self::ConservativeHold => "conservative_hold",
Self::FloorReached => "floor_reached",
}
}
}
#[derive(Debug, Clone, PartialEq, serde::Serialize)]
pub struct PaneRetentionDecision {
pub strategy: PaneMemoryStrategy,
pub budget: PaneRetentionBudget,
pub conservative_debug: bool,
pub units_before: usize,
pub units_after: usize,
pub units_pruned: usize,
pub bytes_before: usize,
pub bytes_after: usize,
pub current_state_hash: u64,
pub outcome: PaneRetentionOutcome,
pub log: String,
}
impl PaneRetentionDecision {
#[allow(clippy::too_many_arguments)]
fn build(
strategy: PaneMemoryStrategy,
policy: &PaneRetentionPolicy,
units_before: usize,
units_after: usize,
bytes_before: usize,
bytes_after: usize,
current_state_hash: u64,
outcome: PaneRetentionOutcome,
) -> Self {
let units_pruned = units_before.saturating_sub(units_after);
let log = format!(
"retention[{}] {}: units {}->{} (pruned {}), bytes {}->{} (budget bytes={} units={}{}); head_hash={:#018x}",
strategy.as_str(),
outcome.as_str(),
units_before,
units_after,
units_pruned,
bytes_before,
bytes_after,
policy.budget.max_retained_bytes,
policy.budget.max_retained_units,
if policy.conservative_debug {
", conservative-debug"
} else {
""
},
current_state_hash,
);
Self {
strategy,
budget: policy.budget,
conservative_debug: policy.conservative_debug,
units_before,
units_after,
units_pruned,
bytes_before,
bytes_after,
current_state_hash,
outcome,
log,
}
}
}
fn classify(
units_pruned: usize,
units_after: usize,
bytes_after: usize,
budget: PaneRetentionBudget,
) -> PaneRetentionOutcome {
let byte_over = budget.max_retained_bytes != 0 && bytes_after > budget.max_retained_bytes;
if byte_over && units_after <= 1 {
PaneRetentionOutcome::FloorReached
} else if units_pruned > 0 {
PaneRetentionOutcome::PrunedToFit
} else {
PaneRetentionOutcome::WithinBudget
}
}
pub fn apply_to_version_store(
store: &mut PaneVersionStore,
policy: &PaneRetentionPolicy,
) -> PaneRetentionDecision {
let strategy = PaneMemoryStrategy::Persistent;
let bytes_before = store.retention().estimated_total_retained_bytes;
let units_before = store.version_count();
let current_state_hash = store.current().state_hash().unwrap_or(0);
if policy.conservative_debug {
let outcome = if policy.budget.is_exceeded_by(bytes_before, units_before) {
PaneRetentionOutcome::ConservativeHold
} else {
PaneRetentionOutcome::WithinBudget
};
return PaneRetentionDecision::build(
strategy,
policy,
units_before,
units_before,
bytes_before,
bytes_before,
current_state_hash,
outcome,
);
}
if policy.budget.max_retained_units != 0 {
store.set_max_versions(policy.budget.max_retained_units);
}
if policy.budget.max_retained_bytes != 0 {
while store.version_count() > 1
&& store.retention().estimated_total_retained_bytes > policy.budget.max_retained_bytes
{
let keep = store.version_count() - 1;
if store.set_max_versions(keep) == 0 {
break;
}
}
}
let units_after = store.version_count();
let bytes_after = store.retention().estimated_total_retained_bytes;
let outcome = classify(
units_before.saturating_sub(units_after),
units_after,
bytes_after,
policy.budget,
);
PaneRetentionDecision::build(
strategy,
policy,
units_before,
units_after,
bytes_before,
bytes_after,
current_state_hash,
outcome,
)
}
pub fn apply_to_timeline(
timeline: &mut PaneInteractionTimeline,
policy: &PaneRetentionPolicy,
) -> PaneRetentionDecision {
let strategy = PaneMemoryStrategy::Checkpointed;
let bytes_before = timeline
.retention_diagnostics()
.estimated_total_retained_bytes;
let units_before = timeline.entries.len();
let current_state_hash = if timeline.cursor == 0 {
timeline
.entries
.first()
.map_or(0, |entry| entry.before_hash)
} else {
timeline
.entries
.get(timeline.cursor - 1)
.map_or(0, |entry| entry.after_hash)
};
if policy.conservative_debug {
let outcome = if policy.budget.is_exceeded_by(bytes_before, units_before) {
PaneRetentionOutcome::ConservativeHold
} else {
PaneRetentionOutcome::WithinBudget
};
return PaneRetentionDecision::build(
strategy,
policy,
units_before,
units_before,
bytes_before,
bytes_before,
current_state_hash,
outcome,
);
}
if policy.budget.max_retained_units != 0 {
timeline.set_max_entries(policy.budget.max_retained_units);
}
if policy.budget.max_retained_bytes != 0 {
while timeline.entries.len() > 1
&& timeline
.retention_diagnostics()
.estimated_total_retained_bytes
> policy.budget.max_retained_bytes
{
let keep = timeline.entries.len() - 1;
if timeline.set_max_entries(keep) == 0 {
break;
}
}
}
let units_after = timeline.entries.len();
let bytes_after = timeline
.retention_diagnostics()
.estimated_total_retained_bytes;
let outcome = classify(
units_before.saturating_sub(units_after),
units_after,
bytes_after,
policy.budget,
);
PaneRetentionDecision::build(
strategy,
policy,
units_before,
units_after,
bytes_before,
bytes_after,
current_state_hash,
outcome,
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::pane::{
PaneId, PaneInteractionTimeline, PaneLeaf, PaneOperation, PanePlacement, PaneSplitRatio,
PaneTree, SplitAxis,
};
use crate::pane_persistent::VersionedPaneTree;
fn ratio_of(n: u32, d: u32) -> PaneSplitRatio {
PaneSplitRatio::new(n, d).expect("valid ratio")
}
fn workload(storm: u32) -> Vec<PaneOperation> {
let mut ops = vec![
PaneOperation::SplitLeaf {
target: PaneId::MIN,
axis: SplitAxis::Horizontal,
ratio: ratio_of(1, 1),
placement: PanePlacement::ExistingFirst,
new_leaf: PaneLeaf::new("b"),
},
PaneOperation::SplitLeaf {
target: PaneId::MIN,
axis: SplitAxis::Vertical,
ratio: ratio_of(2, 1),
placement: PanePlacement::ExistingFirst,
new_leaf: PaneLeaf::new("c"),
},
];
let split = PaneId::new(4).expect("valid id");
for n in 1..=storm {
ops.push(PaneOperation::SetSplitRatio {
split,
ratio: ratio_of(n % 7 + 1, 1),
});
}
ops
}
fn build_store(storm: u32) -> PaneVersionStore {
let mut store = PaneVersionStore::new(VersionedPaneTree::singleton("root"));
for op in &workload(storm) {
store.apply(op).expect("store apply");
}
store
}
#[test]
fn version_store_prune_after_undo_preserves_current() {
let mut store = build_store(30);
for _ in 0..30 {
assert!(store.undo());
}
let current_hash = store.current().state_hash().expect("hash");
let pruned = store.set_max_versions(8);
assert_eq!(pruned, 2);
assert_eq!(
store.current().state_hash().expect("hash"),
current_hash,
"pruning must never discard the current (undone-to) version"
);
let mut redos = 0;
while store.redo() {
redos += 1;
}
assert_eq!(redos, 30);
}
#[test]
fn timeline_prune_after_undo_preserves_current_state() {
let mut tree = PaneTree::singleton("root");
let mut timeline = PaneInteractionTimeline::default();
for (i, op) in workload(20).iter().enumerate() {
let id = i as u64;
timeline
.apply_and_record(&mut tree, id, id, op.clone())
.expect("apply");
}
for _ in 0..20 {
timeline.undo(&mut tree).expect("undo");
}
let current_hash = tree.state_hash();
let decision = apply_to_timeline(&mut timeline, &PaneRetentionPolicy::bounded(0, 4));
assert_eq!(
decision.current_state_hash, current_hash,
"preserved hash must be the cursor state, not the head of history"
);
let mut redos = 0;
while timeline.redo(&mut tree).expect("redo") {
redos += 1;
}
assert_eq!(redos, 20);
}
#[test]
fn version_store_byte_budget_terminates_when_cursor_pins_history() {
let mut store = build_store(12);
while store.undo() {}
let current_hash = store.current().state_hash().expect("hash");
let before = store.version_count();
let decision = apply_to_version_store(&mut store, &PaneRetentionPolicy::bounded(1, 0));
assert_eq!(store.version_count(), before);
assert_eq!(decision.units_pruned, 0);
assert_eq!(store.current().state_hash().expect("hash"), current_hash);
}
#[test]
fn timeline_byte_budget_terminates_when_pruning_cannot_progress() {
let mut timeline = build_timeline(10);
timeline.baseline = None;
let before = timeline.entries.len();
let decision = apply_to_timeline(&mut timeline, &PaneRetentionPolicy::bounded(1, 0));
assert_eq!(timeline.entries.len(), before);
assert_eq!(decision.units_pruned, 0);
}
fn build_timeline(storm: u32) -> PaneInteractionTimeline {
let mut tree = PaneTree::singleton("root");
let mut timeline = PaneInteractionTimeline::default();
for (i, op) in workload(storm).iter().enumerate() {
let id = i as u64;
timeline
.apply_and_record(&mut tree, id, id, op.clone())
.expect("timeline apply");
}
timeline
}
#[test]
fn within_budget_is_a_noop() {
let mut store = build_store(20);
let before = store.version_count();
let hash = store.current().state_hash().unwrap();
let d = apply_to_version_store(&mut store, &PaneRetentionPolicy::bounded(usize::MAX, 0));
assert_eq!(d.outcome, PaneRetentionOutcome::WithinBudget);
assert_eq!(d.units_pruned, 0);
assert_eq!(store.version_count(), before);
assert_eq!(d.current_state_hash, hash);
}
#[test]
fn unit_budget_caps_versions_and_preserves_head() {
let mut store = build_store(40);
let head = store.current().state_hash().unwrap();
let d = apply_to_version_store(&mut store, &PaneRetentionPolicy::bounded(0, 8));
assert!(store.version_count() <= 8);
assert_eq!(d.outcome, PaneRetentionOutcome::PrunedToFit);
assert!(d.units_pruned > 0);
assert_eq!(store.current().state_hash().unwrap(), head);
assert_eq!(d.current_state_hash, head);
}
#[test]
fn byte_budget_prunes_to_fit_and_preserves_head() {
let mut store = build_store(40);
let head = store.current().state_hash().unwrap();
let full = store.retention().estimated_total_retained_bytes;
let d = apply_to_version_store(&mut store, &PaneRetentionPolicy::bounded(full / 4, 0));
assert_eq!(d.outcome, PaneRetentionOutcome::PrunedToFit);
assert!(d.bytes_after <= full / 4);
assert!(d.bytes_after < d.bytes_before);
assert!(store.version_count() >= 1);
assert_eq!(store.current().state_hash().unwrap(), head);
}
#[test]
fn conservative_debug_holds_over_budget_state() {
let mut store = build_store(30);
let before = store.version_count();
let bytes = store.retention().estimated_total_retained_bytes;
let policy = PaneRetentionPolicy::bounded(1, 1).conservative();
let d = apply_to_version_store(&mut store, &policy);
assert_eq!(d.outcome, PaneRetentionOutcome::ConservativeHold);
assert_eq!(d.units_pruned, 0);
assert_eq!(store.version_count(), before);
assert_eq!(d.bytes_after, bytes);
}
#[test]
fn floor_is_never_breached() {
let mut store = build_store(30);
let head = store.current().state_hash().unwrap();
let d = apply_to_version_store(&mut store, &PaneRetentionPolicy::bounded(1, 0));
assert_eq!(d.outcome, PaneRetentionOutcome::FloorReached);
assert_eq!(store.version_count(), 1);
assert_eq!(store.current().state_hash().unwrap(), head);
assert_eq!(d.current_state_hash, head);
}
#[test]
fn timeline_policy_prunes_entries_and_preserves_head() {
let mut timeline = build_timeline(40);
let head = timeline.entries.last().unwrap().after_hash;
let d = apply_to_timeline(&mut timeline, &PaneRetentionPolicy::bounded(0, 8));
assert!(timeline.entries.len() <= 8);
assert!(d.units_pruned > 0);
assert_eq!(timeline.entries.last().unwrap().after_hash, head);
assert_eq!(d.current_state_hash, head);
assert_eq!(d.strategy, PaneMemoryStrategy::Checkpointed);
}
#[test]
fn decisions_are_deterministic() {
let mut a = build_store(40);
let mut b = build_store(40);
let policy = PaneRetentionPolicy::bounded(50_000, 16);
assert_eq!(
apply_to_version_store(&mut a, &policy),
apply_to_version_store(&mut b, &policy)
);
}
#[test]
fn unbounded_policy_never_prunes() {
let mut store = build_store(25);
let before = store.version_count();
let d = apply_to_version_store(&mut store, &PaneRetentionPolicy::unbounded());
assert_eq!(d.outcome, PaneRetentionOutcome::WithinBudget);
assert_eq!(store.version_count(), before);
}
}