Skip to main content

ftui_runtime/
evidence_bridges.rs

1#![forbid(unsafe_code)]
2
3//! Evidence bridges: convert domain-specific decision types into unified
4//! [`EvidenceEntry`] records (bd-xox.4).
5//!
6//! Each bridge function takes a domain-specific decision/evidence struct and
7//! a timestamp, and returns a unified `EvidenceEntry` suitable for the
8//! [`UnifiedEvidenceLedger`].
9//!
10//! # Supported domains
11//!
12//! | Domain | Source type | Bridge function |
13//! |--------|-----------|-----------------|
14//! | DiffStrategy | `StrategyEvidence` | [`from_diff_strategy`] |
15//! | ResizeCoalescing / BOCPD | `BocpdEvidence` | [`from_bocpd`] |
16//! | FrameBudget (e-process) | `ThrottleDecision` | [`from_eprocess`] |
17//! | VoiSampling | `VoiDecision` | [`from_voi`] |
18//! | Conformal (Degradation) | `ConformalPrediction` | [`from_conformal`] |
19
20use crate::unified_evidence::{DecisionDomain, EvidenceEntry, EvidenceEntryBuilder};
21
22// ============================================================================
23// 1. Diff Strategy
24// ============================================================================
25
26/// Convert a [`StrategyEvidence`] into a unified evidence entry.
27///
28/// Maps the Beta-Binomial posterior on change rate and per-strategy costs
29/// to the unified schema.
30pub fn from_diff_strategy(
31    evidence: &ftui_render::diff_strategy::StrategyEvidence,
32    timestamp_ns: u64,
33) -> EvidenceEntry {
34    // Determine the action string from the strategy enum.
35    let action: &'static str = match evidence.strategy {
36        ftui_render::diff_strategy::DiffStrategy::Full => "full",
37        ftui_render::diff_strategy::DiffStrategy::DirtyRows => "dirty_rows",
38        ftui_render::diff_strategy::DiffStrategy::FullRedraw => "full_redraw",
39    };
40
41    // Compute log-posterior from posterior_mean (change rate p).
42    // Map p → log-odds that the chosen strategy is optimal.
43    let chosen_cost = match evidence.strategy {
44        ftui_render::diff_strategy::DiffStrategy::Full => evidence.cost_full,
45        ftui_render::diff_strategy::DiffStrategy::DirtyRows => evidence.cost_dirty,
46        ftui_render::diff_strategy::DiffStrategy::FullRedraw => evidence.cost_redraw,
47    };
48    let min_other_cost = [
49        evidence.cost_full,
50        evidence.cost_dirty,
51        evidence.cost_redraw,
52    ]
53    .into_iter()
54    .filter(|&c| (c - chosen_cost).abs() > 1e-12)
55    .fold(f64::MAX, f64::min);
56    let loss_avoided = if min_other_cost < f64::MAX {
57        (min_other_cost - chosen_cost).max(0.0)
58    } else {
59        0.0
60    };
61
62    // Log-posterior from posterior mean (approximate).
63    let p = evidence.posterior_mean.clamp(1e-6, 1.0 - 1e-6);
64    let log_posterior = (p / (1.0 - p)).ln();
65
66    // Confidence interval from posterior variance.
67    let std_dev = evidence.posterior_variance.sqrt();
68    let lower = (p - 1.96 * std_dev).clamp(0.0, 1.0);
69    let upper = (p + 1.96 * std_dev).clamp(0.0, 1.0);
70
71    // Evidence terms: the cost ratios serve as Bayes factors.
72    let mut builder = EvidenceEntryBuilder::new(DecisionDomain::DiffStrategy, 0, timestamp_ns)
73        .log_posterior(log_posterior)
74        .action(action)
75        .loss_avoided(loss_avoided)
76        .confidence_interval(lower, upper);
77
78    // BF for change rate: how much the observed rate supports the chosen strategy.
79    if evidence.posterior_mean > 0.0 {
80        builder = builder.evidence("change_rate", evidence.posterior_mean * 20.0);
81    }
82    // BF for dirty-row ratio.
83    if evidence.total_rows > 0 {
84        let dirty_ratio = evidence.dirty_rows as f64 / evidence.total_rows as f64;
85        builder = builder.evidence("dirty_ratio", 1.0 + dirty_ratio * 5.0);
86    }
87    // Hysteresis applied as negative evidence.
88    if evidence.hysteresis_applied {
89        builder = builder.evidence("hysteresis", 0.8);
90    }
91
92    builder.build()
93}
94
95// ============================================================================
96// 2. E-Process Throttle
97// ============================================================================
98
99/// Convert a [`ThrottleDecision`] into a unified evidence entry.
100///
101/// Maps the wealth-based e-process and empirical rate to the unified schema.
102pub fn from_eprocess(
103    decision: &crate::eprocess_throttle::ThrottleDecision,
104    timestamp_ns: u64,
105) -> EvidenceEntry {
106    let action: &'static str = if decision.forced_by_deadline {
107        "recompute_forced"
108    } else if decision.should_recompute {
109        "recompute"
110    } else {
111        "hold"
112    };
113
114    // Wealth W_t as log-posterior (log-odds of needing recompute).
115    let log_posterior = decision.wealth.max(1e-12).ln();
116
117    // Evidence terms.
118    let mut builder = EvidenceEntryBuilder::new(DecisionDomain::FrameBudget, 0, timestamp_ns)
119        .log_posterior(log_posterior)
120        .action(action)
121        .loss_avoided(if decision.should_recompute {
122            decision.wealth.ln().max(0.0)
123        } else {
124            0.0
125        })
126        .confidence_interval(
127            decision.empirical_rate.max(0.0),
128            (decision.empirical_rate + 0.1).min(1.0),
129        );
130
131    // Wealth as Bayes factor (directly interpretable as evidence strength).
132    builder = builder.evidence("wealth", decision.wealth);
133
134    // Lambda (betting fraction).
135    if decision.lambda.abs() > 1e-12 {
136        builder = builder.evidence("lambda", (1.0 + decision.lambda.abs()).max(0.01));
137    }
138
139    // Empirical rate.
140    builder = builder.evidence("empirical_rate", 1.0 + decision.empirical_rate * 5.0);
141
142    builder.build()
143}
144
145// ============================================================================
146// 3. VOI Sampling
147// ============================================================================
148
149/// Convert a [`VoiDecision`] into a unified evidence entry.
150///
151/// Maps the VOI score, e-process wealth, and posterior statistics
152/// to the unified schema.
153pub fn from_voi(decision: &crate::voi_sampling::VoiDecision, timestamp_ns: u64) -> EvidenceEntry {
154    let action: &'static str = decision.reason;
155
156    // Log-posterior from posterior_mean.
157    let p = decision.posterior_mean.clamp(1e-6, 1.0 - 1e-6);
158    let log_posterior = (p / (1.0 - p)).ln();
159
160    let std_dev = decision.posterior_variance.sqrt();
161    let lower = (p - 1.96 * std_dev).clamp(0.0, 1.0);
162    let upper = (p + 1.96 * std_dev).clamp(0.0, 1.0);
163
164    let mut builder = EvidenceEntryBuilder::new(DecisionDomain::VoiSampling, 0, timestamp_ns)
165        .log_posterior(log_posterior)
166        .action(action)
167        .loss_avoided(decision.voi_gain)
168        .confidence_interval(lower, upper);
169
170    // VOI score as Bayes factor.
171    if decision.score > 0.0 {
172        builder = builder.evidence("voi_score", 1.0 + decision.score * 10.0);
173    }
174
175    // E-value.
176    if decision.e_value > 0.0 {
177        builder = builder.evidence("e_value", decision.e_value);
178    }
179
180    // Boundary score.
181    if decision.boundary_score > 0.0 {
182        builder = builder.evidence("boundary_score", 1.0 + decision.boundary_score * 3.0);
183    }
184
185    builder.build()
186}
187
188// ============================================================================
189// 4. Conformal Prediction (Degradation)
190// ============================================================================
191
192/// Convert a [`ConformalPrediction`] into a unified evidence entry.
193///
194/// Maps the conformal prediction bound and budget risk to the unified
195/// degradation decision schema.
196pub fn from_conformal(
197    prediction: &crate::conformal_predictor::ConformalPrediction,
198    timestamp_ns: u64,
199) -> EvidenceEntry {
200    let action: &'static str = if prediction.risk { "degrade" } else { "hold" };
201
202    // Log-odds of needing degradation.
203    let risk_ratio = if prediction.budget_us > 0.0 {
204        prediction.upper_us / prediction.budget_us
205    } else {
206        1.0
207    };
208    let log_posterior = (risk_ratio.clamp(0.01, 100.0)).ln();
209
210    let mut builder = EvidenceEntryBuilder::new(DecisionDomain::Degradation, 0, timestamp_ns)
211        .log_posterior(log_posterior)
212        .action(action)
213        .loss_avoided(if prediction.risk {
214            (prediction.upper_us - prediction.budget_us).max(0.0) / prediction.budget_us.max(1.0)
215        } else {
216            0.0
217        })
218        .confidence_interval(prediction.confidence - 0.05, prediction.confidence);
219
220    // Budget headroom as BF (< 1.0 means over budget).
221    if prediction.budget_us > 0.0 {
222        builder = builder.evidence(
223            "budget_headroom",
224            (prediction.budget_us / prediction.upper_us.max(1.0)).max(0.01),
225        );
226    }
227
228    // Conformal quantile.
229    if prediction.quantile > 0.0 {
230        builder = builder.evidence("quantile", 1.0 + prediction.quantile / 1000.0);
231    }
232
233    // Sample count (more samples = stronger evidence).
234    if prediction.sample_count > 0 {
235        builder = builder.evidence(
236            "sample_strength",
237            1.0 + (prediction.sample_count as f64).ln() / 5.0,
238        );
239    }
240
241    builder.build()
242}
243
244// ============================================================================
245// 5. BOCPD (Resize Coalescing)
246// ============================================================================
247
248/// Convert a [`BocpdEvidence`] into a unified evidence entry.
249///
250/// Maps the BOCPD regime posterior and run-length statistics to the
251/// resize coalescing decision schema.
252pub fn from_bocpd(evidence: &crate::bocpd::BocpdEvidence, timestamp_ns: u64) -> EvidenceEntry {
253    let action: &'static str = match evidence.regime {
254        crate::bocpd::BocpdRegime::Steady => "apply",
255        crate::bocpd::BocpdRegime::Burst => "coalesce",
256        crate::bocpd::BocpdRegime::Transitional => "placeholder",
257    };
258
259    // Log-posterior from burst probability.
260    let p = evidence.p_burst.clamp(1e-6, 1.0 - 1e-6);
261    let log_posterior = (p / (1.0 - p)).ln();
262
263    // Confidence interval from run-length variance.
264    let rl_std = evidence.run_length_variance.sqrt();
265    let rl_mean = evidence.expected_run_length;
266    let lower = ((rl_mean - 1.96 * rl_std) / (rl_mean + 1.96 * rl_std + 1.0)).clamp(0.0, 1.0);
267    let upper = ((rl_mean + 1.96 * rl_std) / (rl_mean + 1.96 * rl_std + 1.0)).clamp(0.0, 1.0);
268
269    let mut builder = EvidenceEntryBuilder::new(DecisionDomain::ResizeCoalescing, 0, timestamp_ns)
270        .log_posterior(log_posterior)
271        .action(action)
272        .loss_avoided(evidence.log_bayes_factor.abs() * 0.1)
273        .confidence_interval(lower, upper);
274
275    // Burst probability as evidence.
276    builder = builder.evidence(
277        "burst_prob",
278        evidence.p_burst / (1.0 - evidence.p_burst + 1e-12),
279    );
280
281    // Likelihood ratio.
282    if evidence.likelihood_steady > 0.0 {
283        builder = builder.evidence(
284            "likelihood_ratio",
285            evidence.likelihood_burst / evidence.likelihood_steady.max(1e-12),
286        );
287    }
288
289    // Run-length tail mass (high tail = long run, stable).
290    if evidence.run_length_tail_mass > 0.0 {
291        builder = builder.evidence("tail_mass", 1.0 / (evidence.run_length_tail_mass + 0.01));
292    }
293
294    builder.build()
295}
296
297// ============================================================================
298// Tests
299// ============================================================================
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304    use crate::unified_evidence::DecisionDomain;
305
306    #[test]
307    fn diff_strategy_bridge() {
308        let evidence = ftui_render::diff_strategy::StrategyEvidence {
309            strategy: ftui_render::diff_strategy::DiffStrategy::DirtyRows,
310            cost_full: 1.0,
311            cost_dirty: 0.5,
312            cost_redraw: 2.0,
313            posterior_mean: 0.05,
314            posterior_variance: 0.001,
315            alpha: 2.0,
316            beta: 38.0,
317            dirty_rows: 3,
318            total_rows: 24,
319            total_cells: 1920,
320            guard_reason: "none",
321            hysteresis_applied: false,
322            hysteresis_ratio: 0.05,
323        };
324
325        let entry = from_diff_strategy(&evidence, 1_000_000);
326        assert_eq!(entry.domain, DecisionDomain::DiffStrategy);
327        assert_eq!(entry.action, "dirty_rows");
328        assert!(
329            entry.loss_avoided > 0.0,
330            "chosen is cheapest, loss_avoided > 0"
331        );
332        assert!(entry.evidence_count() >= 2);
333    }
334
335    #[test]
336    fn eprocess_bridge() {
337        let decision = crate::eprocess_throttle::ThrottleDecision {
338            should_recompute: true,
339            wealth: 25.0,
340            lambda: 0.3,
341            empirical_rate: 0.4,
342            forced_by_deadline: false,
343            observations_since_recompute: 50,
344        };
345
346        let entry = from_eprocess(&decision, 2_000_000);
347        assert_eq!(entry.domain, DecisionDomain::FrameBudget);
348        assert_eq!(entry.action, "recompute");
349        assert!(entry.log_posterior > 0.0, "wealth > 1 → positive log");
350        assert!(entry.evidence_count() >= 2);
351    }
352
353    #[test]
354    fn eprocess_bridge_forced() {
355        let decision = crate::eprocess_throttle::ThrottleDecision {
356            should_recompute: true,
357            wealth: 0.5,
358            lambda: 0.1,
359            empirical_rate: 0.2,
360            forced_by_deadline: true,
361            observations_since_recompute: 200,
362        };
363
364        let entry = from_eprocess(&decision, 3_000_000);
365        assert_eq!(entry.action, "recompute_forced");
366    }
367
368    #[test]
369    fn voi_bridge() {
370        let decision = crate::voi_sampling::VoiDecision {
371            event_idx: 100,
372            should_sample: true,
373            forced_by_interval: false,
374            blocked_by_min_interval: false,
375            voi_gain: 0.05,
376            score: 0.8,
377            cost: 0.3,
378            log_bayes_factor: 1.5,
379            posterior_mean: 0.1,
380            posterior_variance: 0.005,
381            e_value: 5.0,
382            e_threshold: 20.0,
383            boundary_score: 0.7,
384            events_since_sample: 30,
385            time_since_sample_ms: 500.0,
386            reason: "voi_ge_cost",
387        };
388
389        let entry = from_voi(&decision, 4_000_000);
390        assert_eq!(entry.domain, DecisionDomain::VoiSampling);
391        assert_eq!(entry.action, "voi_ge_cost");
392        assert!(entry.evidence_count() >= 2);
393    }
394
395    #[test]
396    fn conformal_bridge() {
397        let prediction = crate::conformal_predictor::ConformalPrediction {
398            upper_us: 18_000.0,
399            risk: true,
400            confidence: 0.95,
401            bucket: crate::conformal_predictor::BucketKey {
402                mode: crate::conformal_predictor::ModeBucket::AltScreen,
403                diff: crate::conformal_predictor::DiffBucket::Full,
404                size_bucket: 2,
405            },
406            sample_count: 50,
407            quantile: 15_000.0,
408            fallback_level: 0,
409            window_size: 100,
410            reset_count: 0,
411            y_hat: 12_000.0,
412            budget_us: 16_666.0,
413        };
414
415        let entry = from_conformal(&prediction, 5_000_000);
416        assert_eq!(entry.domain, DecisionDomain::Degradation);
417        assert_eq!(entry.action, "degrade");
418        assert!(entry.log_posterior > 0.0, "over budget → positive log");
419        assert!(entry.evidence_count() >= 2);
420    }
421
422    #[test]
423    fn bocpd_bridge_burst() {
424        let evidence = crate::bocpd::BocpdEvidence {
425            p_burst: 0.85,
426            log_bayes_factor: 2.3,
427            observation_ms: 5.0,
428            regime: crate::bocpd::BocpdRegime::Burst,
429            likelihood_steady: 0.01,
430            likelihood_burst: 0.5,
431            expected_run_length: 3.0,
432            run_length_variance: 2.0,
433            run_length_mode: 2,
434            run_length_p95: 8,
435            run_length_tail_mass: 0.02,
436            recommended_delay_ms: Some(50),
437            hard_deadline_forced: None,
438            observation_count: 100,
439            timestamp: std::time::Instant::now(),
440        };
441
442        let entry = from_bocpd(&evidence, 6_000_000);
443        assert_eq!(entry.domain, DecisionDomain::ResizeCoalescing);
444        assert_eq!(entry.action, "coalesce");
445        assert!(entry.log_posterior > 0.0, "high p_burst → positive log");
446        assert!(entry.evidence_count() >= 2);
447    }
448
449    #[test]
450    fn bocpd_bridge_steady() {
451        let evidence = crate::bocpd::BocpdEvidence {
452            p_burst: 0.1,
453            log_bayes_factor: -1.5,
454            observation_ms: 200.0,
455            regime: crate::bocpd::BocpdRegime::Steady,
456            likelihood_steady: 0.8,
457            likelihood_burst: 0.01,
458            expected_run_length: 50.0,
459            run_length_variance: 10.0,
460            run_length_mode: 48,
461            run_length_p95: 65,
462            run_length_tail_mass: 0.001,
463            recommended_delay_ms: None,
464            hard_deadline_forced: None,
465            observation_count: 500,
466            timestamp: std::time::Instant::now(),
467        };
468
469        let entry = from_bocpd(&evidence, 7_000_000);
470        assert_eq!(entry.action, "apply");
471        assert!(entry.log_posterior < 0.0, "low p_burst → negative log");
472    }
473
474    #[test]
475    fn all_bridges_produce_valid_jsonl() {
476        let diff = from_diff_strategy(
477            &ftui_render::diff_strategy::StrategyEvidence {
478                strategy: ftui_render::diff_strategy::DiffStrategy::Full,
479                cost_full: 0.5,
480                cost_dirty: 0.8,
481                cost_redraw: 1.5,
482                posterior_mean: 0.3,
483                posterior_variance: 0.01,
484                alpha: 5.0,
485                beta: 12.0,
486                dirty_rows: 10,
487                total_rows: 24,
488                total_cells: 1920,
489                guard_reason: "none",
490                hysteresis_applied: true,
491                hysteresis_ratio: 0.05,
492            },
493            0,
494        );
495
496        let eproc = from_eprocess(
497            &crate::eprocess_throttle::ThrottleDecision {
498                should_recompute: false,
499                wealth: 0.5,
500                lambda: 0.1,
501                empirical_rate: 0.2,
502                forced_by_deadline: false,
503                observations_since_recompute: 10,
504            },
505            1000,
506        );
507
508        let entries = [diff, eproc];
509        for (i, entry) in entries.iter().enumerate() {
510            let jsonl = entry.to_jsonl();
511            let parsed: Result<serde_json::Value, _> = serde_json::from_str(&jsonl);
512            assert!(
513                parsed.is_ok(),
514                "Bridge {} produced invalid JSONL: {}",
515                i,
516                &jsonl[..jsonl.len().min(100)]
517            );
518        }
519    }
520}