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 odds = self.log_posterior.exp();
164 odds / (1.0 + odds)
165 }
166
167 #[must_use]
169 pub fn evidence_count(&self) -> usize {
170 self.top_evidence.iter().filter(|t| t.is_some()).count()
171 }
172
173 #[must_use]
175 pub fn combined_log_bf(&self) -> f64 {
176 self.top_evidence
177 .iter()
178 .filter_map(|t| t.as_ref())
179 .map(|t| t.log_bf())
180 .sum()
181 }
182
183 pub fn to_jsonl(&self) -> String {
185 let mut out = String::with_capacity(256);
186 out.push_str("{\"schema\":\"ftui-evidence-v2\"");
187 let _ = write!(out, ",\"id\":{}", self.decision_id);
188 let _ = write!(out, ",\"ts_ns\":{}", self.timestamp_ns);
189 let _ = write!(out, ",\"domain\":\"{}\"", self.domain.as_str());
190 let _ = write!(out, ",\"log_posterior\":{:.6}", self.log_posterior);
191
192 out.push_str(",\"evidence\":[");
193 let mut first = true;
194 for term in self.top_evidence.iter().flatten() {
195 if !first {
196 out.push(',');
197 }
198 first = false;
199 let _ = write!(
200 out,
201 "{{\"label\":\"{}\",\"bf\":{:.6}}}",
202 term.label, term.bayes_factor
203 );
204 }
205 out.push(']');
206
207 let _ = write!(out, ",\"action\":\"{}\"", self.action);
208 let _ = write!(out, ",\"loss_avoided\":{:.6}", self.loss_avoided);
209 let _ = write!(
210 out,
211 ",\"ci\":[{:.6},{:.6}]",
212 self.confidence_interval.0, self.confidence_interval.1
213 );
214 out.push('}');
215 out
216 }
217}
218
219pub struct EvidenceEntryBuilder {
227 decision_id: u64,
228 timestamp_ns: u64,
229 domain: DecisionDomain,
230 log_posterior: f64,
231 evidence: Vec<EvidenceTerm>,
232 action: &'static str,
233 loss_avoided: f64,
234 confidence_interval: (f64, f64),
235}
236
237impl EvidenceEntryBuilder {
238 pub fn new(domain: DecisionDomain, decision_id: u64, timestamp_ns: u64) -> Self {
240 Self {
241 decision_id,
242 timestamp_ns,
243 domain,
244 log_posterior: 0.0,
245 evidence: Vec::new(),
246 action: "",
247 loss_avoided: 0.0,
248 confidence_interval: (0.0, 1.0),
249 }
250 }
251
252 #[must_use]
254 pub fn log_posterior(mut self, value: f64) -> Self {
255 self.log_posterior = value;
256 self
257 }
258
259 #[must_use]
261 pub fn evidence(mut self, label: &'static str, bayes_factor: f64) -> Self {
262 self.evidence.push(EvidenceTerm::new(label, bayes_factor));
263 self
264 }
265
266 #[must_use]
268 pub fn action(mut self, action: &'static str) -> Self {
269 self.action = action;
270 self
271 }
272
273 #[must_use]
275 pub fn loss_avoided(mut self, value: f64) -> Self {
276 self.loss_avoided = value;
277 self
278 }
279
280 #[must_use]
282 pub fn confidence_interval(mut self, lower: f64, upper: f64) -> Self {
283 self.confidence_interval = (lower, upper);
284 self
285 }
286
287 pub fn build(mut self) -> EvidenceEntry {
289 self.evidence
291 .sort_by(|a, b| b.log_bf().abs().total_cmp(&a.log_bf().abs()));
292
293 let mut top = [None, None, None];
294 for (i, term) in self.evidence.into_iter().take(3).enumerate() {
295 top[i] = Some(term);
296 }
297
298 EvidenceEntry {
299 decision_id: self.decision_id,
300 timestamp_ns: self.timestamp_ns,
301 domain: self.domain,
302 log_posterior: self.log_posterior,
303 top_evidence: top,
304 action: self.action,
305 loss_avoided: self.loss_avoided,
306 confidence_interval: self.confidence_interval,
307 }
308 }
309}
310
311pub struct UnifiedEvidenceLedger {
321 entries: Vec<Option<EvidenceEntry>>,
322 head: usize,
323 count: usize,
324 capacity: usize,
325 next_id: u64,
326 domain_counts: [u64; 7],
328}
329
330impl std::fmt::Debug for UnifiedEvidenceLedger {
331 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
332 f.debug_struct("UnifiedEvidenceLedger")
333 .field("count", &self.count)
334 .field("capacity", &self.capacity)
335 .field("next_id", &self.next_id)
336 .finish()
337 }
338}
339
340impl UnifiedEvidenceLedger {
341 pub fn new(capacity: usize) -> Self {
343 let capacity = capacity.max(1);
344 Self {
345 entries: (0..capacity).map(|_| None).collect(),
346 head: 0,
347 count: 0,
348 capacity,
349 next_id: 0,
350 domain_counts: [0; 7],
351 }
352 }
353
354 pub fn record(&mut self, mut entry: EvidenceEntry) -> u64 {
358 let id = self.next_id;
359 self.next_id += 1;
360 entry.decision_id = id;
361
362 let domain_idx = entry.domain as usize;
363 self.domain_counts[domain_idx] += 1;
364
365 self.entries[self.head] = Some(entry);
366 self.head = (self.head + 1) % self.capacity;
367 if self.count < self.capacity {
368 self.count += 1;
369 }
370 id
371 }
372
373 pub fn len(&self) -> usize {
375 self.count
376 }
377
378 pub fn is_empty(&self) -> bool {
380 self.count == 0
381 }
382
383 pub fn total_recorded(&self) -> u64 {
385 self.next_id
386 }
387
388 pub fn domain_count(&self, domain: DecisionDomain) -> u64 {
390 self.domain_counts[domain as usize]
391 }
392
393 pub fn entries(&self) -> impl Iterator<Item = &EvidenceEntry> {
395 let cap = self.capacity;
396 let count = self.count;
397 let head = self.head;
398 let start = if count < cap { 0 } else { head };
399
400 (0..count).filter_map(move |i| {
401 let idx = (start + i) % cap;
402 self.entries[idx].as_ref()
403 })
404 }
405
406 pub fn entries_for_domain(
408 &self,
409 domain: DecisionDomain,
410 ) -> impl Iterator<Item = &EvidenceEntry> {
411 self.entries().filter(move |e| e.domain == domain)
412 }
413
414 pub fn last_entry(&self) -> Option<&EvidenceEntry> {
416 if self.count == 0 {
417 return None;
418 }
419 let idx = if self.head == 0 {
420 self.capacity - 1
421 } else {
422 self.head - 1
423 };
424 self.entries[idx].as_ref()
425 }
426
427 pub fn last_entry_for_domain(&self, domain: DecisionDomain) -> Option<&EvidenceEntry> {
429 let start = if self.head == 0 {
431 self.capacity - 1
432 } else {
433 self.head - 1
434 };
435 for i in 0..self.count {
436 let idx = (start + self.capacity - i) % self.capacity;
437 if let Some(entry) = &self.entries[idx]
438 && entry.domain == domain
439 {
440 return Some(entry);
441 }
442 }
443 None
444 }
445
446 pub fn export_jsonl(&self) -> String {
448 let mut out = String::new();
449 for entry in self.entries() {
450 out.push_str(&entry.to_jsonl());
451 out.push('\n');
452 }
453 out
454 }
455
456 pub fn flush_to_sink(&self, sink: &crate::evidence_sink::EvidenceSink) -> std::io::Result<()> {
458 for entry in self.entries() {
459 sink.write_jsonl(&entry.to_jsonl())?;
460 }
461 Ok(())
462 }
463
464 pub fn clear(&mut self) {
466 for slot in &mut self.entries {
467 *slot = None;
468 }
469 self.head = 0;
470 self.count = 0;
471 }
472
473 pub fn summary(&self) -> LedgerSummary {
475 let mut per_domain = [(0u64, 0.0f64, 0.0f64); 7]; for entry in self.entries() {
477 let idx = entry.domain as usize;
478 per_domain[idx].0 += 1;
479 per_domain[idx].1 += entry.loss_avoided;
480 per_domain[idx].2 += entry.posterior_probability();
481 }
482
483 let domains: Vec<DomainSummary> = DecisionDomain::ALL
484 .iter()
485 .enumerate()
486 .filter(|(i, _)| per_domain[*i].0 > 0)
487 .map(|(i, domain)| {
488 let (count, sum_loss, sum_posterior) = per_domain[i];
489 DomainSummary {
490 domain: *domain,
491 decision_count: count,
492 mean_loss_avoided: sum_loss / count as f64,
493 mean_posterior: sum_posterior / count as f64,
494 }
495 })
496 .collect();
497
498 LedgerSummary {
499 total_decisions: self.next_id,
500 stored_decisions: self.count as u64,
501 domains,
502 }
503 }
504}
505
506#[derive(Debug, Clone)]
508pub struct LedgerSummary {
509 pub total_decisions: u64,
511 pub stored_decisions: u64,
513 pub domains: Vec<DomainSummary>,
515}
516
517#[derive(Debug, Clone)]
519pub struct DomainSummary {
520 pub domain: DecisionDomain,
522 pub decision_count: u64,
524 pub mean_loss_avoided: f64,
526 pub mean_posterior: f64,
528}
529
530pub trait EmitsEvidence {
539 fn to_evidence_entry(&self, timestamp_ns: u64) -> EvidenceEntry;
541
542 fn evidence_domain(&self) -> DecisionDomain;
544}
545
546#[cfg(test)]
551mod tests {
552 use super::*;
553
554 fn make_entry(domain: DecisionDomain, action: &'static str) -> EvidenceEntry {
555 EvidenceEntry {
556 decision_id: 0, timestamp_ns: 1_000_000,
558 domain,
559 log_posterior: 1.386, top_evidence: [
561 Some(EvidenceTerm::new("change_rate", 4.0)),
562 Some(EvidenceTerm::new("dirty_rows", 2.5)),
563 None,
564 ],
565 action,
566 loss_avoided: 0.15,
567 confidence_interval: (0.72, 0.95),
568 }
569 }
570
571 #[test]
572 fn empty_ledger() {
573 let ledger = UnifiedEvidenceLedger::new(100);
574 assert!(ledger.is_empty());
575 assert_eq!(ledger.len(), 0);
576 assert_eq!(ledger.total_recorded(), 0);
577 assert!(ledger.last_entry().is_none());
578 }
579
580 #[test]
581 fn record_single() {
582 let mut ledger = UnifiedEvidenceLedger::new(100);
583 let id = ledger.record(make_entry(DecisionDomain::DiffStrategy, "dirty_rows"));
584 assert_eq!(id, 0);
585 assert_eq!(ledger.len(), 1);
586 assert_eq!(ledger.total_recorded(), 1);
587 assert_eq!(ledger.last_entry().unwrap().action, "dirty_rows");
588 }
589
590 #[test]
591 fn record_multiple_domains() {
592 let mut ledger = UnifiedEvidenceLedger::new(100);
593 ledger.record(make_entry(DecisionDomain::DiffStrategy, "dirty_rows"));
594 ledger.record(make_entry(DecisionDomain::ResizeCoalescing, "coalesce"));
595 ledger.record(make_entry(DecisionDomain::HintRanking, "rank_3"));
596
597 assert_eq!(ledger.len(), 3);
598 assert_eq!(ledger.domain_count(DecisionDomain::DiffStrategy), 1);
599 assert_eq!(ledger.domain_count(DecisionDomain::ResizeCoalescing), 1);
600 assert_eq!(ledger.domain_count(DecisionDomain::HintRanking), 1);
601 assert_eq!(ledger.domain_count(DecisionDomain::FrameBudget), 0);
602 }
603
604 #[test]
605 fn ring_buffer_wraps() {
606 let mut ledger = UnifiedEvidenceLedger::new(5);
607 for i in 0..10u64 {
608 let mut e = make_entry(DecisionDomain::DiffStrategy, "full");
609 e.timestamp_ns = i * 1000;
610 ledger.record(e);
611 }
612 assert_eq!(ledger.len(), 5);
613 assert_eq!(ledger.total_recorded(), 10);
614
615 let ids: Vec<u64> = ledger.entries().map(|e| e.decision_id).collect();
616 assert_eq!(ids, vec![5, 6, 7, 8, 9]);
617 }
618
619 #[test]
620 fn entries_for_domain() {
621 let mut ledger = UnifiedEvidenceLedger::new(100);
622 ledger.record(make_entry(DecisionDomain::DiffStrategy, "full"));
623 ledger.record(make_entry(DecisionDomain::ResizeCoalescing, "apply"));
624 ledger.record(make_entry(DecisionDomain::DiffStrategy, "dirty_rows"));
625
626 let diff_entries: Vec<&str> = ledger
627 .entries_for_domain(DecisionDomain::DiffStrategy)
628 .map(|e| e.action)
629 .collect();
630 assert_eq!(diff_entries, vec!["full", "dirty_rows"]);
631 }
632
633 #[test]
634 fn last_entry_for_domain() {
635 let mut ledger = UnifiedEvidenceLedger::new(100);
636 ledger.record(make_entry(DecisionDomain::DiffStrategy, "full"));
637 ledger.record(make_entry(DecisionDomain::ResizeCoalescing, "apply"));
638 ledger.record(make_entry(DecisionDomain::DiffStrategy, "dirty_rows"));
639
640 let last = ledger
641 .last_entry_for_domain(DecisionDomain::DiffStrategy)
642 .unwrap();
643 assert_eq!(last.action, "dirty_rows");
644
645 let last_resize = ledger
646 .last_entry_for_domain(DecisionDomain::ResizeCoalescing)
647 .unwrap();
648 assert_eq!(last_resize.action, "apply");
649
650 assert!(
651 ledger
652 .last_entry_for_domain(DecisionDomain::FrameBudget)
653 .is_none()
654 );
655 }
656
657 #[test]
658 fn posterior_probability() {
659 let entry = make_entry(DecisionDomain::DiffStrategy, "full");
660 let prob = entry.posterior_probability();
661 assert!((prob - 0.8).abs() < 0.01);
663 }
664
665 #[test]
666 fn evidence_count() {
667 let entry = make_entry(DecisionDomain::DiffStrategy, "full");
668 assert_eq!(entry.evidence_count(), 2); }
670
671 #[test]
672 fn combined_log_bf() {
673 let entry = make_entry(DecisionDomain::DiffStrategy, "full");
674 let expected = 4.0f64.ln() + 2.5f64.ln();
675 assert!((entry.combined_log_bf() - expected).abs() < 1e-10);
676 }
677
678 #[test]
679 fn jsonl_output() {
680 let entry = make_entry(DecisionDomain::DiffStrategy, "dirty_rows");
681 let jsonl = entry.to_jsonl();
682 assert!(jsonl.contains("\"schema\":\"ftui-evidence-v2\""));
683 assert!(jsonl.contains("\"domain\":\"diff_strategy\""));
684 assert!(jsonl.contains("\"action\":\"dirty_rows\""));
685 assert!(jsonl.contains("\"change_rate\""));
686 assert!(jsonl.contains("\"bf\":4.0"));
687 assert!(jsonl.contains("\"ci\":["));
688 assert!(!jsonl.contains('\n'));
690 }
691
692 #[test]
693 fn export_jsonl() {
694 let mut ledger = UnifiedEvidenceLedger::new(100);
695 ledger.record(make_entry(DecisionDomain::DiffStrategy, "full"));
696 ledger.record(make_entry(DecisionDomain::ResizeCoalescing, "apply"));
697 let output = ledger.export_jsonl();
698 let lines: Vec<&str> = output.lines().collect();
699 assert_eq!(lines.len(), 2);
700 assert!(lines[0].contains("diff_strategy"));
701 assert!(lines[1].contains("resize_coalescing"));
702 }
703
704 #[test]
705 fn clear() {
706 let mut ledger = UnifiedEvidenceLedger::new(100);
707 ledger.record(make_entry(DecisionDomain::DiffStrategy, "full"));
708 ledger.record(make_entry(DecisionDomain::DiffStrategy, "dirty_rows"));
709 ledger.clear();
710 assert!(ledger.is_empty());
711 assert_eq!(ledger.total_recorded(), 2); assert!(ledger.last_entry().is_none());
713 }
714
715 #[test]
716 fn summary() {
717 let mut ledger = UnifiedEvidenceLedger::new(100);
718 for _ in 0..5 {
719 ledger.record(make_entry(DecisionDomain::DiffStrategy, "full"));
720 }
721 for _ in 0..3 {
722 ledger.record(make_entry(DecisionDomain::HintRanking, "rank_1"));
723 }
724
725 let summary = ledger.summary();
726 assert_eq!(summary.total_decisions, 8);
727 assert_eq!(summary.stored_decisions, 8);
728 assert_eq!(summary.domains.len(), 2);
729
730 let diff = summary
731 .domains
732 .iter()
733 .find(|d| d.domain == DecisionDomain::DiffStrategy)
734 .unwrap();
735 assert_eq!(diff.decision_count, 5);
736 assert!(diff.mean_posterior > 0.0);
737 }
738
739 #[test]
740 fn builder_selects_top_3() {
741 let entry = EvidenceEntryBuilder::new(DecisionDomain::PaletteScoring, 0, 1000)
742 .log_posterior(2.0)
743 .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")
749 .loss_avoided(0.8)
750 .confidence_interval(0.90, 0.99)
751 .build();
752
753 assert_eq!(entry.evidence_count(), 3);
756 assert_eq!(entry.top_evidence[0].as_ref().unwrap().label, "match_type");
757 assert_eq!(entry.top_evidence[1].as_ref().unwrap().label, "tag_match");
758 let third = entry.top_evidence[2].as_ref().unwrap().label;
760 assert!(
761 third == "word_boundary" || third == "gap_penalty",
762 "unexpected third: {third}"
763 );
764 }
765
766 #[test]
767 fn builder_fewer_than_3() {
768 let entry = EvidenceEntryBuilder::new(DecisionDomain::FrameBudget, 0, 1000)
769 .evidence("frame_time", 2.0)
770 .action("hold")
771 .build();
772
773 assert_eq!(entry.evidence_count(), 1);
774 assert!(entry.top_evidence[1].is_none());
775 assert!(entry.top_evidence[2].is_none());
776 }
777
778 #[test]
779 fn domain_all_covers_seven() {
780 assert_eq!(DecisionDomain::ALL.len(), 7);
781 }
782
783 #[test]
784 fn domain_as_str_roundtrip() {
785 for domain in DecisionDomain::ALL {
786 let s = domain.as_str();
787 assert!(!s.is_empty());
788 assert!(s.chars().all(|c| c.is_ascii_lowercase() || c == '_'));
789 }
790 }
791
792 #[test]
793 fn minimum_capacity() {
794 let mut ledger = UnifiedEvidenceLedger::new(0); ledger.record(make_entry(DecisionDomain::DiffStrategy, "full"));
796 assert_eq!(ledger.len(), 1);
797 ledger.record(make_entry(DecisionDomain::DiffStrategy, "dirty_rows"));
798 assert_eq!(ledger.len(), 1); assert_eq!(ledger.last_entry().unwrap().action, "dirty_rows");
800 }
801
802 #[test]
803 fn debug_format() {
804 let ledger = UnifiedEvidenceLedger::new(100);
805 let debug = format!("{ledger:?}");
806 assert!(debug.contains("UnifiedEvidenceLedger"));
807 assert!(debug.contains("count: 0"));
808 }
809
810 #[test]
811 fn entries_order_before_wrap() {
812 let mut ledger = UnifiedEvidenceLedger::new(10);
813 for i in 0..5u64 {
814 let mut e = make_entry(DecisionDomain::DiffStrategy, "full");
815 e.timestamp_ns = i;
816 ledger.record(e);
817 }
818 let ids: Vec<u64> = ledger.entries().map(|e| e.decision_id).collect();
819 assert_eq!(ids, vec![0, 1, 2, 3, 4]);
820 }
821
822 #[test]
823 fn evidence_term_log_bf() {
824 let term = EvidenceTerm::new("test", 4.0);
825 assert!((term.log_bf() - 4.0f64.ln()).abs() < 1e-10);
826 }
827
828 #[test]
829 fn loss_avoided_nonnegative_for_optimal() {
830 let entry = make_entry(DecisionDomain::DiffStrategy, "full");
831 assert!(entry.loss_avoided >= 0.0);
832 }
833
834 #[test]
835 fn confidence_interval_bounds() {
836 let entry = make_entry(DecisionDomain::DiffStrategy, "full");
837 assert!(entry.confidence_interval.0 <= entry.confidence_interval.1);
838 assert!(entry.confidence_interval.0 >= 0.0);
839 assert!(entry.confidence_interval.1 <= 1.0);
840 }
841
842 #[test]
843 fn flush_to_sink_writes_all() {
844 let mut ledger = UnifiedEvidenceLedger::new(100);
845 ledger.record(make_entry(DecisionDomain::DiffStrategy, "full"));
846 ledger.record(make_entry(DecisionDomain::HintRanking, "rank_1"));
847
848 let config = crate::evidence_sink::EvidenceSinkConfig::enabled_stdout();
849 if let Ok(Some(sink)) = crate::evidence_sink::EvidenceSink::from_config(&config) {
850 let result = ledger.flush_to_sink(&sink);
851 assert!(result.is_ok());
852 }
853 }
854
855 #[test]
856 fn simulate_mixed_domains() {
857 let mut ledger = UnifiedEvidenceLedger::new(10_000);
858 let domains = DecisionDomain::ALL;
859 let actions = [
860 "full",
861 "coalesce",
862 "hold",
863 "degrade_1",
864 "sample",
865 "rank_1",
866 "exact",
867 ];
868
869 for i in 0..1000u64 {
870 let domain = domains[(i as usize) % 7];
871 let action = actions[(i as usize) % 7];
872 let mut e = make_entry(domain, action);
873 e.timestamp_ns = i * 16_000; ledger.record(e);
875 }
876
877 assert_eq!(ledger.len(), 1000);
878 assert_eq!(ledger.total_recorded(), 1000);
879
880 for domain in DecisionDomain::ALL {
882 let count = ledger.domain_count(domain);
883 assert!(
884 (142..=143).contains(&count),
885 "{:?}: expected ~142, got {}",
886 domain,
887 count
888 );
889 }
890
891 let jsonl = ledger.export_jsonl();
893 assert_eq!(jsonl.lines().count(), 1000);
894 }
895
896 #[test]
899 fn jsonl_roundtrip_all_fields() {
900 let entry = EvidenceEntryBuilder::new(DecisionDomain::DiffStrategy, 42, 999_000)
901 .log_posterior(1.386)
902 .evidence("change_rate", 4.0)
903 .evidence("dirty_ratio", 2.5)
904 .action("dirty_rows")
905 .loss_avoided(0.15)
906 .confidence_interval(0.72, 0.95)
907 .build();
908
909 let jsonl = entry.to_jsonl();
910 let parsed: serde_json::Value = serde_json::from_str(&jsonl).expect("valid JSON");
911
912 assert_eq!(parsed["schema"], "ftui-evidence-v2");
914 assert_eq!(parsed["id"], 42);
915 assert_eq!(parsed["ts_ns"], 999_000);
916 assert_eq!(parsed["domain"], "diff_strategy");
917 assert!(parsed["log_posterior"].as_f64().is_some());
918 assert_eq!(parsed["action"], "dirty_rows");
919 assert!(parsed["loss_avoided"].as_f64().unwrap() > 0.0);
920
921 let evidence = parsed["evidence"].as_array().expect("evidence is array");
923 assert_eq!(evidence.len(), 2);
924 assert_eq!(evidence[0]["label"], "change_rate");
925 assert!(evidence[0]["bf"].as_f64().unwrap() > 0.0);
926
927 let ci = parsed["ci"].as_array().expect("ci is array");
929 assert_eq!(ci.len(), 2);
930 let lower = ci[0].as_f64().unwrap();
931 let upper = ci[1].as_f64().unwrap();
932 assert!(lower < upper);
933 }
934
935 #[test]
936 fn jsonl_schema_required_fields_present() {
937 let required_keys = [
939 "schema",
940 "id",
941 "ts_ns",
942 "domain",
943 "log_posterior",
944 "evidence",
945 "action",
946 "loss_avoided",
947 "ci",
948 ];
949
950 for (i, domain) in DecisionDomain::ALL.iter().enumerate() {
951 let entry = EvidenceEntryBuilder::new(*domain, i as u64, (i as u64 + 1) * 1000)
952 .log_posterior(0.5)
953 .evidence("test_signal", 2.0)
954 .action("test_action")
955 .loss_avoided(0.01)
956 .confidence_interval(0.4, 0.6)
957 .build();
958
959 let jsonl = entry.to_jsonl();
960 let parsed: serde_json::Value = serde_json::from_str(&jsonl).unwrap();
961
962 for key in &required_keys {
963 assert!(
964 !parsed[key].is_null(),
965 "domain {:?} missing required key '{}'",
966 domain,
967 key
968 );
969 }
970
971 assert_eq!(parsed["domain"], domain.as_str());
973 }
974 }
975
976 #[test]
977 fn jsonl_backward_compat_extra_fields_ignored() {
978 let future_jsonl = concat!(
981 r#"{"schema":"ftui-evidence-v2","id":1,"ts_ns":5000,"domain":"diff_strategy","#,
982 r#""log_posterior":1.386,"evidence":[{"label":"change_rate","bf":4.0}],"#,
983 r#""action":"dirty_rows","loss_avoided":0.15,"ci":[0.72,0.95],"#,
984 r#""new_optional_field":"future_value","extra_metric":42.5}"#
985 );
986
987 let parsed: serde_json::Value =
988 serde_json::from_str(future_jsonl).expect("extra fields should not break parsing");
989
990 assert_eq!(parsed["schema"], "ftui-evidence-v2");
992 assert_eq!(parsed["id"], 1);
993 assert_eq!(parsed["domain"], "diff_strategy");
994 assert_eq!(parsed["action"], "dirty_rows");
995 assert!(parsed["log_posterior"].as_f64().is_some());
996 assert!(parsed["evidence"].as_array().is_some());
997 assert!(parsed["ci"].as_array().is_some());
998 }
999
1000 #[test]
1001 fn jsonl_backward_compat_missing_optional_evidence() {
1002 let entry = EvidenceEntryBuilder::new(DecisionDomain::FrameBudget, 0, 1000)
1004 .log_posterior(0.0)
1005 .action("hold")
1006 .build();
1007
1008 let jsonl = entry.to_jsonl();
1009 let parsed: serde_json::Value = serde_json::from_str(&jsonl).unwrap();
1010 let evidence = parsed["evidence"].as_array().unwrap();
1011 assert!(evidence.is_empty(), "no evidence terms → empty array");
1012 }
1013
1014 #[test]
1015 fn diff_strategy_evidence_format() {
1016 let evidence = ftui_render::diff_strategy::StrategyEvidence {
1019 strategy: ftui_render::diff_strategy::DiffStrategy::DirtyRows,
1020 cost_full: 1.0,
1021 cost_dirty: 0.5,
1022 cost_redraw: 2.0,
1023 posterior_mean: 0.05,
1024 posterior_variance: 0.001,
1025 alpha: 2.0,
1026 beta: 38.0,
1027 dirty_rows: 3,
1028 total_rows: 24,
1029 total_cells: 1920,
1030 guard_reason: "none",
1031 hysteresis_applied: false,
1032 hysteresis_ratio: 0.05,
1033 };
1034
1035 let entry = crate::evidence_bridges::from_diff_strategy(&evidence, 100_000);
1036 let jsonl = entry.to_jsonl();
1037 let parsed: serde_json::Value = serde_json::from_str(&jsonl).unwrap();
1038
1039 assert_eq!(parsed["domain"], "diff_strategy");
1040 assert_eq!(parsed["action"], "dirty_rows");
1041
1042 let ev_array = parsed["evidence"].as_array().unwrap();
1044 let labels: Vec<&str> = ev_array
1045 .iter()
1046 .map(|e| e["label"].as_str().unwrap())
1047 .collect();
1048 assert!(
1049 labels.contains(&"change_rate"),
1050 "missing change_rate evidence"
1051 );
1052 assert!(
1053 labels.contains(&"dirty_ratio"),
1054 "missing dirty_ratio evidence"
1055 );
1056
1057 let ci = parsed["ci"].as_array().unwrap();
1059 let lower = ci[0].as_f64().unwrap();
1060 let upper = ci[1].as_f64().unwrap();
1061 assert!(
1062 (0.0..=1.0).contains(&lower),
1063 "CI lower out of range: {lower}"
1064 );
1065 assert!(
1066 (0.0..=1.0).contains(&upper),
1067 "CI upper out of range: {upper}"
1068 );
1069 assert!(lower <= upper, "CI lower > upper");
1070 }
1071}