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 self.entries.fill_with(|| None);
472 self.head = 0;
473 self.count = 0;
474 }
475
476 pub fn summary(&self) -> LedgerSummary {
478 let mut per_domain = [(0u64, 0.0f64, 0.0f64); 7]; for entry in self.entries() {
480 let idx = entry.domain as usize;
481 per_domain[idx].0 += 1;
482 per_domain[idx].1 += entry.loss_avoided;
483 per_domain[idx].2 += entry.posterior_probability();
484 }
485
486 let domains: Vec<DomainSummary> = DecisionDomain::ALL
487 .iter()
488 .enumerate()
489 .filter(|(i, _)| per_domain[*i].0 > 0)
490 .map(|(i, domain)| {
491 let (count, sum_loss, sum_posterior) = per_domain[i];
492 DomainSummary {
493 domain: *domain,
494 decision_count: count,
495 mean_loss_avoided: sum_loss / count as f64,
496 mean_posterior: sum_posterior / count as f64,
497 }
498 })
499 .collect();
500
501 LedgerSummary {
502 total_decisions: self.next_id,
503 stored_decisions: self.count as u64,
504 domains,
505 }
506 }
507}
508
509#[derive(Debug, Clone)]
511pub struct LedgerSummary {
512 pub total_decisions: u64,
514 pub stored_decisions: u64,
516 pub domains: Vec<DomainSummary>,
518}
519
520#[derive(Debug, Clone)]
522pub struct DomainSummary {
523 pub domain: DecisionDomain,
525 pub decision_count: u64,
527 pub mean_loss_avoided: f64,
529 pub mean_posterior: f64,
531}
532
533pub trait EmitsEvidence {
542 fn to_evidence_entry(&self, timestamp_ns: u64) -> EvidenceEntry;
544
545 fn evidence_domain(&self) -> DecisionDomain;
547}
548
549#[cfg(test)]
554mod tests {
555 use super::*;
556
557 fn make_entry(domain: DecisionDomain, action: &'static str) -> EvidenceEntry {
558 EvidenceEntry {
559 decision_id: 0, timestamp_ns: 1_000_000,
561 domain,
562 log_posterior: 1.386, top_evidence: [
564 Some(EvidenceTerm::new("change_rate", 4.0)),
565 Some(EvidenceTerm::new("dirty_rows", 2.5)),
566 None,
567 ],
568 action,
569 loss_avoided: 0.15,
570 confidence_interval: (0.72, 0.95),
571 }
572 }
573
574 #[test]
575 fn empty_ledger() {
576 let ledger = UnifiedEvidenceLedger::new(100);
577 assert!(ledger.is_empty());
578 assert_eq!(ledger.len(), 0);
579 assert_eq!(ledger.total_recorded(), 0);
580 assert!(ledger.last_entry().is_none());
581 }
582
583 #[test]
584 fn record_single() {
585 let mut ledger = UnifiedEvidenceLedger::new(100);
586 let id = ledger.record(make_entry(DecisionDomain::DiffStrategy, "dirty_rows"));
587 assert_eq!(id, 0);
588 assert_eq!(ledger.len(), 1);
589 assert_eq!(ledger.total_recorded(), 1);
590 assert_eq!(ledger.last_entry().unwrap().action, "dirty_rows");
591 }
592
593 #[test]
594 fn record_multiple_domains() {
595 let mut ledger = UnifiedEvidenceLedger::new(100);
596 ledger.record(make_entry(DecisionDomain::DiffStrategy, "dirty_rows"));
597 ledger.record(make_entry(DecisionDomain::ResizeCoalescing, "coalesce"));
598 ledger.record(make_entry(DecisionDomain::HintRanking, "rank_3"));
599
600 assert_eq!(ledger.len(), 3);
601 assert_eq!(ledger.domain_count(DecisionDomain::DiffStrategy), 1);
602 assert_eq!(ledger.domain_count(DecisionDomain::ResizeCoalescing), 1);
603 assert_eq!(ledger.domain_count(DecisionDomain::HintRanking), 1);
604 assert_eq!(ledger.domain_count(DecisionDomain::FrameBudget), 0);
605 }
606
607 #[test]
608 fn ring_buffer_wraps() {
609 let mut ledger = UnifiedEvidenceLedger::new(5);
610 for i in 0..10u64 {
611 let mut e = make_entry(DecisionDomain::DiffStrategy, "full");
612 e.timestamp_ns = i * 1000;
613 ledger.record(e);
614 }
615 assert_eq!(ledger.len(), 5);
616 assert_eq!(ledger.total_recorded(), 10);
617
618 let ids: Vec<u64> = ledger.entries().map(|e| e.decision_id).collect();
619 assert_eq!(ids, vec![5, 6, 7, 8, 9]);
620 }
621
622 #[test]
623 fn entries_for_domain() {
624 let mut ledger = UnifiedEvidenceLedger::new(100);
625 ledger.record(make_entry(DecisionDomain::DiffStrategy, "full"));
626 ledger.record(make_entry(DecisionDomain::ResizeCoalescing, "apply"));
627 ledger.record(make_entry(DecisionDomain::DiffStrategy, "dirty_rows"));
628
629 let diff_entries: Vec<&str> = ledger
630 .entries_for_domain(DecisionDomain::DiffStrategy)
631 .map(|e| e.action)
632 .collect();
633 assert_eq!(diff_entries, vec!["full", "dirty_rows"]);
634 }
635
636 #[test]
637 fn last_entry_for_domain() {
638 let mut ledger = UnifiedEvidenceLedger::new(100);
639 ledger.record(make_entry(DecisionDomain::DiffStrategy, "full"));
640 ledger.record(make_entry(DecisionDomain::ResizeCoalescing, "apply"));
641 ledger.record(make_entry(DecisionDomain::DiffStrategy, "dirty_rows"));
642
643 let last = ledger
644 .last_entry_for_domain(DecisionDomain::DiffStrategy)
645 .unwrap();
646 assert_eq!(last.action, "dirty_rows");
647
648 let last_resize = ledger
649 .last_entry_for_domain(DecisionDomain::ResizeCoalescing)
650 .unwrap();
651 assert_eq!(last_resize.action, "apply");
652
653 assert!(
654 ledger
655 .last_entry_for_domain(DecisionDomain::FrameBudget)
656 .is_none()
657 );
658 }
659
660 #[test]
661 fn posterior_probability() {
662 let entry = make_entry(DecisionDomain::DiffStrategy, "full");
663 let prob = entry.posterior_probability();
664 assert!((prob - 0.8).abs() < 0.01);
666 }
667
668 #[test]
669 fn posterior_probability_extreme_log_odds_stays_finite() {
670 let mut high = make_entry(DecisionDomain::DiffStrategy, "full");
671 high.log_posterior = 1000.0;
672 let high_prob = high.posterior_probability();
673 assert!(high_prob.is_finite());
674 assert!(high_prob > 0.999_999);
675
676 let mut low = make_entry(DecisionDomain::DiffStrategy, "full");
677 low.log_posterior = -1000.0;
678 let low_prob = low.posterior_probability();
679 assert!(low_prob.is_finite());
680 assert!(low_prob < 0.000_001);
681 }
682
683 #[test]
684 fn evidence_count() {
685 let entry = make_entry(DecisionDomain::DiffStrategy, "full");
686 assert_eq!(entry.evidence_count(), 2); }
688
689 #[test]
690 fn combined_log_bf() {
691 let entry = make_entry(DecisionDomain::DiffStrategy, "full");
692 let expected = 4.0f64.ln() + 2.5f64.ln();
693 assert!((entry.combined_log_bf() - expected).abs() < 1e-10);
694 }
695
696 #[test]
697 fn jsonl_output() {
698 let entry = make_entry(DecisionDomain::DiffStrategy, "dirty_rows");
699 let jsonl = entry.to_jsonl();
700 assert!(jsonl.contains("\"schema\":\"ftui-evidence-v2\""));
701 assert!(jsonl.contains("\"domain\":\"diff_strategy\""));
702 assert!(jsonl.contains("\"action\":\"dirty_rows\""));
703 assert!(jsonl.contains("\"change_rate\""));
704 assert!(jsonl.contains("\"bf\":4.0"));
705 assert!(jsonl.contains("\"ci\":["));
706 assert!(!jsonl.contains('\n'));
708 }
709
710 #[test]
711 fn export_jsonl() {
712 let mut ledger = UnifiedEvidenceLedger::new(100);
713 ledger.record(make_entry(DecisionDomain::DiffStrategy, "full"));
714 ledger.record(make_entry(DecisionDomain::ResizeCoalescing, "apply"));
715 let output = ledger.export_jsonl();
716 let lines: Vec<&str> = output.lines().collect();
717 assert_eq!(lines.len(), 2);
718 assert!(lines[0].contains("diff_strategy"));
719 assert!(lines[1].contains("resize_coalescing"));
720 }
721
722 #[test]
723 fn clear() {
724 let mut ledger = UnifiedEvidenceLedger::new(100);
725 ledger.record(make_entry(DecisionDomain::DiffStrategy, "full"));
726 ledger.record(make_entry(DecisionDomain::DiffStrategy, "dirty_rows"));
727 ledger.clear();
728 assert!(ledger.is_empty());
729 assert_eq!(ledger.total_recorded(), 2); assert!(ledger.last_entry().is_none());
731 }
732
733 #[test]
734 fn summary() {
735 let mut ledger = UnifiedEvidenceLedger::new(100);
736 for _ in 0..5 {
737 ledger.record(make_entry(DecisionDomain::DiffStrategy, "full"));
738 }
739 for _ in 0..3 {
740 ledger.record(make_entry(DecisionDomain::HintRanking, "rank_1"));
741 }
742
743 let summary = ledger.summary();
744 assert_eq!(summary.total_decisions, 8);
745 assert_eq!(summary.stored_decisions, 8);
746 assert_eq!(summary.domains.len(), 2);
747
748 let diff = summary
749 .domains
750 .iter()
751 .find(|d| d.domain == DecisionDomain::DiffStrategy)
752 .unwrap();
753 assert_eq!(diff.decision_count, 5);
754 assert!(diff.mean_posterior > 0.0);
755 }
756
757 #[test]
758 fn summary_mean_posterior_is_finite_for_extreme_log_odds() {
759 let mut ledger = UnifiedEvidenceLedger::new(10);
760 let mut entry = make_entry(DecisionDomain::DiffStrategy, "full");
761 entry.log_posterior = 1000.0;
762 ledger.record(entry.clone());
763 ledger.record(entry);
764
765 let summary = ledger.summary();
766 let diff = summary
767 .domains
768 .iter()
769 .find(|domain| domain.domain == DecisionDomain::DiffStrategy)
770 .expect("diff strategy summary");
771 assert!(diff.mean_posterior.is_finite());
772 assert!(diff.mean_posterior > 0.999_999);
773 }
774
775 #[test]
776 fn builder_selects_top_3() {
777 let entry = EvidenceEntryBuilder::new(DecisionDomain::PaletteScoring, 0, 1000)
778 .log_posterior(2.0)
779 .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")
785 .loss_avoided(0.8)
786 .confidence_interval(0.90, 0.99)
787 .build();
788
789 assert_eq!(entry.evidence_count(), 3);
792 assert_eq!(entry.top_evidence[0].as_ref().unwrap().label, "match_type");
793 assert_eq!(entry.top_evidence[1].as_ref().unwrap().label, "tag_match");
794 let third = entry.top_evidence[2].as_ref().unwrap().label;
796 assert!(
797 third == "word_boundary" || third == "gap_penalty",
798 "unexpected third: {third}"
799 );
800 }
801
802 #[test]
803 fn builder_fewer_than_3() {
804 let entry = EvidenceEntryBuilder::new(DecisionDomain::FrameBudget, 0, 1000)
805 .evidence("frame_time", 2.0)
806 .action("hold")
807 .build();
808
809 assert_eq!(entry.evidence_count(), 1);
810 assert!(entry.top_evidence[1].is_none());
811 assert!(entry.top_evidence[2].is_none());
812 }
813
814 #[test]
815 fn domain_all_covers_seven() {
816 assert_eq!(DecisionDomain::ALL.len(), 7);
817 }
818
819 #[test]
820 fn domain_as_str_roundtrip() {
821 for domain in DecisionDomain::ALL {
822 let s = domain.as_str();
823 assert!(!s.is_empty());
824 assert!(s.chars().all(|c| c.is_ascii_lowercase() || c == '_'));
825 }
826 }
827
828 #[test]
829 fn minimum_capacity() {
830 let mut ledger = UnifiedEvidenceLedger::new(0); ledger.record(make_entry(DecisionDomain::DiffStrategy, "full"));
832 assert_eq!(ledger.len(), 1);
833 ledger.record(make_entry(DecisionDomain::DiffStrategy, "dirty_rows"));
834 assert_eq!(ledger.len(), 1); assert_eq!(ledger.last_entry().unwrap().action, "dirty_rows");
836 }
837
838 #[test]
839 fn debug_format() {
840 let ledger = UnifiedEvidenceLedger::new(100);
841 let debug = format!("{ledger:?}");
842 assert!(debug.contains("UnifiedEvidenceLedger"));
843 assert!(debug.contains("count: 0"));
844 }
845
846 #[test]
847 fn entries_order_before_wrap() {
848 let mut ledger = UnifiedEvidenceLedger::new(10);
849 for i in 0..5u64 {
850 let mut e = make_entry(DecisionDomain::DiffStrategy, "full");
851 e.timestamp_ns = i;
852 ledger.record(e);
853 }
854 let ids: Vec<u64> = ledger.entries().map(|e| e.decision_id).collect();
855 assert_eq!(ids, vec![0, 1, 2, 3, 4]);
856 }
857
858 #[test]
859 fn evidence_term_log_bf() {
860 let term = EvidenceTerm::new("test", 4.0);
861 assert!((term.log_bf() - 4.0f64.ln()).abs() < 1e-10);
862 }
863
864 #[test]
865 fn loss_avoided_nonnegative_for_optimal() {
866 let entry = make_entry(DecisionDomain::DiffStrategy, "full");
867 assert!(entry.loss_avoided >= 0.0);
868 }
869
870 #[test]
871 fn confidence_interval_bounds() {
872 let entry = make_entry(DecisionDomain::DiffStrategy, "full");
873 assert!(entry.confidence_interval.0 <= entry.confidence_interval.1);
874 assert!(entry.confidence_interval.0 >= 0.0);
875 assert!(entry.confidence_interval.1 <= 1.0);
876 }
877
878 #[test]
879 fn flush_to_sink_writes_all() {
880 let mut ledger = UnifiedEvidenceLedger::new(100);
881 ledger.record(make_entry(DecisionDomain::DiffStrategy, "full"));
882 ledger.record(make_entry(DecisionDomain::HintRanking, "rank_1"));
883
884 let config = crate::evidence_sink::EvidenceSinkConfig::enabled_stdout();
885 if let Ok(Some(sink)) = crate::evidence_sink::EvidenceSink::from_config(&config) {
886 let result = ledger.flush_to_sink(&sink);
887 assert!(result.is_ok());
888 }
889 }
890
891 #[test]
892 fn simulate_mixed_domains() {
893 let mut ledger = UnifiedEvidenceLedger::new(10_000);
894 let domains = DecisionDomain::ALL;
895 let actions = [
896 "full",
897 "coalesce",
898 "hold",
899 "degrade_1",
900 "sample",
901 "rank_1",
902 "exact",
903 ];
904
905 for i in 0..1000u64 {
906 let domain = domains[(i as usize) % 7];
907 let action = actions[(i as usize) % 7];
908 let mut e = make_entry(domain, action);
909 e.timestamp_ns = i * 16_000; ledger.record(e);
911 }
912
913 assert_eq!(ledger.len(), 1000);
914 assert_eq!(ledger.total_recorded(), 1000);
915
916 for domain in DecisionDomain::ALL {
918 let count = ledger.domain_count(domain);
919 assert!(
920 (142..=143).contains(&count),
921 "{:?}: expected ~142, got {}",
922 domain,
923 count
924 );
925 }
926
927 let jsonl = ledger.export_jsonl();
929 assert_eq!(jsonl.lines().count(), 1000);
930 }
931
932 #[test]
935 fn jsonl_roundtrip_all_fields() {
936 let entry = EvidenceEntryBuilder::new(DecisionDomain::DiffStrategy, 42, 999_000)
937 .log_posterior(1.386)
938 .evidence("change_rate", 4.0)
939 .evidence("dirty_ratio", 2.5)
940 .action("dirty_rows")
941 .loss_avoided(0.15)
942 .confidence_interval(0.72, 0.95)
943 .build();
944
945 let jsonl = entry.to_jsonl();
946 let parsed: serde_json::Value = serde_json::from_str(&jsonl).expect("valid JSON");
947
948 assert_eq!(parsed["schema"], "ftui-evidence-v2");
950 assert_eq!(parsed["id"], 42);
951 assert_eq!(parsed["ts_ns"], 999_000);
952 assert_eq!(parsed["domain"], "diff_strategy");
953 assert!(parsed["log_posterior"].as_f64().is_some());
954 assert_eq!(parsed["action"], "dirty_rows");
955 assert!(parsed["loss_avoided"].as_f64().unwrap() > 0.0);
956
957 let evidence = parsed["evidence"].as_array().expect("evidence is array");
959 assert_eq!(evidence.len(), 2);
960 assert_eq!(evidence[0]["label"], "change_rate");
961 assert!(evidence[0]["bf"].as_f64().unwrap() > 0.0);
962
963 let ci = parsed["ci"].as_array().expect("ci is array");
965 assert_eq!(ci.len(), 2);
966 let lower = ci[0].as_f64().unwrap();
967 let upper = ci[1].as_f64().unwrap();
968 assert!(lower < upper);
969 }
970
971 #[test]
972 fn jsonl_schema_required_fields_present() {
973 let required_keys = [
975 "schema",
976 "id",
977 "ts_ns",
978 "domain",
979 "log_posterior",
980 "evidence",
981 "action",
982 "loss_avoided",
983 "ci",
984 ];
985
986 for (i, domain) in DecisionDomain::ALL.iter().enumerate() {
987 let entry = EvidenceEntryBuilder::new(*domain, i as u64, (i as u64 + 1) * 1000)
988 .log_posterior(0.5)
989 .evidence("test_signal", 2.0)
990 .action("test_action")
991 .loss_avoided(0.01)
992 .confidence_interval(0.4, 0.6)
993 .build();
994
995 let jsonl = entry.to_jsonl();
996 let parsed: serde_json::Value = serde_json::from_str(&jsonl).unwrap();
997
998 for key in &required_keys {
999 assert!(
1000 !parsed[key].is_null(),
1001 "domain {:?} missing required key '{}'",
1002 domain,
1003 key
1004 );
1005 }
1006
1007 assert_eq!(parsed["domain"], domain.as_str());
1009 }
1010 }
1011
1012 #[test]
1013 fn jsonl_backward_compat_extra_fields_ignored() {
1014 let future_jsonl = concat!(
1017 r#"{"schema":"ftui-evidence-v2","id":1,"ts_ns":5000,"domain":"diff_strategy","#,
1018 r#""log_posterior":1.386,"evidence":[{"label":"change_rate","bf":4.0}],"#,
1019 r#""action":"dirty_rows","loss_avoided":0.15,"ci":[0.72,0.95],"#,
1020 r#""new_optional_field":"future_value","extra_metric":42.5}"#
1021 );
1022
1023 let parsed: serde_json::Value =
1024 serde_json::from_str(future_jsonl).expect("extra fields should not break parsing");
1025
1026 assert_eq!(parsed["schema"], "ftui-evidence-v2");
1028 assert_eq!(parsed["id"], 1);
1029 assert_eq!(parsed["domain"], "diff_strategy");
1030 assert_eq!(parsed["action"], "dirty_rows");
1031 assert!(parsed["log_posterior"].as_f64().is_some());
1032 assert!(parsed["evidence"].as_array().is_some());
1033 assert!(parsed["ci"].as_array().is_some());
1034 }
1035
1036 #[test]
1037 fn jsonl_backward_compat_missing_optional_evidence() {
1038 let entry = EvidenceEntryBuilder::new(DecisionDomain::FrameBudget, 0, 1000)
1040 .log_posterior(0.0)
1041 .action("hold")
1042 .build();
1043
1044 let jsonl = entry.to_jsonl();
1045 let parsed: serde_json::Value = serde_json::from_str(&jsonl).unwrap();
1046 let evidence = parsed["evidence"].as_array().unwrap();
1047 assert!(evidence.is_empty(), "no evidence terms → empty array");
1048 }
1049
1050 #[test]
1051 fn diff_strategy_evidence_format() {
1052 let evidence = ftui_render::diff_strategy::StrategyEvidence {
1055 strategy: ftui_render::diff_strategy::DiffStrategy::DirtyRows,
1056 cost_full: 1.0,
1057 cost_dirty: 0.5,
1058 cost_redraw: 2.0,
1059 posterior_mean: 0.05,
1060 posterior_variance: 0.001,
1061 alpha: 2.0,
1062 beta: 38.0,
1063 dirty_rows: 3,
1064 total_rows: 24,
1065 total_cells: 1920,
1066 guard_reason: "none",
1067 hysteresis_applied: false,
1068 hysteresis_ratio: 0.05,
1069 };
1070
1071 let entry = crate::evidence_bridges::from_diff_strategy(&evidence, 100_000);
1072 let jsonl = entry.to_jsonl();
1073 let parsed: serde_json::Value = serde_json::from_str(&jsonl).unwrap();
1074
1075 assert_eq!(parsed["domain"], "diff_strategy");
1076 assert_eq!(parsed["action"], "dirty_rows");
1077
1078 let ev_array = parsed["evidence"].as_array().unwrap();
1080 let labels: Vec<&str> = ev_array
1081 .iter()
1082 .map(|e| e["label"].as_str().unwrap())
1083 .collect();
1084 assert!(
1085 labels.contains(&"change_rate"),
1086 "missing change_rate evidence"
1087 );
1088 assert!(
1089 labels.contains(&"dirty_ratio"),
1090 "missing dirty_ratio evidence"
1091 );
1092
1093 let ci = parsed["ci"].as_array().unwrap();
1095 let lower = ci[0].as_f64().unwrap();
1096 let upper = ci[1].as_f64().unwrap();
1097 assert!(
1098 (0.0..=1.0).contains(&lower),
1099 "CI lower out of range: {lower}"
1100 );
1101 assert!(
1102 (0.0..=1.0).contains(&upper),
1103 "CI upper out of range: {upper}"
1104 );
1105 assert!(lower <= upper, "CI lower > upper");
1106 }
1107}