Skip to main content

ftui_layout/
pane_execution.rs

1//! Deterministic execution-policy selector for pane-history strategies (`bd-1k7ek.6`).
2//!
3//! Three semantically-equivalent ways to drive pane undo/redo now exist —
4//! **baseline** (no history), the **checkpointed** [`PaneInteractionTimeline`](crate::pane::PaneInteractionTimeline),
5//! and the **persistent** [`PaneVersionStore`](crate::pane_persistent::PaneVersionStore) —
6//! proven byte-identical over lockstep histories
7//! (`tests/pane_persistent_equivalence.rs`). Once equivalent implementations
8//! exist, the system should pick the cheapest *safe* one for the observed
9//! workload rather than hard-coding one globally.
10//!
11//! This module is that policy layer, and it is deliberately **not** opaque
12//! adaptive magic:
13//!
14//! * Selection is a **pure, deterministic function** of an observed
15//!   [`PaneWorkloadProfile`] and explicit, documented thresholds — same inputs
16//!   always yield the same [`PaneExecutionDecision`].
17//! * Every decision carries a human-readable `log` and a [`PaneStrategyReason`]
18//!   explaining *why* a strategy was chosen, so profiling and regressions stay
19//!   explainable.
20//! * The checkpointed timeline is the **conservative fallback** (it is the
21//!   certified production path and the differential oracle for the persistent
22//!   spike); it is chosen whenever the persistent criteria are not all met.
23//! * Operators can **force** any strategy ([`PaneExecutionPolicy::forcing`]) or
24//!   force the conservative path ([`PaneExecutionPolicy::conservative`]) for
25//!   debugging and rollout — overrides bypass the adaptive logic entirely.
26//! * [`reselect`](PaneExecutionPolicy::reselect) applies **hysteresis** so the
27//!   selector does not thrash when the workload jitters near a threshold.
28//!
29//! Because the candidate strategies are proven equivalent, selecting among them
30//! can never change observable behavior — only cost. The selector emits the
31//! *decision*; wiring it to a live execution engine is downstream integration
32//! (the persistent store is still a prototype on no production path).
33
34use crate::pane::{PaneOperation, PaneOperationFamily};
35use crate::pane_memory::PaneMemoryStrategy;
36use crate::pane_retention::PaneRetentionPolicy;
37
38/// Observed shape of a pane-interaction workload window — the deterministic
39/// input to strategy selection.
40#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
41pub struct PaneWorkloadProfile {
42    /// Operations observed in the window.
43    pub operation_count: usize,
44    /// Of those, how many are `Local` (resize / `SetSplitRatio`) — the hot path
45    /// where the persistent store's structural sharing and O(1) navigation win.
46    pub local_operation_count: usize,
47    /// Peak operations-per-second observed (burstiness, e.g. a live drag-resize).
48    pub peak_ops_per_sec: u32,
49    /// Whether undo/redo history is required at all. If not, no history substrate
50    /// is needed and the baseline path is selected.
51    pub history_required: bool,
52}
53
54impl PaneWorkloadProfile {
55    /// Construct a profile from explicit counts.
56    #[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    /// Derive a profile from an observed operation window. Local operations are
72    /// classified by [`PaneOperation::family`] (`Local` = `SetSplitRatio`).
73    #[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    /// Local-operation fraction as an integer percentage in `[0, 100]`.
88    #[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/// Why a strategy was selected — the auditable rationale in every decision.
98#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
99pub enum PaneStrategyReason {
100    /// An operator/debug override forced this strategy.
101    ForcedOverride,
102    /// Conservative mode forced the certified checkpointed path.
103    ConservativeFallback,
104    /// History is not required, so the baseline (no-history) path was chosen.
105    NoHistoryRequired,
106    /// A resize-dominated, bursty, deep workload favored the persistent store.
107    ResizeDominatedBurst,
108    /// No strategy clearly won, so the conservative checkpointed default was used.
109    GeneralDefault,
110    /// A hysteresis margin held the previous strategy to avoid thrashing.
111    HysteresisHold,
112}
113
114impl PaneStrategyReason {
115    /// Stable identifier for logs/artifacts.
116    #[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/// Deterministic policy that selects among the three pane execution strategies.
130///
131/// The thresholds are explicit and tunable; defaults are derived from the
132/// persistence spike (`bd-1k7ek.5`) and the memory telemetry (`bd-25wj7.1`): the
133/// persistent store earns its keep on resize-dominated bursts deep enough that
134/// O(1) navigation and structural sharing pay off, while the checkpointed
135/// timeline is the safe default everywhere else.
136#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
137pub struct PaneExecutionPolicy {
138    /// Force a specific strategy (operator/debug override). `None` = adaptive.
139    pub forced_strategy: Option<PaneMemoryStrategy>,
140    /// Force the conservative certified path (checkpointed). Overrides adaptation.
141    pub conservative: bool,
142    /// Minimum operations before the persistent store is considered.
143    pub persistent_min_operations: usize,
144    /// Minimum local-op fraction (percent) for the persistent store.
145    pub persistent_local_fraction_pct: u32,
146    /// Minimum peak ops/sec (burstiness) for the persistent store.
147    pub persistent_burst_ops_per_sec: u32,
148    /// Hysteresis margin (percent, on the local fraction) used by
149    /// [`reselect`](Self::reselect) to avoid thrashing near a threshold.
150    pub hysteresis_pct: u32,
151    /// The retention policy carried alongside the selected strategy.
152    pub retention: PaneRetentionPolicy,
153}
154
155impl PaneExecutionPolicy {
156    /// Default: persistent only past the spike's bounded-window depth.
157    pub const DEFAULT_PERSISTENT_MIN_OPERATIONS: usize = 64;
158    /// Default: persistent only when the workload is resize-dominated.
159    pub const DEFAULT_PERSISTENT_LOCAL_FRACTION_PCT: u32 = 80;
160    /// Default: persistent only under a drag-resize burst.
161    pub const DEFAULT_PERSISTENT_BURST_OPS_PER_SEC: u32 = 60;
162    /// Default hysteresis margin.
163    pub const DEFAULT_HYSTERESIS_PCT: u32 = 10;
164
165    /// An adaptive policy with the default thresholds, carrying `retention`.
166    #[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    /// Return this policy forced to the conservative certified path (checkpointed).
180    #[must_use]
181    pub const fn conservative(mut self) -> Self {
182        self.conservative = true;
183        self
184    }
185
186    /// Return this policy forced to a specific strategy.
187    #[must_use]
188    pub const fn forcing(mut self, strategy: PaneMemoryStrategy) -> Self {
189        self.forced_strategy = Some(strategy);
190        self
191    }
192
193    /// Select a strategy for `profile` (stateless, deterministic).
194    #[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    /// Re-select with hysteresis given the `previous` strategy: keep `previous`
201    /// unless the workload favors a different strategy by a clear margin. This
202    /// prevents thrashing when the local-op fraction jitters near a threshold.
203    /// Forced/conservative overrides ignore hysteresis.
204    #[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        // The history requirement is a hard functional flag, not a tunable
218        // threshold: honor it immediately (no hysteresis on entering/leaving
219        // the baseline path).
220        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            // Entering persistent: clear the local-fraction threshold by the margin.
227            self.favors_persistent(profile, self.hysteresis_pct)
228        } else {
229            // Leaving persistent: a failed hard gate is decisive; otherwise the
230            // local fraction must drop below the threshold by the margin.
231            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/// A deterministic, auditable strategy-selection decision.
324#[derive(Debug, Clone, PartialEq, serde::Serialize)]
325pub struct PaneExecutionDecision {
326    /// The selected execution strategy.
327    pub strategy: PaneMemoryStrategy,
328    /// Why it was selected.
329    pub reason: PaneStrategyReason,
330    /// Whether an operator override produced this decision.
331    pub forced: bool,
332    /// The workload profile the decision was made against.
333    pub profile: PaneWorkloadProfile,
334    /// The retention policy to apply alongside the selected strategy.
335    pub retention: PaneRetentionPolicy,
336    /// Human-readable one-line decision trace.
337    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        // 512 ops, all local (a pure drag-resize storm), bursty.
355        PaneWorkloadProfile::new(512, 512, 240, true)
356    }
357
358    fn mixed_profile() -> PaneWorkloadProfile {
359        // 384 ops, ~55% local, moderate rate.
360        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        // Resize-dominated and bursty, but below the depth where persistent pays.
396        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        // A mixed workload would pick checkpointed, but force persistent.
406        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        // Local fraction exactly at the entry threshold (80%): a fresh select
426        // would pick persistent, but reselect from checkpointed needs +margin.
427        let at_threshold = PaneWorkloadProfile::new(512, 410, 240, true); // 80%
428        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        // A decisive resize storm (95% local) does enter persistent.
440        let decisive = PaneWorkloadProfile::new(512, 487, 240, true); // 95%
441        let entered = p.reselect(decisive, PaneMemoryStrategy::Checkpointed);
442        assert_eq!(entered.strategy, PaneMemoryStrategy::Persistent);
443        assert_eq!(entered.reason, PaneStrategyReason::ResizeDominatedBurst);
444
445        // Once persistent, a mild dip (75% — within the margin) holds persistent.
446        let mild_dip = PaneWorkloadProfile::new(512, 384, 240, true); // 75%
447        let held = p.reselect(mild_dip, PaneMemoryStrategy::Persistent);
448        assert_eq!(held.strategy, PaneMemoryStrategy::Persistent);
449        assert_eq!(held.reason, PaneStrategyReason::HysteresisHold);
450
451        // A decisive drop (65% — below threshold - margin) leaves persistent.
452        let decisive_drop = PaneWorkloadProfile::new(512, 332, 240, true); // 64%
453        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    /// The headline safety guarantee: whichever strategy the selector picks, the
482    /// observable result (final state hash) is identical — the candidates are
483    /// proven equivalent, so selection changes cost, never behavior.
484    #[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        // Baseline: a plain tree.
512        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        // Checkpointed timeline.
519        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        // Persistent store.
528        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        // And the selector picks among exactly these equivalent substrates.
540        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}