paperboy 0.4.0

A Rust TUI API tester
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
//! Row classes and filters — "show me only the rows that matter".
//!
//! Every reader of a comparison or a ground-truthed run asks the same three
//! questions within a minute of opening it: *what changed?*, *what is wrong?*,
//! and *what did we break?* This module answers them once, off the row model,
//! so the interactive HTML export and the two in-app grids filter to exactly
//! the same rows.
//!
//! It lives beside [`super::metrics`] for the same reason that module exists:
//! the moment a renderer decides for itself what "regressed" means, two views
//! of one run start disagreeing about which rows they are hiding — and a filter
//! that quietly drops a row is worse than no filter at all.

use super::labels::LabelMap;
use super::metrics::Metrics;
use super::model::{OutputColumn, ReportResult, Trend, Verdict};

/// What is true of one row, read off the reserved columns the run already
/// filled in rather than recomputed. Cheap enough to build per row per frame.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct RowFacts {
    /// The comparison found a difference on this row. `false` for a report with
    /// no comparison, and for a row that matched its baseline.
    pub differs: bool,
    /// The row's ground-truth roll-up, if it has one.
    pub verdict: Option<Verdict>,
    /// Which way the row moved against the baseline, if both sides were scored.
    pub trend: Option<Trend>,
}

impl RowFacts {
    /// The facts of row `r`.
    pub fn of(result: &ReportResult, r: usize) -> RowFacts {
        let Some(row) = result.rows.get(r) else {
            return RowFacts::default();
        };
        let cell = |name: &str| row.cells.get(name).map(String::as_str);
        RowFacts {
            // Any non-empty `Result` other than the "matched" phrase is a diff
            // listing. `no baseline`/`no candidate` count as differences: a row
            // that exists on only one side is exactly what a reader scanning
            // for changes needs to see.
            differs: cell(super::compare::RESULT_COLUMN)
                .is_some_and(|v| !v.trim().is_empty() && v != super::compare::MATCH),
            verdict: cell(super::compare::CORRECT_COLUMN).and_then(|v| match v {
                v if v == Verdict::Correct.as_str() => Some(Verdict::Correct),
                v if v == Verdict::Incorrect.as_str() => Some(Verdict::Incorrect),
                v if v == Verdict::Untested.as_str() => Some(Verdict::Untested),
                _ => None,
            }),
            // Taken from the scored cells, not the column's text: `unchanged`
            // is what both a still-right and a still-wrong row say, and a
            // filter that confused the two would hide rows it claimed to show.
            // Rows carried over from a source with no scoring (nothing puts one
            // there today) fall back to the word, which can still distinguish
            // the two that matter.
            trend: result.row_trend(r).or_else(|| {
                cell(super::compare::TREND_COLUMN).and_then(|v| match v {
                    v if v == Trend::Fixed.as_str() => Some(Trend::Fixed),
                    v if v == Trend::Regressed.as_str() => Some(Trend::Regressed),
                    _ => None,
                })
            }),
        }
    }
}

/// One way of narrowing the table.
///
/// Deliberately a small closed set rather than a query language: these are the
/// questions people actually ask, and a filter you have to *write* is one
/// nobody uses.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RowFilter {
    /// Every row (the default).
    All,
    /// Rows the comparison found a difference on.
    Differ,
    /// Rows whose ground-truth roll-up is `incorrect`. Untested rows are *not*
    /// included: "wrong" and "unchecked" are different problems, and merging
    /// them is how an unlabelled corpus comes to look like a broken engine.
    Incorrect,
    /// Rows that were right before and are wrong now — the ones a release
    /// decision turns on.
    Regressed,
    /// One cell of a confusion matrix: rows where `column`'s ground truth is
    /// the `truth` class and its answer is the `answer` class. This is what
    /// makes the matrix clickable — "which 7 rows are those?" is the first
    /// question anyone asks of an off-diagonal count.
    MatrixCell {
        column: String,
        truth: String,
        answer: String,
    },
}

impl RowFilter {
    /// The filter's button text.
    ///
    /// English, like the reserved column values it filters on: the interactive
    /// HTML export is a *document*, produced by writers that carry no `Strings`
    /// at all, and a shared exported file must read the same for everyone who
    /// receives it. The in-app views label their own controls.
    pub fn label(&self) -> String {
        match self {
            RowFilter::All => "All".to_string(),
            RowFilter::Differ => "Differences".to_string(),
            RowFilter::Incorrect => "Incorrect".to_string(),
            RowFilter::Regressed => "Regressions".to_string(),
            RowFilter::MatrixCell { truth, answer, .. } => format!("{truth}{answer}"),
        }
    }

