1#![forbid(unsafe_code)]
2
3use std::fmt::Write as _;
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
56pub enum DecisionDomain {
57 DiffStrategy,
59 ResizeCoalescing,
61 FrameBudget,
63 Degradation,
65 VoiSampling,
67 HintRanking,
69 PaletteScoring,
71}
72
73impl DecisionDomain {
74 pub const fn as_str(self) -> &'static str {
76 match self {
77 Self::DiffStrategy => "diff_strategy",
78 Self::ResizeCoalescing => "resize_coalescing",
79 Self::FrameBudget => "frame_budget",
80 Self::Degradation => "degradation",
81 Self::VoiSampling => "voi_sampling",
82 Self::HintRanking => "hint_ranking",
83 Self::PaletteScoring => "palette_scoring",
84 }
85 }
86
87 pub const ALL: [Self; 7] = [
89 Self::DiffStrategy,
90 Self::ResizeCoalescing,
91 Self::FrameBudget,
92 Self::Degradation,
93 Self::VoiSampling,
94 Self::HintRanking,
95 Self::PaletteScoring,
96 ];
97}
98
99#[derive(Debug, Clone)]
107pub struct EvidenceTerm {
108 pub label: &'static str,
110 pub bayes_factor: f64,
112}
113
114impl EvidenceTerm {
115 #[must_use]
117 pub const fn new(label: &'static str, bayes_factor: f64) -> Self {
118 Self {
119 label,
120 bayes_factor,
121 }
122 }
123
124 #[must_use]
126 pub fn log_bf(&self) -> f64 {
127 self.bayes_factor.ln()
128 }
129}
130
131#[derive(Debug, Clone)]
139pub struct EvidenceEntry {
140 pub decision_id: u64,
142 pub timestamp_ns: u64,
144 pub domain: DecisionDomain,
146 pub log_posterior: f64,
148 pub top_evidence: [Option<EvidenceTerm>; 3],
150 pub action: &'static str,
152 pub loss_avoided: f64,
155 pub confidence_interval: (f64, f64),
157}
158
159impl EvidenceEntry {
160 #[must_use]
162 pub fn posterior_probability(&self) -> f64 {
163 let log_posterior = self.log_posterior;
164 if log_posterior >= 0.0 {
165 1.0 / (1.0 + (-log_posterior).exp())
166 } else {
167 let exp_lp = log_posterior.exp();
168 exp_lp / (1.0 + exp_lp)
169 }
170 }
171
172 #[must_use]
174 pub fn evidence_count(&self) -> usize {
175 self.top_evidence.iter().filter(|t| t.is_some()).count()
176 }
177
178 #[must_use]
180 pub fn combined_log_bf(&self) -> f64 {
181 self.top_evidence
182 .iter()
183 .filter_map(|t| t.as_ref())
184 .map(|t| t.log_bf())
185 .sum()
186 }
187
188 pub fn to_jsonl(&self) -> String {
190 let mut out = String::with_capacity(256);
191 out.push_str("{\"schema\":\"ftui-evidence-v2\"");
192 let _ = write!(out, ",\"id\":{}", self.decision_id);
193 let _ = write!(out, ",\"ts_ns\":{}", self.timestamp_ns);
194 let _ = write!(out, ",\"domain\":\"{}\"", self.domain.as_str());
195 let _ = write!(out, ",\"log_posterior\":{:.6}", self.log_posterior);
196
197 out.push_str(",\"evidence\":[");
198 let mut first = true;
199 for term in self.top_evidence.iter().flatten() {
200 if !first {
201 out.push(',');
202 }
203 first = false;
204 let _ = write!(
205 out,
206 "{{\"label\":\"{}\",\"bf\":{:.6}}}",
207 term.label, term.bayes_factor
208 );
209 }
210 out.push(']');
211
212 let _ = write!(out, ",\"action\":\"{}\"", self.action);
213 let _ = write!(out, ",\"loss_avoided\":{:.6}", self.loss_avoided);
214 let _ = write!(
215 out,
216 ",\"ci\":[{:.6},{:.6}]",
217 self.confidence_interval.0, self.confidence_interval.1
218 );
219 out.push('}');
220 out
221 }
222}
223
224pub struct EvidenceEntryBuilder {
232 decision_id: u64,
233 timestamp_ns: u64,
234 domain: DecisionDomain,
235 log_posterior: f64,
236 evidence: Vec<EvidenceTerm>,
237 action: &'static str,
238 loss_avoided: f64,
239 confidence_interval: (f64, f64),
240}
241
242impl EvidenceEntryBuilder {
243 pub fn new(domain: DecisionDomain, decision_id: u64, timestamp_ns: u64) -> Self {
245 Self {
246 decision_id,
247 timestamp_ns,
248 domain,
249 log_posterior: 0.0,
250 evidence: Vec::new(),
251 action: "",
252 loss_avoided: 0.0,
253 confidence_interval: (0.0, 1.0),
254 }
255 }
256
257 #[must_use]
259 pub fn log_posterior(mut self, value: f64) -> Self {
260 self.log_posterior = value;
261 self
262 }
263
264 #[must_use]
266 pub fn evidence(mut self, label: &'static str, bayes_factor: f64) -> Self {
267 self.evidence.push(EvidenceTerm::new(label, bayes_factor));
268 self
269 }
270
271 #[must_use]
273 pub fn action(mut self, action: &'static str) -> Self {
274 self.action = action;
275 self
276 }
277
278 #[must_use]
280 pub fn loss_avoided(mut self, value: f64) -> Self {
281 self.loss_avoided = value;
282 self
283 }
284
285 #[must_use]
287 pub fn confidence_interval(mut self, lower: f64, upper: f64) -> Self {
288 self.confidence_interval = (lower, upper);
289 self
290 }
291
292 pub fn build(mut self) -> EvidenceEntry {
294 self.evidence
296 .sort_by(|a, b| b.log_bf().abs().total_cmp(&a.log_bf().abs()));
297
298 let mut top = [None, None, None];
299 for (i, term) in self.evidence.into_iter().take(3).enumerate() {
300 top[i] = Some(term);
301 }
302
303 EvidenceEntry {
304 decision_id: self.decision_id,
305 timestamp_ns: self.timestamp_ns,
306 domain: self.domain,
307 log_posterior: self.log_posterior,
308 top_evidence: top,
309 action: self.action,
310 loss_avoided: self.loss_avoided,
311 confidence_interval: self.confidence_interval,
312 }
313 }
314}
315
316pub struct UnifiedEvidenceLedger {
326 entries: Vec<Option<EvidenceEntry>>,
327 head: usize,
328 count: usize,
329 capacity: usize,
330 next_id: u64,
331 domain_counts: [u64; 7],
333}
334
335impl std::fmt::Debug for UnifiedEvidenceLedger {
336 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
337 f.debug_struct("UnifiedEvidenceLedger")
338 .field("count", &self.count)
339 .field("capacity", &self.capacity)
340 .field("next_id", &self.next_id)
341 .finish()
342 }
343}
344
345impl UnifiedEvidenceLedger {
346 pub fn new(capacity: usize) -> Self {
348 let capacity = capacity.max(1);
349 Self {
350 entries: (0..capacity).map(|_| None).collect(),
351 head: 0,
352 count: 0,
353 capacity,
354 next_id: 0,
355 domain_counts: [0; 7],
356 }
357 }
358
359 pub fn record(&mut self, mut entry: EvidenceEntry) -> u64 {
363 let id = self.next_id;
364 self.next_id += 1;
365 entry.decision_id = id;
366
367 let domain_idx = entry.domain as usize;
368 self.domain_counts[domain_idx] += 1;
369
370 self.entries[self.head] = Some(entry);
371 self.head = (self.head + 1) % self.capacity;
372 if self.count < self.capacity {
373 self.count += 1;
374 }
375 id
376 }
377
378 pub fn len(&self) -> usize {
380 self.count
381 }
382
383 pub fn is_empty(&self) -> bool {
385 self.count == 0
386 }
387
388 pub fn total_recorded(&self) -> u64 {
390 self.next_id
391 }
392
393 pub fn domain_count(&self, domain: DecisionDomain) -> u64 {
395 self.domain_counts[domain as usize]
396 }
397
398 pub fn entries(&self) -> impl Iterator<Item = &EvidenceEntry> {
400 let cap = self.capacity;
401 let count = self.count;
402 let head = self.head;
403 let start = if count < cap { 0 } else { head };
404
405 (0..count).filter_map(move |i| {
406 let idx = (start + i) % cap;
407 self.entries[idx].as_ref()
408 })
409 }
410
411 pub fn entries_for_domain(
413 &self,
414 domain: DecisionDomain,
415 ) -> impl Iterator<Item = &EvidenceEntry> {
416 self.entries().filter(move |e| e.domain == domain)
417 }
418
419 pub fn last_entry(&self) -> Option<&EvidenceEntry> {
421 if self.count == 0 {
422 return None;
423 }
424 let idx = if self.head == 0 {
425 self.capacity - 1
426 } else {
427 self.head - 1
428 };
429 self.entries[idx].as_ref()
430 }
431
432 pub fn last_entry_for_domain(&self, domain: DecisionDomain) -> Option<&EvidenceEntry> {
434 let start = if self.head == 0 {
436 self.capacity - 1
437 } else {
438 self.head - 1
439 };
440 for i in 0..self.count {
441 let idx = (start + self.capacity - i) % self.capacity;
442 if let Some(entry) = &self.entries[idx]
443 && entry.domain == domain
444 {
445 return Some(entry);
446 }
447 }
448 None
449 }
450
451 pub fn export_jsonl(&self) -> String {
453 let mut out = String::new();
454 for entry in self.entries() {
455 out.push_str(&entry.to_jsonl());
456 out.push('\n');
457 }
458 out
459 }
460
461 pub fn flush_to_sink(&self, sink: &crate::evidence_sink::EvidenceSink) -> std::io::Result<()> {
463 for entry in self.entries() {
464 sink.write_jsonl(&entry.to_jsonl())?;
465 }
466 Ok(())
467 }
468
469 pub fn clear(&mut self) {
471 for slot in &mut self.entries {
472 *slot = None;
473 }
474 self.head = 0;
475 self.count = 0;
476 }
477
478 pub fn summary(&self) -> LedgerSummary {
480 let mut per_domain = [(0u64, 0.0f64, 0.0f64); 7]; for entry in self.entries() {
482 let idx = entry.domain as usize;
483 per_domain[idx].0 += 1;
484 per_domain[idx].1 += entry.loss_avoided;
485 per_domain[idx].2 += entry.posterior_probability();
486 }
487
488 let domains: Vec<DomainSummary> = DecisionDomain::ALL
489 .iter()
490 .enumerate()
491 .filter(|(i, _)| per_domain[*i].0 > 0)
492 .map(|(i, domain)| {
493 let (count, sum_loss, sum_posterior) = per_domain[i];
494 DomainSummary {
495 domain: *domain,
496 decision_count: count,
497 mean_loss_avoided: sum_loss / count as f64,
498 mean_posterior: sum_posterior / count as f64,
499 }
500 })
501 .collect();
502
503 LedgerSummary {
504 total_decisions: self.next_id,
505 stored_decisions: self.count as u64,
506 domains,
507 }
508 }
509}
510
511#[derive(Debug, Clone)]
513pub struct LedgerSummary {
514 pub total_decisions: u64,
516 pub stored_decisions: u64,
518 pub domains: Vec<DomainSummary>,
520}
521
522#[derive(Debug, Clone)]
524pub struct DomainSummary {
525 pub domain: DecisionDomain,
527 pub decision_count: u64,
529 pub mean_loss_avoided: f64,
531 pub mean_posterior: f64,
533}
534
535pub trait EmitsEvidence {
544 fn to_evidence_entry(&self, timestamp_ns: u64) -> EvidenceEntry;
546
547 fn evidence_domain(&self) -> DecisionDomain;
549}
550
551#[cfg(test)]
556mod tests {
557 use super::*;
558
559 fn make_entry(domain: DecisionDomain, action: &'static str) -> EvidenceEntry {
560 EvidenceEntry {
561 decision_id: 0, timestamp_ns: 1_000_000,
563 domain,
564 log_posterior: 1.386, top_evidence: [
566 Some(EvidenceTerm::new("change_rate", 4.0)),
567 Some(EvidenceTerm::new("dirty_rows", 2.5)),
568 None,
569 ],
570 action,
571 loss_avoided: 0.15,
572 confidence_interval: (0.72, 0.95),
573 }
574 }
575
576 #[test]
577 fn empty_ledger() {
578 let ledger = UnifiedEvidenceLedger::new(100);
579 assert!(ledger.is_empty());
580 assert_eq!(ledger.len(), 0);
581 assert_eq!(ledger.total_recorded(), 0);
582 assert!(ledger.last_entry().is_none());
583 }
584
585 #[test]
586 fn record_single() {
587 let mut ledger = UnifiedEvidenceLedger::new(100);
588 let id = ledger.record(make_entry(DecisionDomain::DiffStrategy, "dirty_rows"));
589 assert_eq!(id, 0);
590 assert_eq!(ledger.len(), 1);
591 assert_eq!(ledger.total_recorded(), 1);
592 assert_eq!(ledger.last_entry().unwrap().action, "dirty_rows");
593 }
594
595 #[test]
596 fn record_multiple_domains() {
597 let mut ledger = UnifiedEvidenceLedger::new(100);
598 ledger.record(make_entry(DecisionDomain::DiffStrategy, "dirty_rows"));
599 ledger.record(make_entry(DecisionDomain::ResizeCoalescing, "coalesce"));
600 ledger.record(make_entry(DecisionDomain::HintRanking, "rank_3"));
601
602 assert_eq!(ledger.len(), 3);
603 assert_eq!(ledger.domain_count(DecisionDomain::DiffStrategy), 1);
604 assert_eq!(ledger.domain_count(DecisionDomain::ResizeCoalescing), 1);
605 assert_eq!(ledger.domain_count(DecisionDomain::HintRanking), 1);
606 assert_eq!(ledger.domain_count(DecisionDomain::FrameBudget), 0);
607 }
608
609 #[test]
610 fn ring_buffer_wraps() {
611 let mut ledger = UnifiedEvidenceLedger::new(5);
612 for i in 0..10u64 {
613 let mut e = make_entry(DecisionDomain::DiffStrategy, "full");
614 e.timestamp_ns = i * 1000;
615 ledger.record(e);
616 }
617 assert_eq!(ledger.len(), 5);
618 assert_eq!(ledger.total_recorded(), 10);
619
620 let ids: Vec<u64> = ledger.entries().map(|e| e.decision_id).collect();
621 assert_eq!(ids, vec![5, 6, 7, 8, 9]);
622 }
623
624 #[test]
625 fn entries_for_domain() {
626 let mut ledger = UnifiedEvidenceLedger::new(100);
627 ledger.record(make_entry(DecisionDomain::DiffStrategy, "full"));
628 ledger.record(make_entry(DecisionDomain::ResizeCoalescing, "apply"));
629 ledger.record(make_entry(DecisionDomain::DiffStrategy, "dirty_rows"));
630
631 let diff_entries: Vec<&str> = ledger
632 .entries_for_domain(DecisionDomain::DiffStrategy)
633 .map(|e| e.action)
634 .collect();
635 assert_eq!(diff_entries, vec!["full", "dirty_rows"]);
636 }
637
638 #[test]
639 fn last_entry_for_domain() {
640 let mut ledger = UnifiedEvidenceLedger::new(100);
641 ledger.record(make_entry(DecisionDomain::DiffStrategy, "full"));
642 ledger.record(make_entry(DecisionDomain::ResizeCoalescing, "apply"));
643 ledger.record(make_entry(DecisionDomain::DiffStrategy, "dirty_rows"));
644
645 let last = ledger
646 .last_entry_for_domain(DecisionDomain::DiffStrategy)
647 .unwrap();
648 assert_eq!(last.action, "dirty_rows");
649
650 let last_resize = ledger
651 .last_entry_for_domain(DecisionDomain::ResizeCoalescing)
652 .unwrap();
653 assert_eq!(last_resize.action, "apply");
654
655 assert!(
656 ledger
657 .last_entry_for_domain(DecisionDomain::FrameBudget)
658 .is_none()
659 );
660 }
661
662 #[test]
663 fn posterior_probability() {
664 let entry = make_entry(DecisionDomain::DiffStrategy, "full");
665 let prob = entry.posterior_probability();
666 assert!((prob - 0.8).abs() < 0.01);
668 }
669
670 #[test]
671 fn posterior_probability_extreme_log_odds_stays_finite() {
672 let mut high = make_entry(DecisionDomain::DiffStrategy, "full");
673 high.log_posterior = 1000.0;
674 let high_prob = high.posterior_probability();
675 assert!(high_prob.is_finite());
676 assert!(high_prob > 0.999_999);
677
678 let mut low = make_entry(DecisionDomain::DiffStrategy, "full");
679 low.log_posterior = -1000.0;
680 let low_prob = low.posterior_probability();
681 assert!(low_prob.is_finite());
682 assert!(low_prob < 0.000_001);
683 }
684
685 #[test]
686 fn evidence_count() {
687 let entry = make_entry(DecisionDomain::DiffStrategy, "full");
688 assert_eq!(entry.evidence_count(), 2); }
690
691 #[test]
692 fn combined_log_bf() {
693 let entry = make_entry(DecisionDomain::DiffStrategy, "full");
694 let expected = 4.0f64.ln() + 2.5f64.ln();
695 assert!((entry.combined_log_bf() - expected).abs() < 1e-10);
696 }
697
698 #[test]
699 fn jsonl_output() {
700 let entry = make_entry(DecisionDomain::DiffStrategy, "dirty_rows");
701 let jsonl = entry.to_jsonl();
702 assert!(jsonl.contains("\"schema\":\"ftui-evidence-v2\""));
703 assert!(jsonl.contains("\"domain\":\"diff_strategy\""));
704 assert!(jsonl.contains("\"action\":\"dirty_rows\""));
705 assert!(jsonl.contains("\"change_rate\""));
706 assert!(jsonl.contains("\"bf\":4.0"));
707 assert!(jsonl.contains("\"ci\":["));
708 assert!(!jsonl.contains('\n'));
710 }
711
712 #[test]
713 fn export_jsonl() {
714 let mut ledger = UnifiedEvidenceLedger::new(100);
715 ledger.record(make_entry(DecisionDomain::DiffStrategy, "full"));
716 ledger.record(make_entry(DecisionDomain::ResizeCoalescing, "apply"));
717 let output = ledger.export_jsonl();
718 let lines: Vec<&str> = output.lines().collect();
719 assert_eq!(lines.len(), 2);
720 assert!(lines[0].contains("diff_strategy"));
721 assert!(lines[1].contains("resize_coalescing"));
722 }
723
724 #[test]
725 fn clear() {
726 let mut ledger = UnifiedEvidenceLedger::new(100);
727 ledger.record(make_entry(DecisionDomain::DiffStrategy, "full"));
728 ledger.record(make_entry(DecisionDomain::DiffStrategy, "dirty_rows"));
729 ledger.clear();
730 assert!(ledger.is_empty());
731 assert_eq!(ledger.total_recorded(), 2); assert!(ledger.last_entry().is_none());
733 }
734
735 #[test]
736 fn summary() {
737 let mut ledger = UnifiedEvidenceLedger::new(100);
738 for _ in 0..5 {
739 ledger.record(make_entry(DecisionDomain::DiffStrategy, "full"));
740 }
741 for _ in 0..3 {
742 ledger.record(make_entry(DecisionDomain::HintRanking, "rank_1"));
743 }
744
745 let summary = ledger.summary();
746 assert_eq!(summary.total_decisions, 8);
747 assert_eq!(summary.stored_decisions, 8);
748 assert_eq!(summary.domains.len(), 2);
749
750 let diff = summary
751 .domains
752 .iter()
753 .find(|d| d.domain == DecisionDomain::DiffStrategy)
754 .unwrap();
755 assert_eq!(diff.decision_count, 5);
756 assert!(diff.mean_posterior > 0.0);
757 }
758
759 #[test]
760 fn summary_mean_posterior_is_finite_for_extreme_log_odds() {
761 let mut ledger = UnifiedEvidenceLedger::new(10);
762 let mut entry = make_entry(DecisionDomain::DiffStrategy, "full");
763 entry.log_posterior = 1000.0;
764 ledger.record(entry.clone());
765 ledger.record(entry);
766
767 let summary = ledger.summary();
768 let diff = summary
769 .domains
770 .iter()
771 .find(|domain| domain.domain == DecisionDomain::DiffStrategy)
772 .expect("diff strategy summary");
773 assert!(diff.mean_posterior.is_finite());
774 assert!(diff.mean_posterior > 0.999_999);
775 }
776
777 #[test]
778 fn builder_selects_top_3() {
779 let entry = EvidenceEntryBuilder::new(DecisionDomain::PaletteScoring, 0, 1000)
780 .log_posterior(2.0)
781 .evidence("match_type", 9.0) .evidence("position", 1.5) .evidence("word_boundary", 2.0) .evidence("gap_penalty", 0.5) .evidence("tag_match", 3.0) .action("exact")
787 .loss_avoided(0.8)
788 .confidence_interval(0.90, 0.99)
789 .build();
790
791 assert_eq!(entry.evidence_count(), 3);
794 assert_eq!(entry.top_evidence[0].as_ref().unwrap().label, "match_type");
795 assert_eq!(entry.top_evidence[1].as_ref().unwrap().label, "tag_match");
796 let third = entry.top_evidence[2].as_ref().unwrap().label;
798 assert!(
799 third == "word_boundary" || third == "gap_penalty",
800 "unexpected third: {third}"
801 );
802 }
803
804 #[test]
805 fn builder_fewer_than_3() {
806 let entry = EvidenceEntryBuilder::new(DecisionDomain::FrameBudget, 0, 1000)
807 .evidence("frame_time", 2.0)
808 .action("hold")
809 .build();
810
811 assert_eq!(entry.evidence_count(), 1);
812 assert!(entry.top_evidence[1].is_none());
813 assert!(entry.top_evidence[2].is_none());
814 }
815
816 #[test]
817 fn domain_all_covers_seven() {
818 assert_eq!(DecisionDomain::ALL.len(), 7);
819 }
820
821 #[test]
822 fn domain_as_str_roundtrip() {
823 for domain in DecisionDomain::ALL {
824 let s = domain.as_str();
825 assert!(!s.is_empty());
826 assert!(s.chars().all(|c| c.is_ascii_lowercase() || c == '_'));
827 }
828 }
829
830 #[test]
831 fn minimum_capacity() {
832 let mut ledger = UnifiedEvidenceLedger::new(0); ledger.record(make_entry(DecisionDomain::DiffStrategy, "full"));
834 assert_eq!(ledger.len(), 1);
835 ledger.record(make_entry(DecisionDomain::DiffStrategy, "dirty_rows"));
836 assert_eq!(ledger.len(), 1); assert_eq!(ledger.last_entry().unwrap().action, "dirty_rows");
838 }
839
840 #[test]
841 fn debug_format() {
842 let ledger = UnifiedEvidenceLedger::new(100);
843 let debug = format!("{ledger:?}");
844 assert!(debug.contains("UnifiedEvidenceLedger"));
845 assert!(debug.contains("count: 0"));
846 }
847
848 #[test]
849 fn entries_order_before_wrap() {
850 let mut ledger = UnifiedEvidenceLedger::new(10);
851 for i in 0..5u64 {
852 let mut e = make_entry(DecisionDomain::DiffStrategy, "full");
853 e.timestamp_ns = i;
854 ledger.record(e);
855 }
856 let ids: Vec<u64> = ledger.entries().map(|e| e.decision_id).collect();
857 assert_eq!(ids, vec![0, 1, 2, 3, 4]);
858 }
859
860 #[test]
861 fn evidence_term_log_bf() {
862 let term = EvidenceTerm::new("test", 4.0);
863 assert!((term.log_bf() - 4.0f64.ln()).abs() < 1e-10);
864 }
865
866 #[test]
867 fn loss_avoided_nonnegative_for_optimal() {
868 let entry = make_entry(DecisionDomain::DiffStrategy, "full");
869 assert!(entry.loss_avoided >= 0.0);
870 }
871
872 #[test]
873 fn confidence_interval_bounds() {
874 let entry = make_entry(DecisionDomain::DiffStrategy, "full");
875 assert!(entry.confidence_interval.0 <= entry.confidence_interval.1);
876 assert!(entry.confidence_interval.0 >= 0.0);
877 assert!(entry.confidence_interval.1 <= 1.0);
878 }
879
880 #[test]
881 fn flush_to_sink_writes_all() {
882 let mut ledger = UnifiedEvidenceLedger::new(100);
883 ledger.record(make_entry(DecisionDomain::DiffStrategy, "full"));
884 ledger.record(make_entry(DecisionDomain::HintRanking, "rank_1"));
885
886 let config = crate::evidence_sink::EvidenceSinkConfig::enabled_stdout();
887 if let Ok(Some(sink)) = crate::evidence_sink::EvidenceSink::from_config(&config) {
888 let result = ledger.flush_to_sink(&sink);
889 assert!(result.is_ok());
890 }
891 }
892
893 #[test]
894 fn simulate_mixed_domains() {
895 let mut ledger = UnifiedEvidenceLedger::new(10_000);
896 let domains = DecisionDomain::ALL;
897 let actions = [
898 "full",
899 "coalesce",
900 "hold",
901 "degrade_1",
902 "sample",
903 "rank_1",
904 "exact",
905 ];
906
907 for i in 0..1000u64 {
908 let domain = domains[(i as usize) % 7];
909 let action = actions[(i as usize) % 7];
910 let mut e = make_entry(domain, action);
911 e.timestamp_ns = i * 16_000; ledger.record(e);
913 }
914
915 assert_eq!(ledger.len(), 1000);
916 assert_eq!(ledger.total_recorded(), 1000);
917
918 for domain in DecisionDomain::ALL {
920 let count = ledger.domain_count(domain);
921 assert!(
922 (142..=143).contains(&count),
923 "{:?}: expected ~142, got {}",
924 domain,
925 count
926 );
927 }
928
929 let jsonl = ledger.export_jsonl();
931 assert_eq!(jsonl.lines().count(), 1000);
932 }
933
934 #[test]
937 fn jsonl_roundtrip_all_fields() {
938 let entry = EvidenceEntryBuilder::new(DecisionDomain::DiffStrategy, 42, 999_000)
939 .log_posterior(1.386)
940 .evidence("change_rate", 4.0)
941 .evidence("dirty_ratio", 2.5)
942 .action("dirty_rows")
943 .loss_avoided(0.15)
944 .confidence_interval(0.72, 0.95)
945 .build();
946
947 let jsonl = entry.to_jsonl();
948 let parsed: serde_json::Value = serde_json::from_str(&jsonl).expect("valid JSON");
949
950 assert_eq!(parsed["schema"], "ftui-evidence-v2");
952 assert_eq!(parsed["id"], 42);
953 assert_eq!(parsed["ts_ns"], 999_000);
954 assert_eq!(parsed["domain"], "diff_strategy");
955 assert!(parsed["log_posterior"].as_f64().is_some());
956 assert_eq!(parsed["action"], "dirty_rows");
957 assert!(parsed["loss_avoided"].as_f64().unwrap() > 0.0);
958
959 let evidence = parsed["evidence"].as_array().expect("evidence is array");
961 assert_eq!(evidence.len(), 2);
962 assert_eq!(evidence[0]["label"], "change_rate");
963 assert!(evidence[0]["bf"].as_f64().unwrap() > 0.0);
964
965 let ci = parsed["ci"].as_array().expect("ci is array");
967 assert_eq!(ci.len(), 2);
968 let lower = ci[0].as_f64().unwrap();
969 let upper = ci[1].as_f64().unwrap();
970 assert!(lower < upper);
971 }
972
973 #[test]
974 fn jsonl_schema_required_fields_present() {
975 let required_keys = [
977 "schema",
978 "id",
979 "ts_ns",
980 "domain",
981 "log_posterior",
982 "evidence",
983 "action",
984 "loss_avoided",
985 "ci",
986 ];
987
988 for (i, domain) in DecisionDomain::ALL.iter().enumerate() {
989 let entry = EvidenceEntryBuilder::new(*domain, i as u64, (i as u64 + 1) * 1000)
990 .log_posterior(0.5)
991 .evidence("test_signal", 2.0)
992 .action("test_action")
993 .loss_avoided(0.01)
994 .confidence_interval(0.4, 0.6)
995 .build();
996
997 let jsonl = entry.to_jsonl();
998 let parsed: serde_json::Value = serde_json::from_str(&jsonl).unwrap();
999
1000 for key in &required_keys {
1001 assert!(
1002 !parsed[key].is_null(),
1003 "domain {:?} missing required key '{}'",
1004 domain,
1005 key
1006 );
1007 }
1008
1009 assert_eq!(parsed["domain"], domain.as_str());
1011 }
1012 }
1013
1014 #[test]
1015 fn jsonl_backward_compat_extra_fields_ignored() {
1016 let future_jsonl = concat!(
1019 r#"{"schema":"ftui-evidence-v2","id":1,"ts_ns":5000,"domain":"diff_strategy","#,
1020 r#""log_posterior":1.386,"evidence":[{"label":"change_rate","bf":4.0}],"#,
1021 r#""action":"dirty_rows","loss_avoided":0.15,"ci":[0.72,0.95],"#,
1022 r#""new_optional_field":"future_value","extra_metric":42.5}"#
1023 );
1024
1025 let parsed: serde_json::Value =
1026 serde_json::from_str(future_jsonl).expect("extra fields should not break parsing");
1027
1028 assert_eq!(parsed["schema"], "ftui-evidence-v2");
1030 assert_eq!(parsed["id"], 1);
1031 assert_eq!(parsed["domain"], "diff_strategy");
1032 assert_eq!(parsed["action"], "dirty_rows");
1033 assert!(parsed["log_posterior"].as_f64().is_some());
1034 assert!(parsed["evidence"].as_array().is_some());
1035 assert!(parsed["ci"].as_array().is_some());
1036 }
1037
1038 #[test]
1039 fn jsonl_backward_compat_missing_optional_evidence() {
1040 let entry = EvidenceEntryBuilder::new(DecisionDomain::FrameBudget, 0, 1000)
1042 .log_posterior(0.0)
1043 .action("hold")
1044 .build();
1045
1046 let jsonl = entry.to_jsonl();
1047 let parsed: serde_json::Value = serde_json::from_str(&jsonl).unwrap();
1048 let evidence = parsed["evidence"].as_array().unwrap();
1049 assert!(evidence.is_empty(), "no evidence terms → empty array");
1050 }
1051
1052 #[test]
1053 fn diff_strategy_evidence_format() {
1054 let evidence = ftui_render::diff_strategy::StrategyEvidence {
1057 strategy: ftui_render::diff_strategy::DiffStrategy::DirtyRows,
1058 cost_full: 1.0,
1059 cost_dirty: 0.5,
1060 cost_redraw: 2.0,
1061 posterior_mean: 0.05,
1062 posterior_variance: 0.001,
1063 alpha: 2.0,
1064 beta: 38.0,
1065 dirty_rows: 3,
1066 total_rows: 24,
1067 total_cells: 1920,
1068 guard_reason: "none",
1069 hysteresis_applied: false,
1070 hysteresis_ratio: 0.05,
1071 };
1072
1073 let entry = crate::evidence_bridges::from_diff_strategy(&evidence, 100_000);
1074 let jsonl = entry.to_jsonl();
1075 let parsed: serde_json::Value = serde_json::from_str(&jsonl).unwrap();
1076
1077 assert_eq!(parsed["domain"], "diff_strategy");
1078 assert_eq!(parsed["action"], "dirty_rows");
1079
1080 let ev_array = parsed["evidence"].as_array().unwrap();
1082 let labels: Vec<&str> = ev_array
1083 .iter()
1084 .map(|e| e["label"].as_str().unwrap())
1085 .collect();
1086 assert!(
1087 labels.contains(&"change_rate"),
1088 "missing change_rate evidence"
1089 );
1090 assert!(
1091 labels.contains(&"dirty_ratio"),
1092 "missing dirty_ratio evidence"
1093 );
1094
1095 let ci = parsed["ci"].as_array().unwrap();
1097 let lower = ci[0].as_f64().unwrap();
1098 let upper = ci[1].as_f64().unwrap();
1099 assert!(
1100 (0.0..=1.0).contains(&lower),
1101 "CI lower out of range: {lower}"
1102 );
1103 assert!(
1104 (0.0..=1.0).contains(&upper),
1105 "CI upper out of range: {upper}"
1106 );
1107 assert!(lower <= upper, "CI lower > upper");
1108 }
1109}