Skip to main content

zero_tui/widgets/
verdict.rs

1//! Verdict block — card-style widget that renders a single
2//! [`Evaluation`] as a compact, scannable decision surface.
3//!
4//! # Layout
5//!
6//! ```text
7//!  PASS  BTC  conf 72%
8//!  ├─ stage1 : PASS
9//!  ├─ stage2 : HOLD
10//!  └─ stage3 : PASS
11//!  rationale: trend aligned with regime; volume confirming.
12//! ```
13//!
14//! - Row 1 is the **verdict chip** — one of `PASS`, `HOLD`,
15//!   `REJECT`, or a pass-through of whatever the engine sent if
16//!   it's a string we don't recognize. Color tracks the severity:
17//!   green (primary) on PASS, amber (caution) on HOLD, red
18//!   (alert + bold) on REJECT. Everything else renders in
19//!   metadata color so a typo on the engine side does not look
20//!   authoritative.
21//! - Confidence is rendered as an integer percentage next to
22//!   the chip. Values outside `[0.0, 1.0]` are rounded into
23//!   range — we do not display `200%`.
24//! - Gates are stacked vertically, sorted lexically for stable
25//!   rendering across frames. Gate statuses use the same
26//!   severity palette as the verdict chip.
27//! - Rationale wraps to one row and is truncated with `…` at
28//!   the right edge when it doesn't fit. The full text lives in
29//!   the engine's response; the widget's job is at-a-glance
30//!   triage, not prose.
31//!
32//! # Honest "no verdict" state
33//!
34//! When `evaluation.verdict` is `None` the widget renders a
35//! single low-contrast row:
36//!
37//! ```text
38//!  (no verdict — `/evaluate <coin>` to request one)
39//! ```
40//!
41//! No chip, no fake gates, no placeholder confidence bar. The
42//! widget never fabricates data — the verdict block is a trust
43//! surface, and an empty gate table rendered as `?` or `--`
44//! would be visually indistinguishable from a real partial
45//! engine response.
46
47use ratatui::buffer::Buffer;
48use ratatui::layout::Rect;
49use ratatui::style::{Modifier, Style};
50use ratatui::text::{Line, Span};
51use ratatui::widgets::Widget;
52use zero_engine_client::Evaluation;
53
54use crate::theme::Theme;
55
56/// Normalized verdict severity. Maps the engine's string verdict
57/// onto a finite palette so the widget's color rules are
58/// exhaustive.
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum VerdictSeverity {
61    Pass,
62    Hold,
63    Reject,
64    Unknown,
65}
66
67impl VerdictSeverity {
68    #[must_use]
69    pub fn parse(s: &str) -> Self {
70        match s.trim().to_ascii_uppercase().as_str() {
71            "PASS" | "APPROVE" | "OK" => Self::Pass,
72            "HOLD" | "WAIT" | "PARTIAL" => Self::Hold,
73            "REJECT" | "DENY" | "FAIL" => Self::Reject,
74            _ => Self::Unknown,
75        }
76    }
77
78    #[must_use]
79    const fn label(self) -> &'static str {
80        match self {
81            Self::Pass => "PASS",
82            Self::Hold => "HOLD",
83            Self::Reject => "REJECT",
84            Self::Unknown => "?",
85        }
86    }
87
88    fn style(self, theme: &Theme) -> Style {
89        match self {
90            Self::Pass => Style::default()
91                .fg(theme.primary)
92                .add_modifier(Modifier::BOLD),
93            Self::Hold => Style::default()
94                .fg(theme.caution)
95                .add_modifier(Modifier::BOLD),
96            Self::Reject => Style::default()
97                .fg(theme.alert)
98                .add_modifier(Modifier::BOLD),
99            Self::Unknown => Style::default()
100                .fg(theme.metadata)
101                .add_modifier(Modifier::DIM),
102        }
103    }
104}
105
106#[derive(Debug)]
107pub struct VerdictBlock<'a> {
108    pub evaluation: &'a Evaluation,
109    pub theme: Theme,
110}
111
112impl Widget for VerdictBlock<'_> {
113    fn render(self, area: Rect, buf: &mut Buffer) {
114        if area.height == 0 || area.width == 0 {
115            return;
116        }
117        // Clear first so a shrinking pane does not leave ghost
118        // glyphs from the previous verdict.
119        for y in area.top()..area.bottom() {
120            for x in area.left()..area.right() {
121                buf[(x, y)].set_char(' ');
122            }
123        }
124
125        // The engine's `/evaluate/{coin}` response is always
126        // populated, so an empty `layers` list is the reliable
127        // "nothing to show" sentinel — not a missing verdict
128        // string. A populated evaluation means we can always
129        // derive PASS / HOLD / REJECT from the layer results.
130        if self.evaluation.layers.is_empty() && self.evaluation.direction.is_none() {
131            let line = Line::from(vec![Span::styled(
132                " (no verdict — `/evaluate <coin>` to request one)",
133                Style::default().fg(self.theme.metadata),
134            )]);
135            line.render(row(area, 0), buf);
136            return;
137        }
138
139        let sev = VerdictSeverity::parse(self.evaluation.verdict());
140
141        // Row 0 — chip + coin + confidence.
142        let chip = Span::styled(
143            format!(" {} ", sev.label()),
144            sev.style(&self.theme).add_modifier(Modifier::REVERSED),
145        );
146        let coin = Span::styled(
147            format!(" {}", self.evaluation.coin.as_deref().unwrap_or("?")),
148            Style::default()
149                .fg(self.theme.primary)
150                .add_modifier(Modifier::BOLD),
151        );
152        let conf = Span::styled(
153            format!(" conf {}%", confidence_pct(self.evaluation.conviction)),
154            Style::default().fg(self.theme.metadata),
155        );
156        Line::from(vec![chip, coin, conf]).render(row(area, 0), buf);
157
158        // Rows 1..=N — layer table in engine order. The engine
159        // already decides the row sequence; we preserve it so
160        // `layer_0 .. layer_N` reads top-to-bottom the way the
161        // gate stack is written in the engine source, rather
162        // than re-sorting to a lexical order that would scramble
163        // `layer_10` above `layer_2`.
164        let layer_count = self.evaluation.layers.len();
165        for (i, layer) in self.evaluation.layers.iter().enumerate() {
166            let y = 1 + u16::try_from(i).unwrap_or(u16::MAX);
167            let target_row = row(area, y);
168            if target_row.height == 0 {
169                break;
170            }
171            let is_last = i + 1 == layer_count;
172            let connector = if is_last { "└─ " } else { "├─ " };
173            let status = if layer.passed { "PASS" } else { "REJECT" };
174            let gate_sev = VerdictSeverity::parse(status);
175            Line::from(vec![
176                Span::styled(
177                    format!(" {connector}"),
178                    Style::default().fg(self.theme.metadata),
179                ),
180                Span::styled(
181                    format!("{:<10}", layer.layer),
182                    Style::default().fg(self.theme.metadata),
183                ),
184                Span::styled(format!(" : {status}"), gate_sev.style(&self.theme)),
185            ])
186            .render(target_row, buf);
187        }
188
189        // Rationale — synthesized from the real fields the engine
190        // emits: regime + direction + consensus. When the engine
191        // later adds a `rationale` string we can prefer it, but
192        // today the wire format does not carry one.
193        let rationale = synthesize_rationale(self.evaluation);
194        if !rationale.is_empty() {
195            let y = 1 + u16::try_from(layer_count).unwrap_or(u16::MAX);
196            let target_row = row(area, y);
197            if target_row.height > 0 {
198                let available = usize::from(target_row.width).saturating_sub(13);
199                let clipped = truncate_with_ellipsis(&rationale, available);
200                Line::from(vec![
201                    Span::styled(" rationale: ", Style::default().fg(self.theme.metadata)),
202                    Span::styled(clipped, Style::default().fg(self.theme.primary)),
203                ])
204                .render(target_row, buf);
205            }
206        }
207    }
208}
209
210/// Build a one-line rationale from the fields the engine actually
211/// emits. Returns an empty string when nothing useful is available,
212/// which the widget treats as "skip the rationale row."
213fn synthesize_rationale(e: &Evaluation) -> String {
214    let mut parts: Vec<String> = Vec::new();
215    if let Some(dir) = e.direction.as_deref().filter(|d| !d.is_empty()) {
216        parts.push(format!("direction {dir}"));
217    }
218    if let Some(reg) = e.regime.as_deref().filter(|s| !s.is_empty()) {
219        parts.push(format!("regime {reg}"));
220    }
221    if let Some(cons) = e.consensus {
222        parts.push(format!("consensus {cons}"));
223    }
224    parts.join(" · ")
225}
226
227fn confidence_pct(v: Option<f64>) -> i32 {
228    let Some(x) = v else {
229        return 0;
230    };
231    // Input is clamped to `[0.0, 1.0]` before scaling, so the
232    // product is in `[0.0, 100.0]` — well within `i32` range and
233    // never negative. The cast is bounded, not truncating.
234    #[allow(clippy::cast_possible_truncation)]
235    let pct = (x.clamp(0.0, 1.0) * 100.0).round() as i32;
236    pct
237}
238
239fn truncate_with_ellipsis(s: &str, max_chars: usize) -> String {
240    if max_chars == 0 {
241        return String::new();
242    }
243    let total = s.chars().count();
244    if total <= max_chars {
245        return s.to_string();
246    }
247    // Reserve one cell for the ellipsis.
248    let keep = max_chars.saturating_sub(1);
249    let prefix: String = s.chars().take(keep).collect();
250    format!("{prefix}…")
251}
252
253fn row(area: Rect, y_offset: u16) -> Rect {
254    let abs_y = area.y.saturating_add(y_offset);
255    if abs_y >= area.bottom() {
256        return Rect {
257            x: area.x,
258            y: area.bottom().saturating_sub(1),
259            width: area.width,
260            height: 0,
261        };
262    }
263    Rect {
264        x: area.x,
265        y: abs_y,
266        width: area.width,
267        height: 1,
268    }
269}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274    use ratatui::Terminal;
275    use ratatui::backend::TestBackend;
276    use zero_engine_client::models::EvaluationLayer;
277
278    fn render(e: &Evaluation, width: u16, height: u16) -> Vec<String> {
279        let backend = TestBackend::new(width, height);
280        let mut term = Terminal::new(backend).expect("term");
281        term.draw(|f| {
282            let w = VerdictBlock {
283                evaluation: e,
284                theme: Theme::default(),
285            };
286            f.render_widget(w, f.area());
287        })
288        .expect("draw");
289        let buf = term.backend().buffer().clone();
290        (0..buf.area.height)
291            .map(|y| {
292                (0..buf.area.width)
293                    .map(|x| buf[(x, y)].symbol().to_string())
294                    .collect::<String>()
295                    .trim_end()
296                    .to_string()
297            })
298            .collect()
299    }
300
301    fn layer(name: &str, passed: bool) -> EvaluationLayer {
302        EvaluationLayer {
303            layer: name.into(),
304            passed,
305            value: serde_json::Value::Null,
306            detail: String::new(),
307        }
308    }
309
310    fn pass_eval() -> Evaluation {
311        Evaluation {
312            coin: Some("BTC".into()),
313            direction: Some("LONG".into()),
314            conviction: Some(0.72),
315            regime: Some("trending".into()),
316            consensus: Some(8),
317            layers: vec![
318                layer("layer_0", true),
319                layer("layer_1", true),
320                layer("layer_2", true),
321            ],
322            ..Default::default()
323        }
324    }
325
326    #[test]
327    fn renders_verdict_coin_and_confidence() {
328        let lines = render(&pass_eval(), 60, 6);
329        assert!(lines[0].contains("PASS"), "verdict chip missing: {lines:?}");
330        assert!(lines[0].contains("BTC"), "coin missing: {lines:?}");
331        assert!(
332            lines[0].contains("conf 72%"),
333            "confidence missing: {lines:?}"
334        );
335    }
336
337    #[test]
338    fn layers_render_in_engine_order_with_tree_connectors() {
339        let lines = render(&pass_eval(), 60, 6);
340        assert!(lines[1].contains("├─ layer_0"), "row1 wrong: {lines:?}");
341        assert!(lines[2].contains("├─ layer_1"), "row2 wrong: {lines:?}");
342        assert!(
343            lines[3].contains("└─ layer_2"),
344            "row3 wrong (last): {lines:?}"
345        );
346    }
347
348    #[test]
349    fn rejected_layer_marks_overall_verdict_reject() {
350        let mut e = pass_eval();
351        e.layers[1].passed = false;
352        let lines = render(&e, 60, 6);
353        assert!(
354            lines[0].contains("REJECT"),
355            "overall verdict should flip to REJECT when any layer fails: {lines:?}"
356        );
357    }
358
359    #[test]
360    fn rationale_synthesizes_from_direction_regime_consensus() {
361        let lines = render(&pass_eval(), 60, 6);
362        let rat = lines
363            .iter()
364            .find(|l| l.contains("rationale:"))
365            .expect("rationale row must render");
366        assert!(rat.contains("direction LONG"), "direction missing: {rat:?}");
367        assert!(rat.contains("regime trending"), "regime missing: {rat:?}");
368        assert!(rat.contains("consensus 8"), "consensus missing: {rat:?}");
369    }
370
371    #[test]
372    fn long_rationale_truncates_to_fit() {
373        let mut e = pass_eval();
374        e.regime = Some("x".repeat(500));
375        let lines = render(&e, 40, 6);
376        let rat = lines
377            .iter()
378            .find(|l| l.contains("rationale:"))
379            .expect("rationale row must render");
380        assert!(rat.contains('…'), "long rationale must ellipsize: {rat:?}");
381        assert!(
382            rat.chars().count() <= 40,
383            "rationale must fit within width: {rat:?}"
384        );
385    }
386
387    #[test]
388    fn missing_verdict_renders_honest_empty_row() {
389        let e = Evaluation::default();
390        let lines = render(&e, 60, 3);
391        assert!(
392            lines[0].contains("no verdict"),
393            "expected honest empty state: {lines:?}"
394        );
395        for needle in ["PASS", "REJECT", "├─", "conf "] {
396            for line in &lines {
397                assert!(!line.contains(needle), "fake {needle} leaked: {line:?}");
398            }
399        }
400    }
401
402    #[test]
403    fn hold_when_all_pass_but_direction_none() {
404        let mut e = pass_eval();
405        e.direction = Some("NONE".into());
406        let lines = render(&e, 60, 6);
407        assert!(
408            lines[0].contains("HOLD"),
409            "direction=NONE with all layers passing should be HOLD: {lines:?}"
410        );
411    }
412
413    #[test]
414    fn confidence_clamps_out_of_range_values() {
415        assert_eq!(confidence_pct(Some(-0.2)), 0);
416        assert_eq!(confidence_pct(Some(1.4)), 100);
417        assert_eq!(confidence_pct(None), 0);
418        assert_eq!(confidence_pct(Some(0.5)), 50);
419    }
420
421    #[test]
422    fn verdict_severity_parses_common_strings() {
423        assert_eq!(VerdictSeverity::parse("PASS"), VerdictSeverity::Pass);
424        assert_eq!(VerdictSeverity::parse("pass"), VerdictSeverity::Pass);
425        assert_eq!(VerdictSeverity::parse("HOLD"), VerdictSeverity::Hold);
426        assert_eq!(VerdictSeverity::parse("REJECT"), VerdictSeverity::Reject);
427        assert_eq!(VerdictSeverity::parse(""), VerdictSeverity::Unknown);
428        assert_eq!(VerdictSeverity::parse("idk"), VerdictSeverity::Unknown);
429    }
430}