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 let burst_margin = self.persistent_burst_ops_per_sec / 10;
233 let ops_margin = self.persistent_min_operations / 10;
234 self.favors_persistent(profile, self.hysteresis_pct)
235 && profile.peak_ops_per_sec
236 >= self
237 .persistent_burst_ops_per_sec
238 .saturating_add(burst_margin)
239 && profile.operation_count
240 >= self.persistent_min_operations.saturating_add(ops_margin)
241 } else {
242 profile.operation_count < self.persistent_min_operations
245 || profile.peak_ops_per_sec < self.persistent_burst_ops_per_sec
246 || profile.local_fraction_pct()
247 < self
248 .persistent_local_fraction_pct
249 .saturating_sub(self.hysteresis_pct)
250 };
251 if decisive {
252 self.decision(fresh, reason, forced, profile)
253 } else {
254 self.decision(previous, PaneStrategyReason::HysteresisHold, false, profile)
255 }
256 }
257
258 fn decide(
259 &self,
260 profile: PaneWorkloadProfile,
261 ) -> (PaneMemoryStrategy, PaneStrategyReason, bool) {
262 if let Some(forced) = self.forced_strategy {
263 return (forced, PaneStrategyReason::ForcedOverride, true);
264 }
265 if self.conservative {
266 return (
267 PaneMemoryStrategy::Checkpointed,
268 PaneStrategyReason::ConservativeFallback,
269 true,
270 );
271 }
272 if !profile.history_required {
273 return (
274 PaneMemoryStrategy::Baseline,
275 PaneStrategyReason::NoHistoryRequired,
276 false,
277 );
278 }
279 if self.favors_persistent(profile, 0) {
280 return (
281 PaneMemoryStrategy::Persistent,
282 PaneStrategyReason::ResizeDominatedBurst,
283 false,
284 );
285 }
286 (
287 PaneMemoryStrategy::Checkpointed,
288 PaneStrategyReason::GeneralDefault,
289 false,
290 )
291 }
292
293 fn favors_persistent(&self, profile: PaneWorkloadProfile, local_margin_pct: u32) -> bool {
294 profile.operation_count >= self.persistent_min_operations
295 && profile.local_fraction_pct()
296 >= self
297 .persistent_local_fraction_pct
298 .saturating_add(local_margin_pct)
299 && profile.peak_ops_per_sec >= self.persistent_burst_ops_per_sec
300 }
301
302 fn decision(
303 &self,
304 strategy: PaneMemoryStrategy,
305 reason: PaneStrategyReason,
306 forced: bool,
307 profile: PaneWorkloadProfile,
308 ) -> PaneExecutionDecision {
309 let log = format!(
310 "execution[{}] {}: ops={} local={}% burst={}/s history={} (thresholds: min_ops={} local>={}% burst>={}/s hysteresis={}%{}); retention budget bytes={} units={}",
311 strategy.as_str(),
312 reason.as_str(),
313 profile.operation_count,
314 profile.local_fraction_pct(),
315 profile.peak_ops_per_sec,
316 profile.history_required,
317 self.persistent_min_operations,
318 self.persistent_local_fraction_pct,
319 self.persistent_burst_ops_per_sec,
320 self.hysteresis_pct,
321 if forced { ", forced" } else { "" },
322 self.retention.budget.max_retained_bytes,
323 self.retention.budget.max_retained_units,
324 );
325 PaneExecutionDecision {
326 strategy,
327 reason,
328 forced,
329 profile,
330 retention: self.retention,
331 log,
332 }
333 }
334}
335
336#[derive(Debug, Clone, PartialEq, serde::Serialize)]
338pub struct PaneExecutionDecision {
339 pub strategy: PaneMemoryStrategy,
341 pub reason: PaneStrategyReason,
343 pub forced: bool,
345 pub profile: PaneWorkloadProfile,
347 pub retention: PaneRetentionPolicy,
349 pub log: String,
351}
352
353#[cfg(test)]
354mod tests {
355 use super::*;
356 use crate::pane::{
357 PaneId, PaneInteractionTimeline, PaneLeaf, PaneOperation, PanePlacement, PaneSplitRatio,
358 PaneTree, SplitAxis,
359 };
360 use crate::pane_persistent::{PaneVersionStore, VersionedPaneTree};
361
362 fn policy() -> PaneExecutionPolicy {
363 PaneExecutionPolicy::adaptive(PaneRetentionPolicy::bounded(500_000, 64))
364 }
365
366 fn resize_storm_profile() -> PaneWorkloadProfile {
367 PaneWorkloadProfile::new(512, 512, 240, true)
369 }
370
371 fn mixed_profile() -> PaneWorkloadProfile {
372 PaneWorkloadProfile::new(384, 211, 40, true)
374 }
375
376 #[test]
377 fn selection_is_deterministic() {
378 let p = policy();
379 let profile = resize_storm_profile();
380 assert_eq!(p.select(profile), p.select(profile));
381 }
382
383 #[test]
384 fn resize_storm_selects_persistent() {
385 let d = policy().select(resize_storm_profile());
386 assert_eq!(d.strategy, PaneMemoryStrategy::Persistent);
387 assert_eq!(d.reason, PaneStrategyReason::ResizeDominatedBurst);
388 assert!(!d.forced);
389 }
390
391 #[test]
392 fn mixed_workload_falls_back_to_checkpointed() {
393 let d = policy().select(mixed_profile());
394 assert_eq!(d.strategy, PaneMemoryStrategy::Checkpointed);
395 assert_eq!(d.reason, PaneStrategyReason::GeneralDefault);
396 }
397
398 #[test]
399 fn no_history_selects_baseline() {
400 let profile = PaneWorkloadProfile::new(512, 512, 240, false);
401 let d = policy().select(profile);
402 assert_eq!(d.strategy, PaneMemoryStrategy::Baseline);
403 assert_eq!(d.reason, PaneStrategyReason::NoHistoryRequired);
404 }
405
406 #[test]
407 fn shallow_resize_storm_stays_checkpointed() {
408 let profile = PaneWorkloadProfile::new(32, 32, 240, true);
410 assert_eq!(
411 policy().select(profile).strategy,
412 PaneMemoryStrategy::Checkpointed
413 );
414 }
415
416 #[test]
417 fn forced_strategy_overrides_adaptation() {
418 let forced = policy().forcing(PaneMemoryStrategy::Persistent);
420 let d = forced.select(mixed_profile());
421 assert_eq!(d.strategy, PaneMemoryStrategy::Persistent);
422 assert_eq!(d.reason, PaneStrategyReason::ForcedOverride);
423 assert!(d.forced);
424 }
425
426 #[test]
427 fn conservative_forces_checkpointed_even_on_resize_storm() {
428 let conservative = policy().conservative();
429 let d = conservative.select(resize_storm_profile());
430 assert_eq!(d.strategy, PaneMemoryStrategy::Checkpointed);
431 assert_eq!(d.reason, PaneStrategyReason::ConservativeFallback);
432 assert!(d.forced);
433 }
434
435 #[test]
436 fn hysteresis_prevents_thrashing_near_threshold() {
437 let p = policy();
438 let at_threshold = PaneWorkloadProfile::new(512, 410, 240, true); assert_eq!(
442 p.select(at_threshold).strategy,
443 PaneMemoryStrategy::Persistent
444 );
445 assert_eq!(
446 p.reselect(at_threshold, PaneMemoryStrategy::Checkpointed)
447 .strategy,
448 PaneMemoryStrategy::Checkpointed,
449 "should not enter persistent without clearing the hysteresis margin"
450 );
451
452 let decisive = PaneWorkloadProfile::new(512, 487, 240, true); let entered = p.reselect(decisive, PaneMemoryStrategy::Checkpointed);
455 assert_eq!(entered.strategy, PaneMemoryStrategy::Persistent);
456 assert_eq!(entered.reason, PaneStrategyReason::ResizeDominatedBurst);
457
458 let mild_dip = PaneWorkloadProfile::new(512, 384, 240, true); let held = p.reselect(mild_dip, PaneMemoryStrategy::Persistent);
461 assert_eq!(held.strategy, PaneMemoryStrategy::Persistent);
462 assert_eq!(held.reason, PaneStrategyReason::HysteresisHold);
463
464 let decisive_drop = PaneWorkloadProfile::new(512, 332, 240, true); assert_eq!(
467 p.reselect(decisive_drop, PaneMemoryStrategy::Persistent)
468 .strategy,
469 PaneMemoryStrategy::Checkpointed
470 );
471 }
472
473 #[test]
474 fn hard_gate_jitter_does_not_oscillate() {
475 let p = policy();
480 let below_gate = PaneWorkloadProfile::new(512, 512, 59, true);
481 let at_gate = PaneWorkloadProfile::new(512, 512, 60, true);
482 let clears_gate = PaneWorkloadProfile::new(512, 512, 66, true);
483
484 assert_eq!(
486 p.reselect(below_gate, PaneMemoryStrategy::Persistent)
487 .strategy,
488 PaneMemoryStrategy::Checkpointed
489 );
490 let held = p.reselect(at_gate, PaneMemoryStrategy::Checkpointed);
492 assert_eq!(held.strategy, PaneMemoryStrategy::Checkpointed);
493 assert_eq!(held.reason, PaneStrategyReason::HysteresisHold);
494 assert_eq!(
496 p.reselect(clears_gate, PaneMemoryStrategy::Checkpointed)
497 .strategy,
498 PaneMemoryStrategy::Persistent
499 );
500 }
501
502 #[test]
503 fn observe_classifies_local_operations() {
504 let ops = vec![
505 PaneOperation::SetSplitRatio {
506 split: PaneId::new(2).unwrap(),
507 ratio: PaneSplitRatio::new(1, 1).unwrap(),
508 },
509 PaneOperation::SetSplitRatio {
510 split: PaneId::new(2).unwrap(),
511 ratio: PaneSplitRatio::new(2, 1).unwrap(),
512 },
513 PaneOperation::CloseNode {
514 target: PaneId::new(3).unwrap(),
515 },
516 ];
517 let profile = PaneWorkloadProfile::observe(&ops, 120, true);
518 assert_eq!(profile.operation_count, 3);
519 assert_eq!(profile.local_operation_count, 2);
520 assert_eq!(profile.local_fraction_pct(), 66);
521 }
522
523 #[test]
527 fn strategy_choice_never_diverges_behavior() {
528 let ratio = |n, d| PaneSplitRatio::new(n, d).expect("ratio");
529 let mut ops = vec![
530 PaneOperation::SplitLeaf {
531 target: PaneId::MIN,
532 axis: SplitAxis::Horizontal,
533 ratio: ratio(1, 1),
534 placement: PanePlacement::ExistingFirst,
535 new_leaf: PaneLeaf::new("b"),
536 },
537 PaneOperation::SplitLeaf {
538 target: PaneId::MIN,
539 axis: SplitAxis::Vertical,
540 ratio: ratio(2, 1),
541 placement: PanePlacement::ExistingFirst,
542 new_leaf: PaneLeaf::new("c"),
543 },
544 ];
545 let split = PaneId::new(4).expect("id");
546 for n in 1..=12u32 {
547 ops.push(PaneOperation::SetSplitRatio {
548 split,
549 ratio: ratio(n % 5 + 1, 1),
550 });
551 }
552
553 let mut baseline = PaneTree::singleton("root");
555 for (i, op) in ops.iter().enumerate() {
556 baseline
557 .apply_operation_conservative(i as u64 + 1, op.clone())
558 .expect("baseline apply");
559 }
560 let mut tree = PaneTree::singleton("root");
562 let mut timeline = PaneInteractionTimeline::default();
563 for (i, op) in ops.iter().enumerate() {
564 let id = i as u64;
565 timeline
566 .apply_and_record(&mut tree, id, id, op.clone())
567 .expect("timeline apply");
568 }
569 let mut store = PaneVersionStore::new(VersionedPaneTree::singleton("root"));
571 for op in &ops {
572 store.apply(op).expect("store apply");
573 }
574
575 let baseline_hash = baseline.state_hash();
576 let timeline_hash = tree.state_hash();
577 let store_hash = store.current().state_hash().expect("hash");
578 assert_eq!(baseline_hash, timeline_hash);
579 assert_eq!(baseline_hash, store_hash);
580
581 let profile = PaneWorkloadProfile::observe(&ops, 240, true);
583 let strategy = policy().select(profile).strategy;
584 assert!(matches!(
585 strategy,
586 PaneMemoryStrategy::Baseline
587 | PaneMemoryStrategy::Checkpointed
588 | PaneMemoryStrategy::Persistent
589 ));
590 }
591}