1use std::collections::BTreeMap;
11
12use super::prompts;
13use super::score::{Class, Score};
14use crate::result::{Cell, CellStyle, Column, ResultTable, Stat};
15
16fn render_class(class: &Class) -> (String, CellStyle, String) {
19 match class {
20 Class::Identical => ("identical".into(), CellStyle::Good, String::new()),
21 Class::Diverged { at, detail } => (
22 format!("DIVERGED @ char {at}"),
23 CellStyle::Bad,
24 detail.clone(),
25 ),
26 Class::Contaminated { foreign } => (
27 "CONTAMINATED".into(),
28 CellStyle::Bad,
29 format!("carries foreign canary {foreign}"),
30 ),
31 Class::Persistent { at } => (
32 format!("PERSISTENT @ char {at}"),
33 CellStyle::Bad,
34 "state survived into solo execution".into(),
35 ),
36 Class::AloneUnstable => (
37 "alone-unstable".into(),
38 CellStyle::Warn,
39 "two solo runs disagree (#435) — contamination unattributable".into(),
40 ),
41 Class::Unmeasured { why } => ("unmeasured".into(), CellStyle::Warn, why.clone()),
42 }
43}
44
45pub(super) fn table(s: &Score) -> ResultTable {
48 let mut t = ResultTable::new(
49 "PROBE × LEG",
50 vec![
51 Column::left("Probe", 6),
52 Column::left("Leg", 6),
53 Column::left("Result", 20),
54 Column::left("Detail", 44),
55 ],
56 );
57 for ((probe_idx, leg), class) in &s.cells {
58 let name = prompts::PROBES
59 .get(*probe_idx)
60 .map(|p| p.name)
61 .unwrap_or("?");
62 let (what, style, detail) = render_class(class);
63 t.push(vec![
64 Cell::new(name),
65 Cell::styled(leg.clone(), CellStyle::Dim),
66 Cell::styled(what, style),
67 Cell::styled(detail, CellStyle::Dim),
68 ]);
69 }
70 t
71}
72
73fn count_stat(label: &str, n: usize) -> Stat {
74 Stat::new(label, n.to_string(), "").with_style(if n == 0 {
75 CellStyle::Good
76 } else {
77 CellStyle::Bad
78 })
79}
80
81pub(super) fn summary(s: &Score) -> Vec<Stat> {
82 vec![
83 Stat::new("Identical", format!("{}/{}", s.identical, s.compared), "").with_style(
84 if s.compared > 0 && s.identical == s.compared {
85 CellStyle::Good
86 } else {
87 CellStyle::Bad
88 },
89 ),
90 count_stat("Contaminated", s.contaminated),
91 count_stat("Persistent", s.persistent),
92 count_stat("Diverged", s.diverged),
93 Stat::new("Tokens compared", s.tokens_compared.to_string(), "")
94 .with_style(CellStyle::Accent),
95 ]
96}
97
98pub(super) fn metrics(s: &Score) -> BTreeMap<String, f64> {
101 [
102 ("compared", s.compared),
103 ("identical", s.identical),
104 ("diverged", s.diverged),
105 ("contaminated", s.contaminated),
106 ("persistent", s.persistent),
107 ("alone_unstable", s.alone_unstable),
108 ("unmeasured", s.unmeasured),
109 ("tokens_compared", s.tokens_compared),
110 ]
111 .into_iter()
112 .map(|(k, v)| (k.to_string(), v as f64))
113 .collect()
114}
115
116#[cfg(test)]
117mod tests {
118 use super::super::score::{Class, Score};
119 use super::*;
120 use crate::result::CellStyle;
121
122 fn score_with(cells: Vec<((usize, &str), Class)>) -> Score {
123 let mut s = Score::default();
124 for ((i, leg), c) in cells {
125 match &c {
126 Class::Identical => s.identical += 1,
127 Class::Diverged { .. } => s.diverged += 1,
128 Class::Contaminated { .. } => s.contaminated += 1,
129 Class::Persistent { .. } => s.persistent += 1,
130 Class::AloneUnstable => s.alone_unstable += 1,
131 Class::Unmeasured { .. } => s.unmeasured += 1,
132 }
133 s.compared += 1;
134 s.cells.insert((i, leg.to_string()), c);
135 }
136 s
137 }
138
139 #[test]
140 fn every_class_renders_its_evidence_and_severity() {
141 for (class, expected) in [
142 (Class::Identical, ("identical", CellStyle::Good, "")),
143 (
144 Class::Diverged {
145 at: 4,
146 detail: "streams differ".into(),
147 },
148 ("DIVERGED @ char 4", CellStyle::Bad, "streams differ"),
149 ),
150 (
151 Class::Contaminated {
152 foreign: "FOREIGN".into(),
153 },
154 (
155 "CONTAMINATED",
156 CellStyle::Bad,
157 "carries foreign canary FOREIGN",
158 ),
159 ),
160 (
161 Class::Persistent { at: 7 },
162 (
163 "PERSISTENT @ char 7",
164 CellStyle::Bad,
165 "state survived into solo execution",
166 ),
167 ),
168 (
169 Class::AloneUnstable,
170 (
171 "alone-unstable",
172 CellStyle::Warn,
173 "two solo runs disagree (#435) — contamination unattributable",
174 ),
175 ),
176 (
177 Class::Unmeasured {
178 why: "timeout".into(),
179 },
180 ("unmeasured", CellStyle::Warn, "timeout"),
181 ),
182 ] {
183 let rendered = render_class(&class);
184 assert_eq!(
185 (rendered.0.as_str(), rendered.1, rendered.2.as_str()),
186 expected
187 );
188 }
189
190 let s = score_with(vec![
191 ((0, "c2"), Class::Identical),
192 (
193 (0, "c4"),
194 Class::Contaminated {
195 foreign: "QJ-CRIMSON-OTTER-77".into(),
196 },
197 ),
198 ]);
199 let t = table(&s);
200 assert_eq!(t.rows.len(), 2, "one row per cell");
201 let bad: Vec<_> = t
202 .rows
203 .iter()
204 .filter(|r| r[2].style == CellStyle::Bad)
205 .collect();
206 assert_eq!(bad.len(), 1, "exactly the contaminated cell is Bad");
207 assert!(bad[0][2].text.contains("CONTAMINATED"));
208 assert!(
209 bad[0][3].text.contains("QJ-CRIMSON-OTTER-77"),
210 "the operator's first question is WHOSE state leaked; the row must \
211 answer it: {:?}",
212 bad[0][3].text
213 );
214 let clean = t
215 .rows
216 .iter()
217 .find(|r| r[1].text == "c2")
218 .expect("the c2 row");
219 assert_eq!(clean[2].text, "identical");
220 assert_eq!(clean[2].style, CellStyle::Good);
221 }
222
223 #[test]
224 fn probe_rows_carry_the_probe_name_not_an_index() {
225 let s = score_with(vec![((1, "post"), Class::Persistent { at: 7 })]);
226 let t = table(&s);
227 assert_eq!(t.rows[0][0].text, prompts::PROBES[1].name);
228 assert!(t.rows[0][2].text.contains("PERSISTENT @ char 7"));
229 }
230
231 #[test]
232 fn summary_headline_tracks_the_verdict_direction() {
233 let clean = score_with(vec![((0, "c2"), Class::Identical)]);
234 assert_eq!(summary(&clean)[0].style, CellStyle::Good);
235
236 let dirty = score_with(vec![
237 ((0, "c2"), Class::Identical),
238 (
239 (1, "c2"),
240 Class::Diverged {
241 at: 3,
242 detail: "streams differ".into(),
243 },
244 ),
245 (
246 (0, "c4"),
247 Class::Contaminated {
248 foreign: "foreign".into(),
249 },
250 ),
251 ((1, "post"), Class::Persistent { at: 9 }),
252 ]);
253 let mut dirty = dirty;
254 dirty.tokens_compared = 42;
255 let tiles: Vec<_> = summary(&dirty)
256 .into_iter()
257 .map(|tile| (tile.label, tile.value, tile.style))
258 .collect();
259 assert_eq!(
260 tiles,
261 vec![
262 ("Identical".into(), "1/4".into(), CellStyle::Bad),
263 ("Contaminated".into(), "1".into(), CellStyle::Bad),
264 ("Persistent".into(), "1".into(), CellStyle::Bad),
265 ("Diverged".into(), "1".into(), CellStyle::Bad),
266 ("Tokens compared".into(), "42".into(), CellStyle::Accent),
267 ]
268 );
269 }
270
271 #[test]
272 fn metrics_carry_every_class_even_when_zero() {
273 let s = score_with(vec![((0, "c2"), Class::Identical)]);
274 let m = metrics(&s);
275 for key in [
276 "compared",
277 "identical",
278 "diverged",
279 "contaminated",
280 "persistent",
281 "alone_unstable",
282 "unmeasured",
283 "tokens_compared",
284 ] {
285 assert!(m.contains_key(key), "missing metric {key}");
286 }
287 assert_eq!(m["identical"], 1.0);
288 assert_eq!(m["contaminated"], 0.0, "zero, not absent");
289
290 let dirty = Score {
293 compared: 8,
294 identical: 7,
295 diverged: 6,
296 contaminated: 5,
297 persistent: 4,
298 alone_unstable: 3,
299 unmeasured: 2,
300 tokens_compared: 1,
301 ..Default::default()
302 };
303 assert_eq!(
304 metrics(&dirty),
305 [
306 ("alone_unstable".to_string(), 3.0),
307 ("compared".to_string(), 8.0),
308 ("contaminated".to_string(), 5.0),
309 ("diverged".to_string(), 6.0),
310 ("identical".to_string(), 7.0),
311 ("persistent".to_string(), 4.0),
312 ("tokens_compared".to_string(), 1.0),
313 ("unmeasured".to_string(), 2.0),
314 ]
315 .into_iter()
316 .collect()
317 );
318 }
319}