    /// The filters worth offering for `result`, in the order they should be
    /// shown. A filter that could only ever select nothing is left out: an
    /// always-empty "Regressions" button on a report with no ground truth
    /// invites the reader to conclude there are none.
    pub fn available(result: &ReportResult) -> Vec<RowFilter> {
        let mut out = vec![RowFilter::All];
        let facts: Vec<RowFacts> = (0..result.rows.len())
            .map(|r| RowFacts::of(result, r))
            .collect();
        if facts.iter().any(|f| f.differs) {
            out.push(RowFilter::Differ);
        }
        if facts.iter().any(|f| f.verdict == Some(Verdict::Incorrect)) {
            out.push(RowFilter::Incorrect);
        }
        if facts.iter().any(|f| f.trend == Some(Trend::Regressed)) {
            out.push(RowFilter::Regressed);
        }
        out
    }

    /// Whether row `r` passes this filter.
    pub fn matches(
        &self,
        result: &ReportResult,
        columns: &[OutputColumn],
        labels: &LabelMap,
        r: usize,
    ) -> bool {
        match self {
            RowFilter::All => true,
            RowFilter::Differ => RowFacts::of(result, r).differs,
            RowFilter::Incorrect => RowFacts::of(result, r).verdict == Some(Verdict::Incorrect),
            RowFilter::Regressed => RowFacts::of(result, r).trend == Some(Trend::Regressed),
            RowFilter::MatrixCell {
                column,
                truth,
                answer,
            } => {
                let key = (r, column.clone());
                // Only a scored cell can be in the matrix, and the comparison
                // is by *class*, exactly as the matrix counted it — otherwise
                // clicking a cell counted through `# labels:` would select rows
                // by raw text and come back with fewer rows than the count.
                let Some(expected) = result.truths.get(&key) else {
                    return false;
                };
                let Some(row) = result.rows.get(r) else {
                    return false;
                };
                let Some(col) = columns.iter().find(|c| &c.header == column) else {
                    return false;
                };
                labels.label_of(expected) == *truth
                    && labels.label_of(&col.value(row, &result.no_match_marker)) == *answer
            }
        }
    }
}

/// Every filter a report offers, and how many of them are toolbar buttons.
///
/// The buttons come first (see [`RowFilter::available`]), then one
/// [`RowFilter::MatrixCell`] per non-empty confusion-matrix cell — "which seven
/// rows are those?" is the first question anyone asks of an off-diagonal count,
/// so every count is a way into the rows it counted.
///
/// One list rather than two because the HTML export indexes into it: rows carry
/// the filter indices they pass, so the browser only ever compares numbers.
/// Built here rather than in either renderer so the in-app views and the export
/// cannot end up offering different sets.
pub fn all_filters(result: &ReportResult, metrics: Option<&Metrics>) -> (Vec<RowFilter>, usize) {
    let mut filters = RowFilter::available(result);
    let buttons = filters.len();
    if let Some(metrics) = metrics {
        for m in &metrics.columns {
            let Some(matrix) = &m.matrix else { continue };
            for (t, truth) in matrix.axis.iter().enumerate() {
                for (p, answer) in matrix.axis.iter().enumerate() {
                    if matrix.counts[t][p] > 0 {
                        filters.push(RowFilter::MatrixCell {
                            column: m.header.clone(),
                            truth: truth.clone(),
                            answer: answer.clone(),
                        });
                    }
                }
            }
        }
    }
    (filters, buttons)
}

