1use crate::decision_core::{Action, Decision};
13use crate::unified_evidence::DecisionDomain;
14use std::fmt;
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
18#[repr(u8)]
19pub enum DisclosureLevel {
20 TrafficLight = 0,
22 PlainEnglish = 1,
24 EvidenceTerms = 2,
26 FullBayesian = 3,
28}
29
30impl DisclosureLevel {
31 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum TrafficLight {
46 Green,
48 Yellow,
50 Red,
52}
53
54impl TrafficLight {
55 #[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 #[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#[derive(Debug, Clone)]
89pub struct Disclosure {
90 pub domain: DecisionDomain,
92 pub level: DisclosureLevel,
94 pub signal: TrafficLight,
96 pub action_label: String,
98 pub explanation: Option<String>,
100 pub evidence_terms: Option<Vec<DisclosureEvidence>>,
102 pub bayesian_details: Option<BayesianDetails>,
104}
105
106#[derive(Debug, Clone)]
108pub struct DisclosureEvidence {
109 pub label: &'static str,
111 pub bayes_factor: f64,
113 pub direction: EvidenceDirection,
115}
116
117#[derive(Debug, Clone, Copy, PartialEq, Eq)]
119pub enum EvidenceDirection {
120 Supporting,
122 Opposing,
124 Neutral,
126}
127
128impl EvidenceDirection {
129 #[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#[derive(Debug, Clone)]
144pub struct BayesianDetails {
145 pub log_posterior: f64,
147 pub confidence_interval: (f64, f64),
149 pub expected_loss: f64,
151 pub next_best_loss: f64,
153 pub loss_avoided: f64,
155}
156
157pub 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
211fn 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
237fn 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
250impl fmt::Display for Disclosure {
252 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
253 write!(f, "[{}] {}", self.signal, self.action_label)?;
255
256 if let Some(ref explanation) = self.explanation {
258 write!(f, "\n {explanation}")?;
259 }
260
261 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 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 #[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}