Skip to main content

ftui_runtime/
decision_core.rs

1//! Generic expected-loss decision framework (bd-2uv9c, bd-3rss7).
2//!
3//! `DecisionCore<S, A>` is the universal trait that unifies all Bayesian
4//! decision points in FrankenTUI. Each adaptive controller (diff strategy,
5//! resize coalescing, frame budget, degradation, VOI sampling, hint ranking,
6//! palette scoring) implements this trait with domain-specific state and
7//! action types.
8//!
9//! # Decision Rule
10//!
11//! The framework follows the **expected-loss minimization** paradigm:
12//!
13//! ```text
14//! a* = argmin_a  E_{s ~ posterior(evidence)} [ loss(a, s) ]
15//! ```
16//!
17//! Every decision produces an [`EvidenceEntry`] that records the posterior,
18//! evidence terms, action chosen, and loss avoided — enabling post-hoc audit
19//! via the [`UnifiedEvidenceLedger`].
20//!
21//! # Calibration
22//!
23//! After each decision, the actual outcome is observed and fed back via
24//! [`DecisionCore::calibrate`]. This closes the feedback loop, updating
25//! the posterior for the next decision.
26//!
27//! # Fallback Safety
28//!
29//! Every `DecisionCore` implementation must provide a safe fallback action
30//! via [`DecisionCore::fallback_action`]. When the posterior is degenerate
31//! or computation fails, the framework returns this action rather than
32//! panicking.
33//!
34//! [`EvidenceEntry`]: super::unified_evidence::EvidenceEntry
35//! [`UnifiedEvidenceLedger`]: super::unified_evidence::UnifiedEvidenceLedger
36
37use std::fmt;
38
39use crate::unified_evidence::{DecisionDomain, EvidenceEntry, EvidenceEntryBuilder, EvidenceTerm};
40
41// ============================================================================
42// Core Traits
43// ============================================================================
44
45/// Marker trait for a state space element.
46///
47/// States represent the unknown ground truth that the decision-maker
48/// reasons about. Examples: change rate (f64), resize regime (Steady/Burst),
49/// frame cost bucket (enum).
50pub trait State: fmt::Debug + Clone + 'static {}
51
52/// Marker trait for an action space element.
53///
54/// Actions are the choices available to the decision-maker.
55/// Each action has a static label for evidence logging.
56pub trait Action: fmt::Debug + Clone + 'static {
57    /// Human-readable label for JSONL evidence (e.g., "dirty_rows").
58    fn label(&self) -> &'static str;
59}
60
61/// Posterior belief over the state space.
62///
63/// Encapsulates the decision-maker's current belief about the state,
64/// sufficient for computing expected loss.
65#[derive(Debug, Clone)]
66pub struct Posterior<S: State> {
67    /// The point estimate (mode or mean) of the posterior.
68    pub point_estimate: S,
69    /// Log-posterior odds of the point estimate being correct.
70    pub log_posterior: f64,
71    /// Confidence interval `(lower, upper)` on the posterior probability.
72    pub confidence_interval: (f64, f64),
73    /// Top evidence terms that shaped this posterior.
74    pub evidence: Vec<EvidenceTerm>,
75}
76
77/// Result of a decision: the chosen action plus evidence for the ledger.
78#[derive(Debug, Clone)]
79pub struct Decision<A: Action> {
80    /// The action chosen by expected-loss minimization.
81    pub action: A,
82    /// Expected loss of the chosen action.
83    pub expected_loss: f64,
84    /// Expected loss of the next-best action (for loss_avoided).
85    pub next_best_loss: f64,
86    /// Log-posterior at decision time.
87    pub log_posterior: f64,
88    /// Confidence interval at decision time.
89    pub confidence_interval: (f64, f64),
90    /// Evidence terms contributing to this decision.
91    pub evidence: Vec<EvidenceTerm>,
92}
93
94impl<A: Action> Decision<A> {
95    /// Loss avoided by choosing this action over the next-best.
96    #[must_use]
97    pub fn loss_avoided(&self) -> f64 {
98        (self.next_best_loss - self.expected_loss).max(0.0)
99    }
100
101    /// Convert to a unified evidence entry for the ledger.
102    #[must_use]
103    pub fn to_evidence_entry(&self, domain: DecisionDomain, timestamp_ns: u64) -> EvidenceEntry {
104        let mut builder = EvidenceEntryBuilder::new(domain, 0, timestamp_ns)
105            .log_posterior(self.log_posterior)
106            .action(self.action.label())
107            .loss_avoided(self.loss_avoided())
108            .confidence_interval(self.confidence_interval.0, self.confidence_interval.1);
109
110        for term in &self.evidence {
111            builder = builder.evidence(term.label, term.bayes_factor);
112        }
113
114        builder.build()
115    }
116}
117
118/// Observed outcome after a decision was executed.
119///
120/// Implementations define what constitutes an "outcome" for their domain.
121/// Examples: actual frame time (u64), actual resize inter-arrival (f64),
122/// whether the diff strategy matched (bool).
123pub trait Outcome: fmt::Debug + 'static {}
124
125// Blanket implementations for common outcome types.
126impl Outcome for bool {}
127impl Outcome for f64 {}
128impl Outcome for u64 {}
129impl Outcome for u32 {}
130
131/// The universal decision-making trait.
132///
133/// Every adaptive controller in FrankenTUI implements this trait.
134/// The trait is generic over:
135/// - `S`: the state space (what the controller believes about the world)
136/// - `A`: the action space (what the controller can choose to do)
137///
138/// # Contract
139///
140/// 1. `posterior()` is pure: it does not mutate the controller.
141/// 2. `decide()` may update internal counters (decision_id, timestamps)
142///    but must be deterministic given the same evidence and internal state.
143/// 3. `calibrate()` updates the posterior with an observed outcome.
144/// 4. `fallback_action()` must always succeed without allocation.
145///
146/// # Example
147///
148/// ```ignore
149/// impl DecisionCore<ChangeRate, DiffAction> for DiffStrategyController {
150///     fn domain(&self) -> DecisionDomain {
151///         DecisionDomain::DiffStrategy
152///     }
153///
154///     fn posterior(&self, evidence: &[EvidenceTerm]) -> Posterior<ChangeRate> {
155///         // Beta-Bernoulli posterior on change rate
156///         // ...
157///     }
158///
159///     fn loss(&self, action: &DiffAction, state: &ChangeRate) -> f64 {
160///         match action {
161///             DiffAction::Full => state.full_cost(),
162///             DiffAction::DirtyRows => state.dirty_cost(),
163///         }
164///     }
165///
166///     fn decide(&mut self, evidence: &[EvidenceTerm]) -> Decision<DiffAction> {
167///         // Expected-loss minimization
168///         // ...
169///     }
170///
171///     fn calibrate(&mut self, outcome: &bool) {
172///         // Update Beta posterior with observed match/mismatch
173///     }
174///
175///     fn fallback_action(&self) -> DiffAction {
176///         DiffAction::Full  // safe default: full redraw
177///     }
178/// }
179/// ```
180pub trait DecisionCore<S: State, A: Action> {
181    /// The outcome type for calibration feedback.
182    type Outcome: Outcome;
183
184    /// Which evidence domain this controller belongs to.
185    fn domain(&self) -> DecisionDomain;
186
187    /// Compute the posterior belief given current evidence.
188    ///
189    /// The evidence terms are additional observations beyond what the
190    /// controller has already internalized via `calibrate()`.
191    fn posterior(&self, evidence: &[EvidenceTerm]) -> Posterior<S>;
192
193    /// Compute the loss of taking `action` when the true state is `state`.
194    ///
195    /// Lower loss = better action for this state.
196    fn loss(&self, action: &A, state: &S) -> f64;
197
198    /// Choose the optimal action by minimizing expected loss.
199    ///
200    /// This is the main entry point. It:
201    /// 1. Computes the posterior from current evidence.
202    /// 2. Evaluates expected loss for each available action.
203    /// 3. Returns the action with minimum expected loss, plus full evidence.
204    ///
205    /// Implementations may update internal state (decision counters, etc.).
206    fn decide(&mut self, evidence: &[EvidenceTerm]) -> Decision<A>;
207
208    /// Update the model with an observed outcome.
209    ///
210    /// Called after `decide()` to close the feedback loop. The outcome
211    /// type is domain-specific (e.g., `bool` for match/mismatch,
212    /// `f64` for measured cost, etc.).
213    fn calibrate(&mut self, outcome: &Self::Outcome);
214
215    /// Safe fallback action when the posterior is degenerate or
216    /// computation fails.
217    ///
218    /// This must always succeed without allocation. Typically returns
219    /// the most conservative action (e.g., full redraw, no coalescing).
220    fn fallback_action(&self) -> A;
221
222    /// Available actions in the action space.
223    ///
224    /// Used by the default `decide()` implementation to enumerate
225    /// candidates for expected-loss minimization.
226    fn actions(&self) -> Vec<A>;
227
228    /// Make a decision and record it in the evidence ledger.
229    ///
230    /// Convenience method that wraps `decide()` + ledger recording.
231    fn decide_and_record(
232        &mut self,
233        evidence: &[EvidenceTerm],
234        ledger: &mut crate::unified_evidence::UnifiedEvidenceLedger,
235        timestamp_ns: u64,
236    ) -> Decision<A> {
237        let decision = self.decide(evidence);
238        let entry = decision.to_evidence_entry(self.domain(), timestamp_ns);
239        ledger.record(entry);
240        decision
241    }
242}
243
244// ============================================================================
245// Helper: Expected-Loss Minimizer
246// ============================================================================
247
248/// Compute the expected-loss-minimizing action given a posterior and loss fn.
249///
250/// This is a helper for implementations that use a point-estimate posterior.
251/// For richer posteriors (e.g., mixture distributions), implementations
252/// should override `decide()` directly.
253pub fn argmin_expected_loss<S, A, F>(
254    actions: &[A],
255    state_estimate: &S,
256    loss_fn: F,
257) -> Option<(usize, f64)>
258where
259    S: State,
260    A: Action,
261    F: Fn(&A, &S) -> f64,
262{
263    if actions.is_empty() {
264        return None;
265    }
266
267    let mut best_idx = 0;
268    let mut best_loss = f64::INFINITY;
269
270    for (i, action) in actions.iter().enumerate() {
271        let l = loss_fn(action, state_estimate);
272        if l < best_loss {
273            best_loss = l;
274            best_idx = i;
275        }
276    }
277
278    Some((best_idx, best_loss))
279}
280
281/// Find the second-best loss (for computing loss_avoided).
282pub fn second_best_loss<S, A, F>(
283    actions: &[A],
284    state_estimate: &S,
285    best_idx: usize,
286    loss_fn: F,
287) -> f64
288where
289    S: State,
290    A: Action,
291    F: Fn(&A, &S) -> f64,
292{
293    let mut second = f64::INFINITY;
294    for (i, action) in actions.iter().enumerate() {
295        if i == best_idx {
296            continue;
297        }
298        let l = loss_fn(action, state_estimate);
299        if l < second {
300            second = l;
301        }
302    }
303    second
304}
305
306// ============================================================================
307// Tests
308// ============================================================================
309
310#[cfg(test)]
311mod tests {
312    use super::*;
313
314    // ── Test state & action types ──────────────────────────────────────
315
316    #[derive(Debug, Clone)]
317    struct TestRate(f64);
318    impl State for TestRate {}
319
320    #[derive(Debug, Clone, PartialEq)]
321    enum TestAction {
322        Low,
323        High,
324    }
325
326    impl Action for TestAction {
327        fn label(&self) -> &'static str {
328            match self {
329                Self::Low => "low",
330                Self::High => "high",
331            }
332        }
333    }
334
335    impl Outcome for TestRate {}
336
337    // ── Test controller ────────────────────────────────────────────────
338
339    struct TestController {
340        rate: f64,
341        calibration_count: u32,
342    }
343
344    impl TestController {
345        fn new(initial_rate: f64) -> Self {
346            Self {
347                rate: initial_rate,
348                calibration_count: 0,
349            }
350        }
351    }
352
353    impl DecisionCore<TestRate, TestAction> for TestController {
354        type Outcome = f64;
355
356        fn domain(&self) -> DecisionDomain {
357            DecisionDomain::DiffStrategy
358        }
359
360        fn posterior(&self, _evidence: &[EvidenceTerm]) -> Posterior<TestRate> {
361            let log_post = (self.rate / (1.0 - self.rate.clamp(0.001, 0.999))).ln();
362            Posterior {
363                point_estimate: TestRate(self.rate),
364                log_posterior: log_post,
365                confidence_interval: (self.rate - 0.1, self.rate + 0.1),
366                evidence: Vec::new(),
367            }
368        }
369
370        fn loss(&self, action: &TestAction, state: &TestRate) -> f64 {
371            match action {
372                TestAction::Low => state.0 * 10.0, // High rate → costly if we choose Low
373                TestAction::High => (1.0 - state.0) * 5.0, // Low rate → costly if we choose High
374            }
375        }
376
377        fn decide(&mut self, evidence: &[EvidenceTerm]) -> Decision<TestAction> {
378            let posterior = self.posterior(evidence);
379            let actions = self.actions();
380            let state = &posterior.point_estimate;
381
382            let (best_idx, best_loss) =
383                argmin_expected_loss(&actions, state, |a, s| self.loss(a, s)).unwrap();
384            let next_best = second_best_loss(&actions, state, best_idx, |a, s| self.loss(a, s));
385
386            Decision {
387                action: actions[best_idx].clone(),
388                expected_loss: best_loss,
389                next_best_loss: next_best,
390                log_posterior: posterior.log_posterior,
391                confidence_interval: posterior.confidence_interval,
392                evidence: posterior.evidence,
393            }
394        }
395
396        fn calibrate(&mut self, outcome: &f64) {
397            // Exponential moving average update.
398            self.rate = self.rate * 0.9 + outcome * 0.1;
399            self.calibration_count += 1;
400        }
401
402        fn fallback_action(&self) -> TestAction {
403            TestAction::High // Conservative fallback.
404        }
405
406        fn actions(&self) -> Vec<TestAction> {
407            vec![TestAction::Low, TestAction::High]
408        }
409    }
410
411    // ── Tests ──────────────────────────────────────────────────────────
412
413    #[test]
414    fn decide_chooses_low_for_low_rate() {
415        let mut ctrl = TestController::new(0.1);
416        let decision = ctrl.decide(&[]);
417        // Low rate: Low action has loss 0.1*10=1, High has loss 0.9*5=4.5
418        assert_eq!(decision.action, TestAction::Low);
419        assert!(decision.expected_loss < decision.next_best_loss);
420    }
421
422    #[test]
423    fn decide_chooses_high_for_high_rate() {
424        let mut ctrl = TestController::new(0.8);
425        let decision = ctrl.decide(&[]);
426        // High rate: Low has loss 0.8*10=8, High has loss 0.2*5=1
427        assert_eq!(decision.action, TestAction::High);
428    }
429
430    #[test]
431    fn loss_avoided_nonnegative() {
432        let mut ctrl = TestController::new(0.3);
433        let decision = ctrl.decide(&[]);
434        assert!(decision.loss_avoided() >= 0.0);
435    }
436
437    #[test]
438    fn calibrate_updates_rate() {
439        let mut ctrl = TestController::new(0.5);
440        ctrl.calibrate(&1.0);
441        // rate = 0.5 * 0.9 + 1.0 * 0.1 = 0.55
442        assert!((ctrl.rate - 0.55).abs() < 1e-10);
443        assert_eq!(ctrl.calibration_count, 1);
444    }
445
446    #[test]
447    fn fallback_is_conservative() {
448        let ctrl = TestController::new(0.5);
449        assert_eq!(ctrl.fallback_action(), TestAction::High);
450    }
451
452    #[test]
453    fn posterior_reflects_rate() {
454        let ctrl = TestController::new(0.7);
455        let post = ctrl.posterior(&[]);
456        assert!((post.point_estimate.0 - 0.7).abs() < 1e-10);
457        assert!(post.log_posterior > 0.0); // rate > 0.5 means positive log-odds
458    }
459
460    #[test]
461    fn posterior_negative_log_odds_for_low_rate() {
462        let ctrl = TestController::new(0.2);
463        let post = ctrl.posterior(&[]);
464        assert!(post.log_posterior < 0.0); // rate < 0.5 means negative log-odds
465    }
466
467    #[test]
468    fn evidence_entry_conversion() {
469        let mut ctrl = TestController::new(0.3);
470        let decision = ctrl.decide(&[]);
471        let entry = decision.to_evidence_entry(DecisionDomain::DiffStrategy, 42_000);
472
473        assert_eq!(entry.domain, DecisionDomain::DiffStrategy);
474        assert_eq!(entry.timestamp_ns, 42_000);
475        assert_eq!(entry.action, "low");
476        assert!(entry.loss_avoided >= 0.0);
477    }
478
479    #[test]
480    fn decide_and_record_adds_to_ledger() {
481        let mut ctrl = TestController::new(0.3);
482        let mut ledger = crate::unified_evidence::UnifiedEvidenceLedger::new(100);
483
484        assert_eq!(ledger.len(), 0);
485        let _decision = ctrl.decide_and_record(&[], &mut ledger, 1000);
486        assert_eq!(ledger.len(), 1);
487    }
488
489    #[test]
490    fn argmin_empty_returns_none() {
491        let actions: Vec<TestAction> = vec![];
492        let state = TestRate(0.5);
493        let result = argmin_expected_loss(&actions, &state, |_, _| 0.0);
494        assert!(result.is_none());
495    }
496
497    #[test]
498    fn argmin_single_action() {
499        let actions = vec![TestAction::Low];
500        let state = TestRate(0.5);
501        let result = argmin_expected_loss(&actions, &state, |a, s| match a {
502            TestAction::Low => s.0 * 10.0,
503            TestAction::High => (1.0 - s.0) * 5.0,
504        });
505        assert_eq!(result, Some((0, 5.0)));
506    }
507
508    #[test]
509    fn second_best_with_two_actions() {
510        let actions = vec![TestAction::Low, TestAction::High];
511        let state = TestRate(0.3);
512        let sb = second_best_loss(&actions, &state, 0, |a, s| match a {
513            TestAction::Low => s.0 * 10.0,
514            TestAction::High => (1.0 - s.0) * 5.0,
515        });
516        // best_idx=0 (Low, loss=3), second is High (loss=3.5)
517        assert!((sb - 3.5).abs() < 1e-10);
518    }
519
520    #[test]
521    fn decision_to_jsonl_roundtrip() {
522        let mut ctrl = TestController::new(0.3);
523        let decision = ctrl.decide(&[]);
524        let entry = decision.to_evidence_entry(DecisionDomain::DiffStrategy, 42_000);
525        let jsonl = entry.to_jsonl();
526
527        assert!(jsonl.contains("\"schema\":\"ftui-evidence-v2\""));
528        assert!(jsonl.contains("\"domain\":\"diff_strategy\""));
529        assert!(jsonl.contains("\"action\":\"low\""));
530    }
531
532    #[test]
533    fn calibrate_multiple_rounds() {
534        let mut ctrl = TestController::new(0.5);
535        // Calibrate with consistently high outcomes.
536        for _ in 0..10 {
537            ctrl.calibrate(&1.0);
538        }
539        // Rate should have moved toward 1.0.
540        assert!(ctrl.rate > 0.8);
541        assert_eq!(ctrl.calibration_count, 10);
542    }
543
544    #[test]
545    fn decision_crossover_point() {
546        // At exactly rate=0.333..., Low and High have equal expected loss.
547        // Low: 0.333 * 10 = 3.33, High: 0.667 * 5 = 3.33
548        let mut ctrl = TestController::new(1.0 / 3.0);
549        let decision = ctrl.decide(&[]);
550        // Either action is acceptable; loss_avoided should be ~0.
551        assert!(decision.loss_avoided() < 0.01);
552    }
553
554    #[test]
555    fn domain_reports_correctly() {
556        let ctrl = TestController::new(0.5);
557        assert_eq!(ctrl.domain(), DecisionDomain::DiffStrategy);
558    }
559
560    #[test]
561    fn deterministic_decide() {
562        let mut ctrl_a = TestController::new(0.4);
563        let mut ctrl_b = TestController::new(0.4);
564        let d_a = ctrl_a.decide(&[]);
565        let d_b = ctrl_b.decide(&[]);
566        assert_eq!(d_a.action, d_b.action);
567        assert!((d_a.expected_loss - d_b.expected_loss).abs() < 1e-10);
568    }
569}