/// The rows to show: those passing `filter` and containing `text` (a
/// case-insensitive substring of any shown column's value; empty matches
/// everything).
///
/// The text search runs over the columns the caller is *showing*, so it finds
/// what the reader can see. Pending rows are excluded — a live run must not
/// offer a filtered view that is mostly blank skeleton.
// Consumed by the in-app interactive views, which unlike the HTML export
// filter in Rust rather than in the browser; kept beside the predicates it
// applies so the two renderings can never disagree about what they hide. Only
// the GUI results view calls it so far, so the non-GUI build still needs the
// allow; drop it once the TUI results view filters too.
#[cfg_attr(not(feature = "gui"), allow(dead_code))]
pub fn visible_rows(
    result: &ReportResult,
    columns: &[OutputColumn],
    labels: &LabelMap,
    filter: &RowFilter,
    text: &str,
) -> Vec<usize> {
    let needle = text.trim().to_lowercase();
    (0..result.rows.len())
        .filter(|r| !result.pending.contains(r))
        .filter(|&r| filter.matches(result, columns, labels, r))
        .filter(|&r| {
            needle.is_empty()
                || result.rows.get(r).is_some_and(|row| {
                    columns.iter().any(|c| {
                        c.value(row, &result.no_match_marker)
                            .to_lowercase()
                            .contains(&needle)
                    })
                })
        })
        .collect()
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::report::compare::{CORRECT_COLUMN, MATCH, RESULT_COLUMN, TREND_COLUMN};
    use crate::report::model::ReportRow;

    fn row(cells: &[(&str, &str)]) -> ReportRow {
        ReportRow {
            cells: cells
                .iter()
                .map(|(k, v)| (k.to_string(), v.to_string()))
                .collect(),
            ..Default::default()
        }
    }

    fn col(header: &str) -> OutputColumn {
        OutputColumn {
            header: header.to_string(),
            sources: vec![header.to_string()],
            stats: Vec::new(),
            image: None,
            truth: None,
            detail: false,
        }
    }

    /// A four-row fixture covering every class: matched-and-right,
    /// changed-and-fixed, changed-and-regressed, changed-and-still-wrong.
    fn fixture() -> ReportResult {
        ReportResult {
            column_order: vec![
                RESULT_COLUMN.into(),
                CORRECT_COLUMN.into(),
                TREND_COLUMN.into(),
                "Name".into(),
                "Verdict".into(),
            ],
            rows: vec![
                row(&[
                    (RESULT_COLUMN, MATCH),
                    (CORRECT_COLUMN, "correct"),
                    (TREND_COLUMN, "unchanged"),
                    ("Name", "alpha"),
                    ("Verdict", "Low Risk"),
                ]),
                row(&[
                    (RESULT_COLUMN, "Verdict: a≠b"),
                    (CORRECT_COLUMN, "correct"),
                    (TREND_COLUMN, "fixed"),
                    ("Name", "beta"),
                    ("Verdict", "Low Risk"),
                ]),
                row(&[
                    (RESULT_COLUMN, "Verdict: a≠b"),
                    (CORRECT_COLUMN, "incorrect"),
                    (TREND_COLUMN, "regressed"),
                    ("Name", "gamma"),
                    ("Verdict", "High Risk"),
                ]),
                row(&[
                    (RESULT_COLUMN, "Verdict: a≠b"),
                    (CORRECT_COLUMN, "incorrect"),
                    // A still-wrong row reads `unchanged` like a still-right
                    // one; what tells them apart is the scored cell below.
                    (TREND_COLUMN, "unchanged"),
                    ("Name", "delta"),
                    ("Verdict", "High Risk"),
                ]),
            ],
            trends: [
                ((0, "Verdict".to_string()), Trend::Unchanged),
                ((1, "Verdict".to_string()), Trend::Fixed),
                ((2, "Verdict".to_string()), Trend::Regressed),
                ((3, "Verdict".to_string()), Trend::StillWrong),
            ]
            .into_iter()
            .collect(),
            ..Default::default()
        }
    }

    /// The two rows that didn't move share a word but not a class: a filter
    /// reading the column's text alone would sweep the still-failing row in
    /// with the passing ones.
    #[test]
    fn a_still_wrong_row_is_told_apart_from_a_still_right_one() {
        let res = fixture();
        assert_eq!(RowFacts::of(&res, 0).trend, Some(Trend::Unchanged));
        assert_eq!(RowFacts::of(&res, 3).trend, Some(Trend::StillWrong));
        assert_eq!(
            res.rows[0].cells.get(TREND_COLUMN),
            res.rows[3].cells.get(TREND_COLUMN),
            "even though the column says the same thing about both"
        );
    }

    fn columns() -> Vec<OutputColumn> {
        vec![col("Name"), col("Verdict")]
    }

    #[test]
    fn each_filter_selects_exactly_its_class() {
        let res = fixture();
        let cols = columns();
        let labels = LabelMap::parse(&[]);
        let pick = |f: RowFilter| visible_rows(&res, &cols, &labels, &f, "");

        assert_eq!(pick(RowFilter::All), vec![0, 1, 2, 3]);
        assert_eq!(
            pick(RowFilter::Differ),
            vec![1, 2, 3],
            "the matched row is not a difference"
        );
        assert_eq!(
            pick(RowFilter::Incorrect),
            vec![2, 3],
            "both wrong rows, whether or not the wrongness is new"
        );
        assert_eq!(
            pick(RowFilter::Regressed),
            vec![2],
            "`still wrong` is failing, but it is not a regression"
        );
    }

    /// An unlabelled row is unchecked, not wrong: merging the two is how a
    /// half-tagged corpus comes to look like a broken engine.
    #[test]
    fn an_untested_row_is_not_incorrect() {
        let mut res = fixture();
        res.rows.push(row(&[
            (RESULT_COLUMN, MATCH),
            (CORRECT_COLUMN, "untested"),
            ("Name", "epsilon"),
        ]));
        let cols = columns();
        let labels = LabelMap::parse(&[]);
        assert_eq!(
            visible_rows(&res, &cols, &labels, &RowFilter::Incorrect, ""),
            vec![2, 3]
        );
    }

    #[test]
    fn the_text_filter_searches_the_shown_columns_and_combines_with_the_class() {
        let res = fixture();
        let cols = columns();
        let labels = LabelMap::parse(&[]);
        assert_eq!(
            visible_rows(&res, &cols, &labels, &RowFilter::All, "GAM"),
            vec![2],
            "case-insensitive substring of a shown value"
        );
        assert_eq!(
            visible_rows(&res, &cols, &labels, &RowFilter::Incorrect, "delta"),
            vec![3],
            "the text and the class narrow together"
        );
        assert!(
            visible_rows(&res, &cols, &labels, &RowFilter::All, "unchanged").is_empty(),
            "a value only in a column the caller isn't showing is not searched"
        );
    }

    /// Clicking a matrix cell has to come back with exactly the rows it counted
    /// — including through the label classes, or a cell counted as `Fail` would
    /// select nothing.
    #[test]
    fn a_matrix_cell_selects_the_rows_it_counted_through_the_label_classes() {
        let mut res = fixture();
        let labels = LabelMap::parse(&[
            "Pass = pass, real, low risk",
            "Fail = fail, fake, high risk",
        ]);
        for (r, truth) in [(0, "real"), (1, "real"), (2, "real"), (3, "fake")] {
            res.truths.insert((r, "Verdict".to_string()), truth.into());
        }
        let cols = columns();
        let cell = |truth: &str, answer: &str| {
            visible_rows(
                &res,
                &cols,
                &labels,
                &RowFilter::MatrixCell {
                    column: "Verdict".into(),
                    truth: truth.into(),
                    answer: answer.into(),
                },
                "",
            )
        };
        assert_eq!(
            cell("Pass", "Pass"),
            vec![0, 1],
            "`real` and `Low Risk` are both the Pass class"
        );
        assert_eq!(cell("Pass", "Fail"), vec![2], "the off-diagonal cell");
        assert_eq!(cell("Fail", "Fail"), vec![3]);
        assert!(cell("Fail", "Pass").is_empty());
    }

    /// A filter that could only ever select nothing is not offered: an
    /// always-empty "Regressions" button reads as "there are none".
    #[test]
    fn only_the_filters_a_report_can_answer_are_offered() {
        assert_eq!(
            RowFilter::available(&fixture()),
            vec![
                RowFilter::All,
                RowFilter::Differ,
                RowFilter::Incorrect,
                RowFilter::Regressed
            ]
        );
        let plain = ReportResult {
            rows: vec![row(&[("Name", "a")])],
            ..Default::default()
        };
        assert_eq!(RowFilter::available(&plain), vec![RowFilter::All]);
    }

    /// A live run must not offer a filtered view that is mostly empty skeleton.
    #[test]
    fn rows_that_have_not_run_yet_are_never_shown() {
        let mut res = fixture();
        res.pending.insert(2);
        let cols = columns();
        let labels = LabelMap::parse(&[]);
        assert_eq!(
            visible_rows(&res, &cols, &labels, &RowFilter::All, ""),
            vec![0, 1, 3]
        );
    }
}