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