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 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    /// Number of evidence terms present.
173    #[must_use]
174    pub fn evidence_count(&self) -> usize {
175        self.top_evidence.iter().filter(|t| t.is_some()).count()
176    }
177
178    /// Combined log Bayes factor (sum of individual log-BFs).
179    #[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    /// Format as a JSONL line (no trailing newline).
189    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
224// ============================================================================
225// Builder
226// ============================================================================
227
228/// Builder for constructing `EvidenceEntry` values.
229///
230/// Handles automatic selection of top-3 evidence terms by |log(BF)|.
231pub 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    /// Start building an evidence entry.
244    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    /// Set the log-posterior odds.
258    #[must_use]
259    pub fn log_posterior(mut self, value: f64) -> Self {
260        self.log_posterior = value;
261        self
262    }
263
264    /// Add an evidence term.
265    #[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    /// Set the chosen action.
272    #[must_use]
273    pub fn action(mut self, action: &'static str) -> Self {
274        self.action = action;
275        self
276    }
277
278    /// Set the loss avoided.
279    #[must_use]
280    pub fn loss_avoided(mut self, value: f64) -> Self {
281        self.loss_avoided = value;
282        self
283    }
284
285    /// Set the confidence interval.
286    #[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    /// Build the entry, selecting top-3 evidence terms by |log(BF)|.
293    pub fn build(mut self) -> EvidenceEntry {
294        // Sort by descending |log(BF)| to pick the top 3.
295        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
316// ============================================================================
317// Unified Evidence Ledger
318// ============================================================================
319
320/// Fixed-capacity ring buffer storing [`EvidenceEntry`] records from all
321/// decision domains.
322///
323/// Pre-allocates all storage so that [`record`](Self::record) never
324/// allocates on the hot path.
325pub struct UnifiedEvidenceLedger {
326    entries: Vec<Option<EvidenceEntry>>,
327    head: usize,
328    count: usize,
329    capacity: usize,
330    next_id: u64,
331    /// Per-domain counters for audit and replay.
332    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    /// Create a new ledger with the given capacity.
347    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    /// Record an evidence entry. Overwrites oldest when full.
360    ///
361    /// Returns the assigned `decision_id`.
362    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    /// Number of entries currently stored.
379    pub fn len(&self) -> usize {
380        self.count
381    }
382
383    /// Whether the ledger is empty.
384    pub fn is_empty(&self) -> bool {
385        self.count == 0
386    }
387
388    /// Total entries ever recorded (including overwritten).
389    pub fn total_recorded(&self) -> u64 {
390        self.next_id
391    }
392
393    /// Number of decisions recorded for a specific domain.
394    pub fn domain_count(&self, domain: DecisionDomain) -> u64 {
395        self.domain_counts[domain as usize]
396    }
397
398    /// Iterate over stored entries in insertion order (oldest first).
399    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    /// Get entries for a specific domain.
412    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    /// Get the most recent entry.
420    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    /// Get the most recent entry for a specific domain.
433    pub fn last_entry_for_domain(&self, domain: DecisionDomain) -> Option<&EvidenceEntry> {
434        // Walk backwards from head.
435        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    /// Export all entries as JSONL.
452    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    /// Flush entries to an evidence sink.
462    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    /// Clear all stored entries. Domain counters are preserved.
470    pub fn clear(&mut self) {
471        self.entries.fill_with(|| None);
472        self.head = 0;
473        self.count = 0;
474    }
475
476    /// Summary statistics per domain.
477    pub fn summary(&self) -> LedgerSummary {
478        let mut per_domain = [(0u64, 0.0f64, 0.0f64); 7]; // (count, sum_loss, sum_posterior)
479        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/// Summary of ledger contents.
510#[derive(Debug, Clone)]
511pub struct LedgerSummary {
512    /// Total decisions ever recorded.
513    pub total_decisions: u64,
514    /// Decisions currently stored in the ring buffer.
515    pub stored_decisions: u64,
516    /// Per-domain statistics.
517    pub domains: Vec<DomainSummary>,
518}
519
520/// Per-domain summary statistics.
521#[derive(Debug, Clone)]
522pub struct DomainSummary {
523    /// Decision domain.
524    pub domain: DecisionDomain,
525    /// Number of decisions from this domain in the buffer.
526    pub decision_count: u64,
527    /// Mean loss avoided across decisions.
528    pub mean_loss_avoided: f64,
529    /// Mean posterior probability across decisions.
530    pub mean_posterior: f64,
531}
532
533// ============================================================================
534// Trait: EmitsEvidence
535// ============================================================================
536
537/// Trait for decision-making components that emit unified evidence.
538///
539/// Implement this on each Bayesian controller to bridge its domain-specific
540/// evidence into the unified schema.
541pub trait EmitsEvidence {
542    /// Convert the current decision state into a unified evidence entry.
543    fn to_evidence_entry(&self, timestamp_ns: u64) -> EvidenceEntry;
544
545    /// The decision domain this component belongs to.
546    fn evidence_domain(&self) -> DecisionDomain;
547}
548
549// ============================================================================
550// Tests
551// ============================================================================
552
553#[cfg(test)]
554mod tests {
555    use super::*;
556
557    fn make_entry(domain: DecisionDomain, action: &'static str) -> EvidenceEntry {
558        EvidenceEntry {
559            decision_id: 0, // assigned by ledger
560            timestamp_ns: 1_000_000,
561            domain,
562            log_posterior: 1.386, // ~80% posterior
563            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        // log_posterior = 1.386 → odds = e^1.386 ≈ 4.0 → prob ≈ 0.8
665        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); // two Some, one None
687    }
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        // Verify it's valid single-line JSON (no newlines).
707        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); // total preserved
730        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) // log(9) = 2.197
780            .evidence("position", 1.5) // log(1.5) = 0.405
781            .evidence("word_boundary", 2.0) // log(2) = 0.693
782            .evidence("gap_penalty", 0.5) // log(0.5) = -0.693 (abs = 0.693)
783            .evidence("tag_match", 3.0) // log(3) = 1.099
784            .action("exact")
785            .loss_avoided(0.8)
786            .confidence_interval(0.90, 0.99)
787            .build();
788
789        // Top 3 by |log(BF)|: match_type (2.197), tag_match (1.099),
790        // then word_boundary or gap_penalty (both 0.693 abs).
791        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        // Third is either word_boundary or gap_penalty (same |log(BF)|).
795        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); // clamped to 1
831        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); // wrapped
835        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; // ~16ms per frame
910            ledger.record(e);
911        }
912
913        assert_eq!(ledger.len(), 1000);
914        assert_eq!(ledger.total_recorded(), 1000);
915
916        // Each domain should have ~142-143 entries.
917        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        // JSONL export should produce 1000 lines.
928        let jsonl = ledger.export_jsonl();
929        assert_eq!(jsonl.lines().count(), 1000);
930    }
931
932    // ── bd-xox.10: Serialization and Schema Tests ─────────────────────────
933
934    #[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        // Verify every required field is present and has correct type/value.
949        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        // Evidence array.
958        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        // Confidence interval.
964        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        // Verify schema compliance for all 7 domains.
974        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            // Domain string matches enum.
1008            assert_eq!(parsed["domain"], domain.as_str());
1009        }
1010    }
1011
1012    #[test]
1013    fn jsonl_backward_compat_extra_fields_ignored() {
1014        // Simulate a future schema version with extra optional fields.
1015        // An "old reader" using serde_json::Value should still parse fine.
1016        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        // Old reader can still access all standard fields.
1027        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        // An entry with zero evidence terms should still be valid.
1039        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        // Verify that diff strategy evidence from the bridge produces
1053        // the expected JSONL format.
1054        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        // Evidence should contain at least change_rate and dirty_ratio.
1079        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        // Confidence interval should be within [0, 1].
1094        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}