Skip to main content

ftui_runtime/
unified_evidence.rs

1#![forbid(unsafe_code)]
2
3//! Unified Evidence Ledger for all Bayesian decision points (bd-fp38v).
4//!
5//! Every adaptive controller in FrankenTUI (diff strategy, resize coalescing,
6//! frame budget, degradation, VOI sampling, hint ranking, command palette
7//! scoring) emits decisions through this common schema. Each decision records:
8//!
9//! - `log_posterior`: log-odds of the chosen action being optimal
10//! - Top-3 evidence terms with Bayes factors
11//! - Action chosen
12//! - Loss avoided (expected loss of next-best minus chosen)
13//! - Confidence interval `[lower, upper]`
14//!
15//! The ledger is a fixed-capacity ring buffer (zero per-decision allocation on
16//! the hot path). JSONL export is supported via [`EvidenceSink`].
17//!
18//! # Usage
19//!
20//! ```rust
21//! use ftui_runtime::unified_evidence::{
22//!     DecisionDomain, EvidenceEntry, EvidenceTerm, UnifiedEvidenceLedger,
23//! };
24//!
25//! let mut ledger = UnifiedEvidenceLedger::new(1000);
26//!
27//! let entry = EvidenceEntry {
28//!     decision_id: 1,
29//!     timestamp_ns: 42_000,
30//!     domain: DecisionDomain::DiffStrategy,
31//!     log_posterior: 1.386,
32//!     top_evidence: [
33//!         Some(EvidenceTerm::new("change_rate", 4.0)),
34//!         Some(EvidenceTerm::new("dirty_rows", 2.5)),
35//!         None,
36//!     ],
37//!     action: "dirty_rows",
38//!     loss_avoided: 0.15,
39//!     confidence_interval: (0.72, 0.95),
40//! };
41//!
42//! ledger.record(entry);
43//! assert_eq!(ledger.len(), 1);
44//! ```
45
46use std::fmt::Write as _;
47
48// ============================================================================
49// Domain Enum
50// ============================================================================
51
52/// Domain of a Bayesian decision point.
53///
54/// Covers all 7 adaptive controllers in FrankenTUI.
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
56pub enum DecisionDomain {
57    /// Diff strategy selection (full vs dirty-rows vs full-redraw).
58    DiffStrategy,
59    /// Resize event coalescing (apply vs coalesce vs placeholder).
60    ResizeCoalescing,
61    /// Frame budget allocation and PID-based timing.
62    FrameBudget,
63    /// Graceful degradation level selection.
64    Degradation,
65    /// Value-of-information adaptive sampling.
66    VoiSampling,
67    /// Hint ranking for type-ahead suggestions.
68    HintRanking,
69    /// Command palette relevance scoring.
70    PaletteScoring,
71}
72
73impl DecisionDomain {
74    /// Domain name as a static string for JSONL output.
75    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    /// All domains in declaration order.
88    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// ============================================================================
100// Evidence Term
101// ============================================================================
102
103/// A single piece of evidence contributing to a Bayesian decision.
104///
105/// Bayes factor > 1 supports the chosen action; < 1 opposes it.
106#[derive(Debug, Clone)]
107pub struct EvidenceTerm {
108    /// Human-readable label (e.g., "change_rate", "word_boundary").
109    pub label: &'static str,
110    /// Bayes factor: `P(evidence | H1) / P(evidence | H0)`.
111    pub bayes_factor: f64,
112}
113
114impl EvidenceTerm {
115    /// Create a new evidence term.
116    #[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    /// Log Bayes factor (natural log).
125    #[must_use]
126    pub fn log_bf(&self) -> f64 {
127        self.bayes_factor.ln()
128    }
129}
130
131// ============================================================================
132// Evidence Entry
133// ============================================================================
134
135/// Unified evidence record for any Bayesian decision point.
136///
137/// Fixed-size: the top-3 evidence array avoids heap allocation.
138#[derive(Debug, Clone)]
139pub struct EvidenceEntry {
140    /// Monotonic decision counter (unique within a session).
141    pub decision_id: u64,
142    /// Monotonic timestamp (nanoseconds from program start).
143    pub timestamp_ns: u64,
144    /// Which decision domain this belongs to.
145    pub domain: DecisionDomain,
146    /// Log-posterior odds of the chosen action being optimal.
147    pub log_posterior: f64,
148    /// Top-3 evidence terms ranked by |log(BF)|, pre-allocated.
149    pub top_evidence: [Option<EvidenceTerm>; 3],
150    /// Action taken (e.g., "dirty_rows", "coalesce", "degrade_1").
151    pub action: &'static str,
152    /// Expected loss avoided: `E[loss(next_best)] - E[loss(chosen)]`.
153    /// Non-negative when the chosen action is optimal.
154    pub loss_avoided: f64,
155    /// Confidence interval `(lower, upper)` on the posterior probability.
156    pub confidence_interval: (f64, f64),
157}
158
159impl EvidenceEntry {
160    /// Posterior probability derived from log-odds.
161    #[must_use]
162    pub fn posterior_probability(&self) -> f64 {
163        let odds = self.log_posterior.exp();
164        odds / (1.0 + odds)
165    }
166
167    /// Number of evidence terms present.
168    #[must_use]
169    pub fn evidence_count(&self) -> usize {
170        self.top_evidence.iter().filter(|t| t.is_some()).count()
171    }
172
173    /// Combined log Bayes factor (sum of individual log-BFs).
174    #[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    /// Format as a JSONL line (no trailing newline).
184    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
219// ============================================================================
220// Builder
221// ============================================================================
222
223/// Builder for constructing `EvidenceEntry` values.
224///
225/// Handles automatic selection of top-3 evidence terms by |log(BF)|.
226pub 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    /// Start building an evidence entry.
239    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    /// Set the log-posterior odds.
253    #[must_use]
254    pub fn log_posterior(mut self, value: f64) -> Self {
255        self.log_posterior = value;
256        self
257    }
258
259    /// Add an evidence term.
260    #[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    /// Set the chosen action.
267    #[must_use]
268    pub fn action(mut self, action: &'static str) -> Self {
269        self.action = action;
270        self
271    }
272
273    /// Set the loss avoided.
274    #[must_use]
275    pub fn loss_avoided(mut self, value: f64) -> Self {
276        self.loss_avoided = value;
277        self
278    }
279
280    /// Set the confidence interval.
281    #[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    /// Build the entry, selecting top-3 evidence terms by |log(BF)|.
288    pub fn build(mut self) -> EvidenceEntry {
289        // Sort by descending |log(BF)| to pick the top 3.
290        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
311// ============================================================================
312// Unified Evidence Ledger
313// ============================================================================
314
315/// Fixed-capacity ring buffer storing [`EvidenceEntry`] records from all
316/// decision domains.
317///
318/// Pre-allocates all storage so that [`record`](Self::record) never
319/// allocates on the hot path.
320pub struct UnifiedEvidenceLedger {
321    entries: Vec<Option<EvidenceEntry>>,
322    head: usize,
323    count: usize,
324    capacity: usize,
325    next_id: u64,
326    /// Per-domain counters for audit and replay.
327    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    /// Create a new ledger with the given capacity.
342    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    /// Record an evidence entry. Overwrites oldest when full.
355    ///
356    /// Returns the assigned `decision_id`.
357    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    /// Number of entries currently stored.
374    pub fn len(&self) -> usize {
375        self.count
376    }
377
378    /// Whether the ledger is empty.
379    pub fn is_empty(&self) -> bool {
380        self.count == 0
381    }
382
383    /// Total entries ever recorded (including overwritten).
384    pub fn total_recorded(&self) -> u64 {
385        self.next_id
386    }
387
388    /// Number of decisions recorded for a specific domain.
389    pub fn domain_count(&self, domain: DecisionDomain) -> u64 {
390        self.domain_counts[domain as usize]
391    }
392
393    /// Iterate over stored entries in insertion order (oldest first).
394    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    /// Get entries for a specific domain.
407    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    /// Get the most recent entry.
415    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    /// Get the most recent entry for a specific domain.
428    pub fn last_entry_for_domain(&self, domain: DecisionDomain) -> Option<&EvidenceEntry> {
429        // Walk backwards from head.
430        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    /// Export all entries as JSONL.
447    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    /// Flush entries to an evidence sink.
457    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    /// Clear all stored entries. Domain counters are preserved.
465    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    /// Summary statistics per domain.
474    pub fn summary(&self) -> LedgerSummary {
475        let mut per_domain = [(0u64, 0.0f64, 0.0f64); 7]; // (count, sum_loss, sum_posterior)
476        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/// Summary of ledger contents.
507#[derive(Debug, Clone)]
508pub struct LedgerSummary {
509    /// Total decisions ever recorded.
510    pub total_decisions: u64,
511    /// Decisions currently stored in the ring buffer.
512    pub stored_decisions: u64,
513    /// Per-domain statistics.
514    pub domains: Vec<DomainSummary>,
515}
516
517/// Per-domain summary statistics.
518#[derive(Debug, Clone)]
519pub struct DomainSummary {
520    /// Decision domain.
521    pub domain: DecisionDomain,
522    /// Number of decisions from this domain in the buffer.
523    pub decision_count: u64,
524    /// Mean loss avoided across decisions.
525    pub mean_loss_avoided: f64,
526    /// Mean posterior probability across decisions.
527    pub mean_posterior: f64,
528}
529
530// ============================================================================
531// Trait: EmitsEvidence
532// ============================================================================
533
534/// Trait for decision-making components that emit unified evidence.
535///
536/// Implement this on each Bayesian controller to bridge its domain-specific
537/// evidence into the unified schema.
538pub trait EmitsEvidence {
539    /// Convert the current decision state into a unified evidence entry.
540    fn to_evidence_entry(&self, timestamp_ns: u64) -> EvidenceEntry;
541
542    /// The decision domain this component belongs to.
543    fn evidence_domain(&self) -> DecisionDomain;
544}
545
546// ============================================================================
547// Tests
548// ============================================================================
549
550#[cfg(test)]
551mod tests {
552    use super::*;
553
554    fn make_entry(domain: DecisionDomain, action: &'static str) -> EvidenceEntry {
555        EvidenceEntry {
556            decision_id: 0, // assigned by ledger
557            timestamp_ns: 1_000_000,
558            domain,
559            log_posterior: 1.386, // ~80% posterior
560            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        // log_posterior = 1.386 → odds = e^1.386 ≈ 4.0 → prob ≈ 0.8
662        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); // two Some, one None
669    }
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        // Verify it's valid single-line JSON (no newlines).
689        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); // total preserved
712        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) // log(9) = 2.197
744            .evidence("position", 1.5) // log(1.5) = 0.405
745            .evidence("word_boundary", 2.0) // log(2) = 0.693
746            .evidence("gap_penalty", 0.5) // log(0.5) = -0.693 (abs = 0.693)
747            .evidence("tag_match", 3.0) // log(3) = 1.099
748            .action("exact")
749            .loss_avoided(0.8)
750            .confidence_interval(0.90, 0.99)
751            .build();
752
753        // Top 3 by |log(BF)|: match_type (2.197), tag_match (1.099),
754        // then word_boundary or gap_penalty (both 0.693 abs).
755        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        // Third is either word_boundary or gap_penalty (same |log(BF)|).
759        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); // clamped to 1
795        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); // wrapped
799        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; // ~16ms per frame
874            ledger.record(e);
875        }
876
877        assert_eq!(ledger.len(), 1000);
878        assert_eq!(ledger.total_recorded(), 1000);
879
880        // Each domain should have ~142-143 entries.
881        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        // JSONL export should produce 1000 lines.
892        let jsonl = ledger.export_jsonl();
893        assert_eq!(jsonl.lines().count(), 1000);
894    }
895
896    // ── bd-xox.10: Serialization and Schema Tests ─────────────────────────
897
898    #[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        // Verify every required field is present and has correct type/value.
913        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        // Evidence array.
922        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        // Confidence interval.
928        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        // Verify schema compliance for all 7 domains.
938        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            // Domain string matches enum.
972            assert_eq!(parsed["domain"], domain.as_str());
973        }
974    }
975
976    #[test]
977    fn jsonl_backward_compat_extra_fields_ignored() {
978        // Simulate a future schema version with extra optional fields.
979        // An "old reader" using serde_json::Value should still parse fine.
980        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        // Old reader can still access all standard fields.
991        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        // An entry with zero evidence terms should still be valid.
1003        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        // Verify that diff strategy evidence from the bridge produces
1017        // the expected JSONL format.
1018        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        // Evidence should contain at least change_rate and dirty_ratio.
1043        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        // Confidence interval should be within [0, 1].
1058        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}