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}