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: EVERY gate must clear by a margin, not just
227            // the local-fraction threshold. Leaving is decisive on any failed
228            // hard gate, so a marginless entry would let burst/op-count jitter
229            // right at a hard gate flip the strategy every window — the exact
230            // oscillation hysteresis exists to prevent. Hard-gate margins are
231            // proportional (10%).
232            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            // Leaving persistent: a failed hard gate is decisive; otherwise the
243            // local fraction must drop below the threshold by the margin.
244            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/// A deterministic, auditable strategy-selection decision.
337#[derive(Debug, Clone, PartialEq, serde::Serialize)]
338pub struct PaneExecutionDecision {
339    /// The selected execution strategy.
340    pub strategy: PaneMemoryStrategy,
341    /// Why it was selected.
342    pub reason: PaneStrategyReason,
343    /// Whether an operator override produced this decision.
344    pub forced: bool,
345    /// The workload profile the decision was made against.
346    pub profile: PaneWorkloadProfile,
347    /// The retention policy to apply alongside the selected strategy.
348    pub retention: PaneRetentionPolicy,
349    /// Human-readable one-line decision trace.
350    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        // 512 ops, all local (a pure drag-resize storm), bursty.
368        PaneWorkloadProfile::new(512, 512, 240, true)
369    }
370
371    fn mixed_profile() -> PaneWorkloadProfile {
372        // 384 ops, ~55% local, moderate rate.
373        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        // Resize-dominated and bursty, but below the depth where persistent pays.
409        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        // A mixed workload would pick checkpointed, but force persistent.
419        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        // Local fraction exactly at the entry threshold (80%): a fresh select
439        // would pick persistent, but reselect from checkpointed needs +margin.
440        let at_threshold = PaneWorkloadProfile::new(512, 410, 240, true); // 80%
441        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        // A decisive resize storm (95% local) does enter persistent.
453        let decisive = PaneWorkloadProfile::new(512, 487, 240, true); // 95%
454        let entered = p.reselect(decisive, PaneMemoryStrategy::Checkpointed);
455        assert_eq!(entered.strategy, PaneMemoryStrategy::Persistent);
456        assert_eq!(entered.reason, PaneStrategyReason::ResizeDominatedBurst);
457
458        // Once persistent, a mild dip (75% — within the margin) holds persistent.
459        let mild_dip = PaneWorkloadProfile::new(512, 384, 240, true); // 75%
460        let held = p.reselect(mild_dip, PaneMemoryStrategy::Persistent);
461        assert_eq!(held.strategy, PaneMemoryStrategy::Persistent);
462        assert_eq!(held.reason, PaneStrategyReason::HysteresisHold);
463
464        // A decisive drop (65% — below threshold - margin) leaves persistent.
465        let decisive_drop = PaneWorkloadProfile::new(512, 332, 240, true); // 64%
466        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        // Burst rate jittering right at the hard gate (59 <-> 60/s) must not
476        // flip Persistent <-> Checkpointed every window: leaving on a failed
477        // hard gate is decisive, so re-entry must clear the gate by a margin
478        // (>= 66/s at the default 60/s threshold), not merely touch it.
479        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        // Below the gate: leaving persistent is decisive.
485        assert_eq!(
486            p.reselect(below_gate, PaneMemoryStrategy::Persistent)
487                .strategy,
488            PaneMemoryStrategy::Checkpointed
489        );
490        // Back at (but not clearing) the gate: re-entry is refused — held.
491        let held = p.reselect(at_gate, PaneMemoryStrategy::Checkpointed);
492        assert_eq!(held.strategy, PaneMemoryStrategy::Checkpointed);
493        assert_eq!(held.reason, PaneStrategyReason::HysteresisHold);
494        // Clearing the gate by the 10% margin enters persistent.
495        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    /// The headline safety guarantee: whichever strategy the selector picks, the
524    /// observable result (final state hash) is identical — the candidates are
525    /// proven equivalent, so selection changes cost, never behavior.
526    #[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        // Baseline: a plain tree.
554        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        // Checkpointed timeline.
561        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        // Persistent store.
570        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        // And the selector picks among exactly these equivalent substrates.
582        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}