Skip to main content

ftui_runtime/
transparency.rs

1//! Galaxy-brain transparency layer with progressive disclosure (bd-xox.5).
2//!
3//! Provides 4 levels of decision transparency:
4//!
5//! - **Level 0 (Traffic Light)**: Green/yellow/red indicator for confidence.
6//! - **Level 1 (Plain English)**: One-sentence human-readable explanation.
7//! - **Level 2 (Evidence Terms)**: Posterior probabilities and confidence intervals.
8//! - **Level 3 (Full Bayesian)**: Complete factor breakdown with prior/posterior comparison.
9//!
10//! Each level includes all information from lower levels.
11
12use crate::decision_core::{Action, Decision};
13use crate::unified_evidence::DecisionDomain;
14use std::fmt;
15
16/// Progressive disclosure level for decision transparency.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
18#[repr(u8)]
19pub enum DisclosureLevel {
20    /// Traffic light indicator only.
21    TrafficLight = 0,
22    /// Plain English one-sentence explanation.
23    PlainEnglish = 1,
24    /// Evidence terms with probabilities and confidence intervals.
25    EvidenceTerms = 2,
26    /// Full Bayesian factor breakdown.
27    FullBayesian = 3,
28}
29
30impl DisclosureLevel {
31    /// Cycle to the next level, wrapping around.
32    #[must_use]
33    pub fn next(self) -> Self {
34        match self {
35            Self::TrafficLight => Self::PlainEnglish,
36            Self::PlainEnglish => Self::EvidenceTerms,
37            Self::EvidenceTerms => Self::FullBayesian,
38            Self::FullBayesian => Self::TrafficLight,
39        }
40    }
41}
42
43/// Traffic light signal for quick confidence assessment.
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum TrafficLight {
46    /// High confidence in the chosen action.
47    Green,
48    /// Moderate confidence — decision is reasonable but uncertain.
49    Yellow,
50    /// Low confidence — near-fallback territory.
51    Red,
52}
53
54impl TrafficLight {
55    /// Determine signal from log-posterior odds and confidence interval width.
56    #[must_use]
57    pub fn from_decision<A: Action>(decision: &Decision<A>) -> Self {
58        let ci_width = decision.confidence_interval.1 - decision.confidence_interval.0;
59        let loss_margin = decision.loss_avoided();
60
61        if decision.log_posterior > 1.0 && ci_width < 0.3 && loss_margin > 0.1 {
62            Self::Green
63        } else if decision.log_posterior > 0.0 && ci_width < 0.6 {
64            Self::Yellow
65        } else {
66            Self::Red
67        }
68    }
69
70    /// Emoji-free label for terminal display.
71    #[must_use]
72    pub fn label(self) -> &'static str {
73        match self {
74            Self::Green => "OK",
75            Self::Yellow => "WARN",
76            Self::Red => "ALERT",
77        }
78    }
79}
80
81impl fmt::Display for TrafficLight {
82    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
83        f.write_str(self.label())
84    }
85}
86
87/// A disclosure snapshot at a specific level for a single decision.
88#[derive(Debug, Clone)]
89pub struct Disclosure {
90    /// The domain this decision belongs to.
91    pub domain: DecisionDomain,
92    /// The disclosure level.
93    pub level: DisclosureLevel,
94    /// Traffic light signal (always available).
95    pub signal: TrafficLight,
96    /// Action label chosen.
97    pub action_label: String,
98    /// Plain English explanation (level >= 1).
99    pub explanation: Option<String>,
100    /// Evidence terms with Bayes factors (level >= 2).
101    pub evidence_terms: Option<Vec<DisclosureEvidence>>,
102    /// Full Bayesian details (level >= 3).
103    pub bayesian_details: Option<BayesianDetails>,
104}
105
106/// An evidence term exposed at disclosure level 2+.
107#[derive(Debug, Clone)]
108pub struct DisclosureEvidence {
109    /// Human-readable label for this evidence factor.
110    pub label: &'static str,
111    /// Bayes factor (likelihood ratio) for this evidence.
112    pub bayes_factor: f64,
113    /// Direction: positive means supporting the chosen action.
114    pub direction: EvidenceDirection,
115}
116
117/// Whether evidence supports or opposes the chosen action.
118#[derive(Debug, Clone, Copy, PartialEq, Eq)]
119pub enum EvidenceDirection {
120    /// Evidence supports the chosen action.
121    Supporting,
122    /// Evidence opposes the chosen action.
123    Opposing,
124    /// Evidence is neutral (Bayes factor near 1.0).
125    Neutral,
126}
127
128impl EvidenceDirection {
129    /// Classify from a Bayes factor.
130    #[must_use]
131    pub fn from_bayes_factor(bf: f64) -> Self {
132        if bf > 1.1 {
133            Self::Supporting
134        } else if bf < 0.9 {
135            Self::Opposing
136        } else {
137            Self::Neutral
138        }
139    }
140}
141
142/// Full Bayesian details exposed at disclosure level 3.
143#[derive(Debug, Clone)]
144pub struct BayesianDetails {
145    /// Log-posterior odds.
146    pub log_posterior: f64,
147    /// Confidence interval (lower, upper) on posterior probability.
148    pub confidence_interval: (f64, f64),
149    /// Expected loss of chosen action.
150    pub expected_loss: f64,
151    /// Expected loss of next-best action.
152    pub next_best_loss: f64,
153    /// Loss avoided by the chosen action.
154    pub loss_avoided: f64,
155}
156
157/// Build a disclosure snapshot from a decision at the requested level.
158pub fn disclose<A: Action>(
159    decision: &Decision<A>,
160    domain: DecisionDomain,
161    level: DisclosureLevel,
162) -> Disclosure {
163    let signal = TrafficLight::from_decision(decision);
164    let action_label = decision.action.label().to_string();
165
166    let explanation = if level >= DisclosureLevel::PlainEnglish {
167        Some(build_explanation(decision, domain, signal))
168    } else {
169        None
170    };
171
172    let evidence_terms = if level >= DisclosureLevel::EvidenceTerms {
173        Some(
174            decision
175                .evidence
176                .iter()
177                .map(|t| DisclosureEvidence {
178                    label: t.label,
179                    bayes_factor: t.bayes_factor,
180                    direction: EvidenceDirection::from_bayes_factor(t.bayes_factor),
181                })
182                .collect(),
183        )
184    } else {
185        None
186    };
187
188    let bayesian_details = if level >= DisclosureLevel::FullBayesian {
189        Some(BayesianDetails {
190            log_posterior: decision.log_posterior,
191            confidence_interval: decision.confidence_interval,
192            expected_loss: decision.expected_loss,
193            next_best_loss: decision.next_best_loss,
194            loss_avoided: decision.loss_avoided(),
195        })
196    } else {
197        None
198    };
199
200    Disclosure {
201        domain,
202        level,
203        signal,
204        action_label,
205        explanation,
206        evidence_terms,
207        bayesian_details,
208    }
209}
210
211/// Build a plain-English explanation for a decision.
212fn build_explanation<A: Action>(
213    decision: &Decision<A>,
214    domain: DecisionDomain,
215    signal: TrafficLight,
216) -> String {
217    let domain_name = domain_display_name(domain);
218    let action = decision.action.label();
219    let confidence = match signal {
220        TrafficLight::Green => "high confidence",
221        TrafficLight::Yellow => "moderate confidence",
222        TrafficLight::Red => "low confidence",
223    };
224
225    let loss_info = if decision.loss_avoided() > 0.01 {
226        format!(
227            ", saving {:.1}% over the alternative",
228            decision.loss_avoided() * 100.0
229        )
230    } else {
231        String::new()
232    };
233
234    format!("{domain_name}: chose '{action}' with {confidence}{loss_info}.")
235}
236
237/// Human-readable domain name for explanations.
238fn domain_display_name(domain: DecisionDomain) -> &'static str {
239    match domain {
240        DecisionDomain::DiffStrategy => "Diff strategy",
241        DecisionDomain::ResizeCoalescing => "Resize coalescing",
242        DecisionDomain::FrameBudget => "Frame budget",
243        DecisionDomain::Degradation => "Degradation",
244        DecisionDomain::VoiSampling => "VOI sampling",
245        DecisionDomain::HintRanking => "Hint ranking",
246        DecisionDomain::PaletteScoring => "Palette scoring",
247    }
248}
249
250/// Format a disclosure for terminal display at any level.
251impl fmt::Display for Disclosure {
252    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
253        // Level 0: traffic light
254        write!(f, "[{}] {}", self.signal, self.action_label)?;
255
256        // Level 1: explanation
257        if let Some(ref explanation) = self.explanation {
258            write!(f, "\n  {explanation}")?;
259        }
260
261        // Level 2: evidence terms
262        if let Some(ref terms) = self.evidence_terms
263            && !terms.is_empty()
264        {
265            write!(f, "\n  Evidence:")?;
266            for t in terms {
267                let dir = match t.direction {
268                    EvidenceDirection::Supporting => "+",
269                    EvidenceDirection::Opposing => "-",
270                    EvidenceDirection::Neutral => "~",
271                };
272                write!(f, "\n    {dir} {}: BF={:.2}", t.label, t.bayes_factor)?;
273            }
274        }
275
276        // Level 3: full Bayesian
277        if let Some(ref details) = self.bayesian_details {
278            write!(
279                f,
280                "\n  Bayesian: log_post={:.3} CI=[{:.3}, {:.3}] E[loss]={:.4} avoided={:.4}",
281                details.log_posterior,
282                details.confidence_interval.0,
283                details.confidence_interval.1,
284                details.expected_loss,
285                details.loss_avoided,
286            )?;
287        }
288
289        Ok(())
290    }
291}
292
293#[cfg(test)]
294mod tests {
295    use super::*;
296    use crate::unified_evidence::EvidenceTerm;
297
298    // Minimal action type for testing.
299    #[derive(Debug, Clone)]
300    struct TestAction(&'static str);
301    impl Action for TestAction {
302        fn label(&self) -> &'static str {
303            self.0
304        }
305    }
306
307    fn sample_decision(
308        log_posterior: f64,
309        ci: (f64, f64),
310        expected_loss: f64,
311        next_best_loss: f64,
312    ) -> Decision<TestAction> {
313        Decision {
314            action: TestAction("full_redraw"),
315            expected_loss,
316            next_best_loss,
317            log_posterior,
318            confidence_interval: ci,
319            evidence: vec![
320                EvidenceTerm {
321                    label: "change_rate",
322                    bayes_factor: 3.5,
323                },
324                EvidenceTerm {
325                    label: "frame_cost",
326                    bayes_factor: 0.8,
327                },
328                EvidenceTerm {
329                    label: "stability",
330                    bayes_factor: 1.0,
331                },
332            ],
333        }
334    }
335
336    #[test]
337    fn traffic_light_green() {
338        let d = sample_decision(2.0, (0.7, 0.95), 0.1, 0.5);
339        assert_eq!(TrafficLight::from_decision(&d), TrafficLight::Green);
340    }
341
342    #[test]
343    fn traffic_light_yellow() {
344        let d = sample_decision(0.5, (0.3, 0.7), 0.3, 0.35);
345        assert_eq!(TrafficLight::from_decision(&d), TrafficLight::Yellow);
346    }
347
348    #[test]
349    fn traffic_light_red() {
350        let d = sample_decision(-0.5, (0.1, 0.9), 0.4, 0.42);
351        assert_eq!(TrafficLight::from_decision(&d), TrafficLight::Red);
352    }
353
354    #[test]
355    fn disclosure_level_0() {
356        let d = sample_decision(2.0, (0.7, 0.95), 0.1, 0.5);
357        let disc = disclose(
358            &d,
359            DecisionDomain::DiffStrategy,
360            DisclosureLevel::TrafficLight,
361        );
362        assert_eq!(disc.signal, TrafficLight::Green);
363        assert!(disc.explanation.is_none());
364        assert!(disc.evidence_terms.is_none());
365        assert!(disc.bayesian_details.is_none());
366    }
367
368    #[test]
369    fn disclosure_level_1() {
370        let d = sample_decision(2.0, (0.7, 0.95), 0.1, 0.5);
371        let disc = disclose(
372            &d,
373            DecisionDomain::DiffStrategy,
374            DisclosureLevel::PlainEnglish,
375        );
376        assert!(disc.explanation.is_some());
377        let expl = disc.explanation.unwrap();
378        assert!(expl.contains("Diff strategy"));
379        assert!(expl.contains("full_redraw"));
380        assert!(expl.contains("high confidence"));
381    }
382
383    #[test]
384    fn disclosure_level_2() {
385        let d = sample_decision(2.0, (0.7, 0.95), 0.1, 0.5);
386        let disc = disclose(
387            &d,
388            DecisionDomain::DiffStrategy,
389            DisclosureLevel::EvidenceTerms,
390        );
391        let terms = disc.evidence_terms.unwrap();
392        assert_eq!(terms.len(), 3);
393        assert_eq!(terms[0].label, "change_rate");
394        assert_eq!(terms[0].direction, EvidenceDirection::Supporting);
395        assert_eq!(terms[1].direction, EvidenceDirection::Opposing);
396        assert_eq!(terms[2].direction, EvidenceDirection::Neutral);
397    }
398
399    #[test]
400    fn disclosure_level_3() {
401        let d = sample_decision(2.0, (0.7, 0.95), 0.1, 0.5);
402        let disc = disclose(
403            &d,
404            DecisionDomain::DiffStrategy,
405            DisclosureLevel::FullBayesian,
406        );
407        let details = disc.bayesian_details.unwrap();
408        assert!((details.log_posterior - 2.0).abs() < 1e-10);
409        assert!((details.expected_loss - 0.1).abs() < 1e-10);
410        assert!((details.loss_avoided - 0.4).abs() < 1e-10);
411    }
412
413    #[test]
414    fn disclosure_level_ordering() {
415        assert!(DisclosureLevel::TrafficLight < DisclosureLevel::PlainEnglish);
416        assert!(DisclosureLevel::PlainEnglish < DisclosureLevel::EvidenceTerms);
417        assert!(DisclosureLevel::EvidenceTerms < DisclosureLevel::FullBayesian);
418    }
419
420    #[test]
421    fn disclosure_level_cycle() {
422        let mut l = DisclosureLevel::TrafficLight;
423        l = l.next();
424        assert_eq!(l, DisclosureLevel::PlainEnglish);
425        l = l.next();
426        assert_eq!(l, DisclosureLevel::EvidenceTerms);
427        l = l.next();
428        assert_eq!(l, DisclosureLevel::FullBayesian);
429        l = l.next();
430        assert_eq!(l, DisclosureLevel::TrafficLight);
431    }
432
433    #[test]
434    fn display_formats_correctly() {
435        let d = sample_decision(2.0, (0.7, 0.95), 0.1, 0.5);
436        let disc = disclose(
437            &d,
438            DecisionDomain::DiffStrategy,
439            DisclosureLevel::FullBayesian,
440        );
441        let output = disc.to_string();
442        assert!(output.contains("[OK]"));
443        assert!(output.contains("full_redraw"));
444        assert!(output.contains("Evidence:"));
445        assert!(output.contains("Bayesian:"));
446    }
447
448    #[test]
449    fn loss_avoided_in_explanation() {
450        let d = sample_decision(2.0, (0.7, 0.95), 0.1, 0.5);
451        let disc = disclose(
452            &d,
453            DecisionDomain::DiffStrategy,
454            DisclosureLevel::PlainEnglish,
455        );
456        let expl = disc.explanation.unwrap();
457        assert!(expl.contains("saving"), "should mention savings: {expl}");
458    }
459
460    #[test]
461    fn no_savings_when_margin_tiny() {
462        let d = sample_decision(2.0, (0.7, 0.95), 0.1, 0.105);
463        let disc = disclose(
464            &d,
465            DecisionDomain::DiffStrategy,
466            DisclosureLevel::PlainEnglish,
467        );
468        let expl = disc.explanation.unwrap();
469        assert!(
470            !expl.contains("saving"),
471            "should not mention savings when margin < 1%: {expl}"
472        );
473    }
474}