1use crate::pane::{PaneOperation, PaneOperationFamily};
35use crate::pane_memory::PaneMemoryStrategy;
36use crate::pane_retention::PaneRetentionPolicy;
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
41pub struct PaneWorkloadProfile {
42 pub operation_count: usize,
44 pub local_operation_count: usize,
47 pub peak_ops_per_sec: u32,
49 pub history_required: bool,
52}
53
54impl PaneWorkloadProfile {
55 #[must_use]
57 pub const fn new(
58 operation_count: usize,
59 local_operation_count: usize,
60 peak_ops_per_sec: u32,
61 history_required: bool,
62 ) -> Self {
63 Self {
64 operation_count,
65 local_operation_count,
66 peak_ops_per_sec,
67 history_required,
68 }
69 }
70
71 #[must_use]
74 pub fn observe(ops: &[PaneOperation], peak_ops_per_sec: u32, history_required: bool) -> Self {
75 let local_operation_count = ops
76 .iter()
77 .filter(|op| op.family() == PaneOperationFamily::Local)
78 .count();
79 Self::new(
80 ops.len(),
81 local_operation_count,
82 peak_ops_per_sec,
83 history_required,
84 )
85 }
86
87 #[must_use]
89 pub const fn local_fraction_pct(self) -> u32 {
90 if self.operation_count == 0 {
91 return 0;
92 }
93 ((self.local_operation_count.saturating_mul(100)) / self.operation_count) as u32
94 }
95}
96
97#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
99pub enum PaneStrategyReason {
100 ForcedOverride,
102 ConservativeFallback,
104 NoHistoryRequired,
106 ResizeDominatedBurst,
108 GeneralDefault,
110 HysteresisHold,
112}
113
114impl PaneStrategyReason {
115 #[must_use]
117 pub const fn as_str(self) -> &'static str {
118 match self {
119 Self::ForcedOverride => "forced_override",
120 Self::ConservativeFallback => "conservative_fallback",
121 Self::NoHistoryRequired => "no_history_required",
122 Self::ResizeDominatedBurst => "resize_dominated_burst",
123 Self::GeneralDefault => "general_default",
124 Self::HysteresisHold => "hysteresis_hold",
125 }
126 }
127}
128
129#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
137pub struct PaneExecutionPolicy {
138 pub forced_strategy: Option<PaneMemoryStrategy>,
140 pub conservative: bool,
142 pub persistent_min_operations: usize,
144 pub persistent_local_fraction_pct: u32,
146 pub persistent_burst_ops_per_sec: u32,
148 pub hysteresis_pct: u32,
151 pub retention: PaneRetentionPolicy,
153}
154
155impl PaneExecutionPolicy {
156 pub const DEFAULT_PERSISTENT_MIN_OPERATIONS: usize = 64;
158 pub const DEFAULT_PERSISTENT_LOCAL_FRACTION_PCT: u32 = 80;
160 pub const DEFAULT_PERSISTENT_BURST_OPS_PER_SEC: u32 = 60;
162 pub const DEFAULT_HYSTERESIS_PCT: u32 = 10;
164
165 #[must_use]
167 pub const fn adaptive(retention: PaneRetentionPolicy) -> Self {
168 Self {
169 forced_strategy: None,
170 conservative: false,
171 persistent_min_operations: Self::DEFAULT_PERSISTENT_MIN_OPERATIONS,
172 persistent_local_fraction_pct: Self::DEFAULT_PERSISTENT_LOCAL_FRACTION_PCT,
173 persistent_burst_ops_per_sec: Self::DEFAULT_PERSISTENT_BURST_OPS_PER_SEC,
174 hysteresis_pct: Self::DEFAULT_HYSTERESIS_PCT,
175 retention,
176 }
177 }
178
179 #[must_use]
181 pub const fn conservative(mut self) -> Self {
182 self.conservative = true;
183 self
184 }
185
186 #[must_use]
188 pub const fn forcing(mut self, strategy: PaneMemoryStrategy) -> Self {
189 self.forced_strategy = Some(strategy);
190 self
191 }
192
193 #[must_use]
195 pub fn select(&self, profile: PaneWorkloadProfile) -> PaneExecutionDecision {
196 let (strategy, reason, forced) = self.decide(profile);
197 self.decision(strategy, reason, forced, profile)
198 }
199
200 #[must_use]
205 pub fn reselect(
206 &self,
207 profile: PaneWorkloadProfile,
208 previous: PaneMemoryStrategy,
209 ) -> PaneExecutionDecision {
210 if self.forced_strategy.is_some() || self.conservative {
211 return self.select(profile);
212 }
213 let (fresh, reason, forced) = self.decide(profile);
214 if fresh == previous {
215 return self.decision(fresh, reason, forced, profile);
216 }
217 if matches!(reason, PaneStrategyReason::NoHistoryRequired)
221 || previous == PaneMemoryStrategy::Baseline
222 {
223 return self.decision(fresh, reason, forced, profile);
224 }
225 let decisive = if fresh == PaneMemoryStrategy::Persistent {
226 self.favors_persistent(profile, self.hysteresis_pct)
228 } else {
229 profile.operation_count < self.persistent_min_operations
232 || profile.peak_ops_per_sec < self.persistent_burst_ops_per_sec
233 || profile.local_fraction_pct()
234 < self
235 .persistent_local_fraction_pct
236 .saturating_sub(self.hysteresis_pct)
237 };
238 if decisive {
239 self.decision(fresh, reason, forced, profile)
240 } else {
241 self.decision(previous, PaneStrategyReason::HysteresisHold, false, profile)
242 }
243 }
244
245 fn decide(
246 &self,
247 profile: PaneWorkloadProfile,
248 ) -> (PaneMemoryStrategy, PaneStrategyReason, bool) {
249 if let Some(forced) = self.forced_strategy {
250 return (forced, PaneStrategyReason::ForcedOverride, true);
251 }
252 if self.conservative {
253 return (
254 PaneMemoryStrategy::Checkpointed,
255 PaneStrategyReason::ConservativeFallback,
256 true,
257 );
258 }
259 if !profile.history_required {
260 return (
261 PaneMemoryStrategy::Baseline,
262 PaneStrategyReason::NoHistoryRequired,
263 false,
264 );
265 }
266 if self.favors_persistent(profile, 0) {
267 return (
268 PaneMemoryStrategy::Persistent,
269 PaneStrategyReason::ResizeDominatedBurst,
270 false,
271 );
272 }
273 (
274 PaneMemoryStrategy::Checkpointed,
275 PaneStrategyReason::GeneralDefault,
276 false,
277 )
278 }
279
280 fn favors_persistent(&self, profile: PaneWorkloadProfile, local_margin_pct: u32) -> bool {
281 profile.operation_count >= self.persistent_min_operations
282 && profile.local_fraction_pct()
283 >= self
284 .persistent_local_fraction_pct
285 .saturating_add(local_margin_pct)
286 && profile.peak_ops_per_sec >= self.persistent_burst_ops_per_sec
287 }
288
289 fn decision(
290 &self,
291 strategy: PaneMemoryStrategy,
292 reason: PaneStrategyReason,
293 forced: bool,
294 profile: PaneWorkloadProfile,
295 ) -> PaneExecutionDecision {
296 let log = format!(
297 "execution[{}] {}: ops={} local={}% burst={}/s history={} (thresholds: min_ops={} local>={}% burst>={}/s hysteresis={}%{}); retention budget bytes={} units={}",
298 strategy.as_str(),
299 reason.as_str(),
300 profile.operation_count,
301 profile.local_fraction_pct(),
302 profile.peak_ops_per_sec,
303 profile.history_required,
304 self.persistent_min_operations,
305 self.persistent_local_fraction_pct,
306 self.persistent_burst_ops_per_sec,
307 self.hysteresis_pct,
308 if forced { ", forced" } else { "" },
309 self.retention.budget.max_retained_bytes,
310 self.retention.budget.max_retained_units,
311 );
312 PaneExecutionDecision {
313 strategy,
314 reason,
315 forced,
316 profile,
317 retention: self.retention,
318 log,
319 }
320 }
321}
322
323#[derive(Debug, Clone, PartialEq, serde::Serialize)]
325pub struct PaneExecutionDecision {
326 pub strategy: PaneMemoryStrategy,
328 pub reason: PaneStrategyReason,
330 pub forced: bool,
332 pub profile: PaneWorkloadProfile,
334 pub retention: PaneRetentionPolicy,
336 pub log: String,
338}
339
340#[cfg(test)]
341mod tests {
342 use super::*;
343 use crate::pane::{
344 PaneId, PaneInteractionTimeline, PaneLeaf, PaneOperation, PanePlacement, PaneSplitRatio,
345 PaneTree, SplitAxis,
346 };
347 use crate::pane_persistent::{PaneVersionStore, VersionedPaneTree};
348
349 fn policy() -> PaneExecutionPolicy {
350 PaneExecutionPolicy::adaptive(PaneRetentionPolicy::bounded(500_000, 64))
351 }
352
353 fn resize_storm_profile() -> PaneWorkloadProfile {
354 PaneWorkloadProfile::new(512, 512, 240, true)
356 }
357
358 fn mixed_profile() -> PaneWorkloadProfile {
359 PaneWorkloadProfile::new(384, 211, 40, true)
361 }
362
363 #[test]
364 fn selection_is_deterministic() {
365 let p = policy();
366 let profile = resize_storm_profile();
367 assert_eq!(p.select(profile), p.select(profile));
368 }
369
370 #[test]
371 fn resize_storm_selects_persistent() {
372 let d = policy().select(resize_storm_profile());
373 assert_eq!(d.strategy, PaneMemoryStrategy::Persistent);
374 assert_eq!(d.reason, PaneStrategyReason::ResizeDominatedBurst);
375 assert!(!d.forced);
376 }
377
378 #[test]
379 fn mixed_workload_falls_back_to_checkpointed() {
380 let d = policy().select(mixed_profile());
381 assert_eq!(d.strategy, PaneMemoryStrategy::Checkpointed);
382 assert_eq!(d.reason, PaneStrategyReason::GeneralDefault);
383 }
384
385 #[test]
386 fn no_history_selects_baseline() {
387 let profile = PaneWorkloadProfile::new(512, 512, 240, false);
388 let d = policy().select(profile);
389 assert_eq!(d.strategy, PaneMemoryStrategy::Baseline);
390 assert_eq!(d.reason, PaneStrategyReason::NoHistoryRequired);
391 }
392
393 #[test]
394 fn shallow_resize_storm_stays_checkpointed() {
395 let profile = PaneWorkloadProfile::new(32, 32, 240, true);
397 assert_eq!(
398 policy().select(profile).strategy,
399 PaneMemoryStrategy::Checkpointed
400 );
401 }
402
403 #[test]
404 fn forced_strategy_overrides_adaptation() {
405 let forced = policy().forcing(PaneMemoryStrategy::Persistent);
407 let d = forced.select(mixed_profile());
408 assert_eq!(d.strategy, PaneMemoryStrategy::Persistent);
409 assert_eq!(d.reason, PaneStrategyReason::ForcedOverride);
410 assert!(d.forced);
411 }
412
413 #[test]
414 fn conservative_forces_checkpointed_even_on_resize_storm() {
415 let conservative = policy().conservative();
416 let d = conservative.select(resize_storm_profile());
417 assert_eq!(d.strategy, PaneMemoryStrategy::Checkpointed);
418 assert_eq!(d.reason, PaneStrategyReason::ConservativeFallback);
419 assert!(d.forced);
420 }
421
422 #[test]
423 fn hysteresis_prevents_thrashing_near_threshold() {
424 let p = policy();
425 let at_threshold = PaneWorkloadProfile::new(512, 410, 240, true); assert_eq!(
429 p.select(at_threshold).strategy,
430 PaneMemoryStrategy::Persistent
431 );
432 assert_eq!(
433 p.reselect(at_threshold, PaneMemoryStrategy::Checkpointed)
434 .strategy,
435 PaneMemoryStrategy::Checkpointed,
436 "should not enter persistent without clearing the hysteresis margin"
437 );
438
439 let decisive = PaneWorkloadProfile::new(512, 487, 240, true); let entered = p.reselect(decisive, PaneMemoryStrategy::Checkpointed);
442 assert_eq!(entered.strategy, PaneMemoryStrategy::Persistent);
443 assert_eq!(entered.reason, PaneStrategyReason::ResizeDominatedBurst);
444
445 let mild_dip = PaneWorkloadProfile::new(512, 384, 240, true); let held = p.reselect(mild_dip, PaneMemoryStrategy::Persistent);
448 assert_eq!(held.strategy, PaneMemoryStrategy::Persistent);
449 assert_eq!(held.reason, PaneStrategyReason::HysteresisHold);
450
451 let decisive_drop = PaneWorkloadProfile::new(512, 332, 240, true); assert_eq!(
454 p.reselect(decisive_drop, PaneMemoryStrategy::Persistent)
455 .strategy,
456 PaneMemoryStrategy::Checkpointed
457 );
458 }
459
460 #[test]
461 fn observe_classifies_local_operations() {
462 let ops = vec![
463 PaneOperation::SetSplitRatio {
464 split: PaneId::new(2).unwrap(),
465 ratio: PaneSplitRatio::new(1, 1).unwrap(),
466 },
467 PaneOperation::SetSplitRatio {
468 split: PaneId::new(2).unwrap(),
469 ratio: PaneSplitRatio::new(2, 1).unwrap(),
470 },
471 PaneOperation::CloseNode {
472 target: PaneId::new(3).unwrap(),
473 },
474 ];
475 let profile = PaneWorkloadProfile::observe(&ops, 120, true);
476 assert_eq!(profile.operation_count, 3);
477 assert_eq!(profile.local_operation_count, 2);
478 assert_eq!(profile.local_fraction_pct(), 66);
479 }
480
481 #[test]
485 fn strategy_choice_never_diverges_behavior() {
486 let ratio = |n, d| PaneSplitRatio::new(n, d).expect("ratio");
487 let mut ops = vec![
488 PaneOperation::SplitLeaf {
489 target: PaneId::MIN,
490 axis: SplitAxis::Horizontal,
491 ratio: ratio(1, 1),
492 placement: PanePlacement::ExistingFirst,
493 new_leaf: PaneLeaf::new("b"),
494 },
495 PaneOperation::SplitLeaf {
496 target: PaneId::MIN,
497 axis: SplitAxis::Vertical,
498 ratio: ratio(2, 1),
499 placement: PanePlacement::ExistingFirst,
500 new_leaf: PaneLeaf::new("c"),
501 },
502 ];
503 let split = PaneId::new(4).expect("id");
504 for n in 1..=12u32 {
505 ops.push(PaneOperation::SetSplitRatio {
506 split,
507 ratio: ratio(n % 5 + 1, 1),
508 });
509 }
510
511 let mut baseline = PaneTree::singleton("root");
513 for (i, op) in ops.iter().enumerate() {
514 baseline
515 .apply_operation_conservative(i as u64 + 1, op.clone())
516 .expect("baseline apply");
517 }
518 let mut tree = PaneTree::singleton("root");
520 let mut timeline = PaneInteractionTimeline::default();
521 for (i, op) in ops.iter().enumerate() {
522 let id = i as u64;
523 timeline
524 .apply_and_record(&mut tree, id, id, op.clone())
525 .expect("timeline apply");
526 }
527 let mut store = PaneVersionStore::new(VersionedPaneTree::singleton("root"));
529 for op in &ops {
530 store.apply(op).expect("store apply");
531 }
532
533 let baseline_hash = baseline.state_hash();
534 let timeline_hash = tree.state_hash();
535 let store_hash = store.current().state_hash().expect("hash");
536 assert_eq!(baseline_hash, timeline_hash);
537 assert_eq!(baseline_hash, store_hash);
538
539 let profile = PaneWorkloadProfile::observe(&ops, 240, true);
541 let strategy = policy().select(profile).strategy;
542 assert!(matches!(
543 strategy,
544 PaneMemoryStrategy::Baseline
545 | PaneMemoryStrategy::Checkpointed
546 | PaneMemoryStrategy::Persistent
547 ));
548 }
549}