1#![forbid(unsafe_code)]
2
3use crate::unified_evidence::{DecisionDomain, EvidenceEntry, EvidenceEntryBuilder};
21
22pub fn from_diff_strategy(
31 evidence: &ftui_render::diff_strategy::StrategyEvidence,
32 timestamp_ns: u64,
33) -> EvidenceEntry {
34 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 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 let p = evidence.posterior_mean.clamp(1e-6, 1.0 - 1e-6);
64 let log_posterior = (p / (1.0 - p)).ln();
65
66 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 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 if evidence.posterior_mean > 0.0 {
80 builder = builder.evidence("change_rate", evidence.posterior_mean * 20.0);
81 }
82 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 if evidence.hysteresis_applied {
89 builder = builder.evidence("hysteresis", 0.8);
90 }
91
92 builder.build()
93}
94
95pub 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 let log_posterior = decision.wealth.max(1e-12).ln();
116
117 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 builder = builder.evidence("wealth", decision.wealth);
133
134 if decision.lambda.abs() > 1e-12 {
136 builder = builder.evidence("lambda", (1.0 + decision.lambda.abs()).max(0.01));
137 }
138
139 builder = builder.evidence("empirical_rate", 1.0 + decision.empirical_rate * 5.0);
141
142 builder.build()
143}
144
145pub fn from_voi(decision: &crate::voi_sampling::VoiDecision, timestamp_ns: u64) -> EvidenceEntry {
154 let action: &'static str = decision.reason;
155
156 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 if decision.score > 0.0 {
172 builder = builder.evidence("voi_score", 1.0 + decision.score * 10.0);
173 }
174
175 if decision.e_value > 0.0 {
177 builder = builder.evidence("e_value", decision.e_value);
178 }
179
180 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
188pub 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 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 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 if prediction.quantile > 0.0 {
230 builder = builder.evidence("quantile", 1.0 + prediction.quantile / 1000.0);
231 }
232
233 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
244pub 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 let p = evidence.p_burst.clamp(1e-6, 1.0 - 1e-6);
261 let log_posterior = (p / (1.0 - p)).ln();
262
263 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 builder = builder.evidence(
277 "burst_prob",
278 evidence.p_burst / (1.0 - evidence.p_burst + 1e-12),
279 );
280
281 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 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#[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}