Skip to main content

vernier_core/
tables.rs

1//! Result tables — opt-in DataFrame-shaped views over the locked spine
2//! (per ADR-0019).
3//!
4//! Produces columnar buffers (`Vec<T>`-of-columns) that `vernier-ffi`
5//! mechanically rewrites into Arrow `RecordBatch`es. No Arrow types
6//! appear here, so non-Python downstreams can consume the same builders.
7//!
8//! ## Quirk dispositions
9//!
10//! - **C5** (`strict`): the `-1.0` sentinel from [`Accumulated`] is
11//!   mapped to `Option::None` here (not in the FFI conversion), so the
12//!   table type stays honest for non-Arrow consumers.
13
14use crate::accumulate::Accumulated;
15use crate::dataset::{Bbox, CocoDataset, CocoDetections, EvalDataset};
16use crate::error::EvalError;
17use crate::evaluate::{EvalGrid, COLLAPSED_CATEGORY_SENTINEL};
18use crate::summarize::{pairwise_sum, IOU_LOOKUP_TOL};
19use ndarray::{Array2, ArrayView2, Axis};
20use std::collections::HashMap;
21
22/// Which tables to compute for an `evaluate(...)` call.
23///
24/// All-`false` ([`Self::NONE`]) means "summary only" — the default
25/// `evaluate()` path. `per_detection` and `per_pair` additionally
26/// require the spine to retain its IoU matrices (set upstream via
27/// [`crate::EvaluateParams::retain_iou`]).
28#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
29pub struct TablesRequest {
30    /// Build a one-row-per-image rollup table.
31    pub per_image: bool,
32    /// Build a one-row-per-category summary table.
33    pub per_class: bool,
34    /// Build a one-row-per-detection table (requires IoU retention).
35    pub per_detection: bool,
36    /// Build a one-row-per-(DT, GT)-pair table (requires IoU retention).
37    pub per_pair: bool,
38}
39
40impl TablesRequest {
41    /// "No tables, summary only." Equivalent to `Default::default()`;
42    /// named for readability at call sites.
43    pub const NONE: Self = Self {
44        per_image: false,
45        per_class: false,
46        per_detection: false,
47        per_pair: false,
48    };
49
50    /// The two zero-overhead-on-the-spine tables: `per_image` +
51    /// `per_class`. Both fold over data the matching engine and
52    /// accumulator already produce; neither needs IoU retention.
53    pub const CHEAP: Self = Self {
54        per_image: true,
55        per_class: true,
56        per_detection: false,
57        per_pair: false,
58    };
59
60    /// Every table. Requires the upstream evaluator to have been built
61    /// with IoU retention enabled.
62    pub const ALL: Self = Self {
63        per_image: true,
64        per_class: true,
65        per_detection: true,
66        per_pair: true,
67    };
68
69    /// True iff at least one requested table requires the spine to
70    /// retain per-cell IoU matrices.
71    pub fn requires_iou_retention(&self) -> bool {
72        self.per_pair || self.per_detection
73    }
74}
75
76/// Configuration knobs for the expensive tables. Inert when the
77/// corresponding flag in [`TablesRequest`] is `false`.
78#[derive(Debug, Clone)]
79pub struct TablesConfig {
80    /// IoU floor for `per_pair`. Pairs with `iou < per_pair_iou_floor`
81    /// are dropped from the table. Default `0.1` matches the ADR.
82    pub per_pair_iou_floor: f64,
83    /// Hard cap on `per_pair` row count. Exceeding it raises
84    /// [`EvalError::PerPairOverflow`], not a silent truncation.
85    pub per_pair_max_rows: usize,
86    /// Whether `per_detection` rows include bbox geometry columns.
87    /// Off by default — most callers don't need them, and the cost is
88    /// non-trivial for D in the millions.
89    pub per_detection_with_geometry: bool,
90}
91
92impl Default for TablesConfig {
93    fn default() -> Self {
94        Self {
95            per_pair_iou_floor: 0.1,
96            per_pair_max_rows: 10_000_000,
97            per_detection_with_geometry: false,
98        }
99    }
100}
101
102/// One row per category — mirrors `summarize_detection`'s 12-stat
103/// layout, plus support counts and labels.
104///
105/// Numeric columns are `Option<f64>`; the `-1.0` sentinel (quirk
106/// **C5**) maps to `None` here so any non-Arrow consumer sees an
107/// honest type. Schema is pinned (see Arrow golden in
108/// `tests/python/tables/schemas/per_class.json`).
109#[derive(Debug, Clone)]
110pub struct PerClassTable {
111    /// COCO category id, or [`COLLAPSED_CATEGORY_SENTINEL`] if the
112    /// upstream evaluator ran with `use_cats=false`.
113    pub category_id: Vec<i64>,
114    /// Human-readable category name from
115    /// [`crate::dataset::CategoryMeta::name`]. The single-row collapsed
116    /// case carries `"(all categories)"`.
117    pub category_name: Vec<String>,
118    /// AP@.50:.95, area=all, maxDets=largest. `None` for cells with no
119    /// data (quirk **C5**).
120    pub ap: Vec<Option<f64>>,
121    /// AP@.50, area=all, maxDets=largest.
122    pub ap50: Vec<Option<f64>>,
123    /// AP@.75, area=all, maxDets=largest.
124    pub ap75: Vec<Option<f64>>,
125    /// AP@.50:.95, area=small, maxDets=largest.
126    pub ap_s: Vec<Option<f64>>,
127    /// AP@.50:.95, area=medium, maxDets=largest.
128    pub ap_m: Vec<Option<f64>>,
129    /// AP@.50:.95, area=large, maxDets=largest.
130    pub ap_l: Vec<Option<f64>>,
131    /// AR@.50:.95, area=all, maxDets=1.
132    pub ar_max_1: Vec<Option<f64>>,
133    /// AR@.50:.95, area=all, maxDets=10.
134    pub ar_max_10: Vec<Option<f64>>,
135    /// AR@.50:.95, area=all, maxDets=100.
136    pub ar_max_100: Vec<Option<f64>>,
137    /// Non-ignore GT count for this category, summed across images at
138    /// area=all. Inferred from [`Accumulated`]'s K-axis size and the
139    /// dataset's annotation list.
140    pub n_gt: Vec<u32>,
141    /// DT count for this category, summed across images at area=all.
142    /// Inferred from the dataset's detection list (when threaded
143    /// through) or from upstream EvalGrid cells (when called from
144    /// streaming).
145    pub n_dt: Vec<u32>,
146}
147
148impl PerClassTable {
149    /// Number of rows.
150    pub fn len(&self) -> usize {
151        self.category_id.len()
152    }
153
154    /// True when no rows.
155    pub fn is_empty(&self) -> bool {
156        self.category_id.is_empty()
157    }
158
159    /// Names of every column in pinned order. Matches the Arrow schema
160    /// emitted by `vernier-ffi`.
161    pub const COLUMN_NAMES: &'static [&'static str] = &[
162        "category_id",
163        "category_name",
164        "ap",
165        "ap50",
166        "ap75",
167        "ap_s",
168        "ap_m",
169        "ap_l",
170        "ar_max_1",
171        "ar_max_10",
172        "ar_max_100",
173        "n_gt",
174        "n_dt",
175    ];
176}
177
178/// Per-category support counts that `build_per_class` consumes
179/// alongside [`Accumulated`]. Pre-aggregated on the caller's side so
180/// the table builder is a pure fold; streaming and batch paths
181/// produce these counts from different substrates (the cells store vs.
182/// the parsed dataset) but both feed into the same builder.
183#[derive(Debug, Clone, Default)]
184pub struct PerClassSupport {
185    /// Non-ignore GT count, K-axis indexed (matching `Accumulated`'s
186    /// K-axis order). `n_gt[k]` is the count for category k.
187    pub n_gt: Vec<u32>,
188    /// DT count, K-axis indexed. `n_dt[k]` is the count for category k.
189    pub n_dt: Vec<u32>,
190}
191
192impl PerClassSupport {
193    /// Construct a zero-filled support struct of length `n_categories`.
194    /// Useful when the caller is going to fill it in from a cells-store
195    /// scan.
196    pub fn zeros(n_categories: usize) -> Self {
197        Self {
198            n_gt: vec![0; n_categories],
199            n_dt: vec![0; n_categories],
200        }
201    }
202}
203
204/// Build a [`PerClassTable`] from an [`Accumulated`] tensor, the
205/// dataset (for category-id → name lookup), and pre-aggregated support
206/// counts.
207///
208/// The accumulator's K-axis is expected to match the dataset's
209/// id-ascending category order produced by
210/// [`crate::evaluate_with`]. When `accum.precision`'s K-axis is `1`
211/// and the dataset has more than one category, that's the
212/// `use_cats=false` collapsed run: a single row is emitted with
213/// `category_id = COLLAPSED_CATEGORY_SENTINEL` and
214/// `category_name = "(all categories)"`.
215///
216/// # Errors
217///
218/// - [`EvalError::DimensionMismatch`] — `iou_thresholds` /
219///   `max_dets` lengths disagree with the accumulator's T / M axes,
220///   or the A-axis is not 4 (this builder requires the COCO
221///   detection grid layout — `[all, small, medium, large]`).
222/// - [`EvalError::InvalidConfig`] — `iou_thresholds` is missing the
223///   0.5 or 0.75 entry, or `max_dets` is missing 1, 10, or 100, or
224///   the K-axis size is incompatible with the dataset.
225pub fn build_per_class(
226    accum: &Accumulated,
227    dataset: &CocoDataset,
228    iou_thresholds: &[f64],
229    max_dets: &[usize],
230    support: &PerClassSupport,
231) -> Result<PerClassTable, EvalError> {
232    let p_shape = accum.precision.shape();
233    let r_shape = accum.recall.shape();
234    let n_t = p_shape[0];
235    let n_k = p_shape[2];
236    let n_a = p_shape[3];
237    let n_m = p_shape[4];
238
239    if n_t != iou_thresholds.len() {
240        return Err(EvalError::DimensionMismatch {
241            detail: format!(
242                "precision T-axis {} != iou_thresholds len {}",
243                n_t,
244                iou_thresholds.len()
245            ),
246        });
247    }
248    if n_m != max_dets.len() {
249        return Err(EvalError::DimensionMismatch {
250            detail: format!(
251                "precision M-axis {} != max_dets len {}",
252                n_m,
253                max_dets.len()
254            ),
255        });
256    }
257    if r_shape[0] != n_t || r_shape[1] != n_k || r_shape[2] != n_a || r_shape[3] != n_m {
258        return Err(EvalError::DimensionMismatch {
259            detail: format!("recall {r_shape:?} disagrees with precision {p_shape:?}"),
260        });
261    }
262    if n_a != 4 {
263        return Err(EvalError::DimensionMismatch {
264            detail: format!(
265                "per_class requires the COCO detection area grid (4 buckets); got {n_a}"
266            ),
267        });
268    }
269    if support.n_gt.len() != n_k || support.n_dt.len() != n_k {
270        return Err(EvalError::DimensionMismatch {
271            detail: format!(
272                "support counts (n_gt={}, n_dt={}) disagree with K-axis {}",
273                support.n_gt.len(),
274                support.n_dt.len(),
275                n_k
276            ),
277        });
278    }
279
280    let t50 = find_iou_index(iou_thresholds, 0.5)?;
281    let t75 = find_iou_index(iou_thresholds, 0.75)?;
282    let m1 = find_max_dets_index(max_dets, 1)?;
283    let m10 = find_max_dets_index(max_dets, 10)?;
284    let m100 = find_max_dets_index(max_dets, 100)?;
285    let m_last = n_m - 1;
286
287    const A_ALL: usize = 0;
288    const A_SMALL: usize = 1;
289    const A_MEDIUM: usize = 2;
290    const A_LARGE: usize = 3;
291
292    // Collapsed K=1 with multi-category dataset means use_cats=false:
293    // emit a single pseudo-row labelled "(all categories)".
294    let (category_ids, category_names): (Vec<i64>, Vec<String>) = if n_k == 1
295        && dataset.categories().len() != 1
296    {
297        (
298            vec![COLLAPSED_CATEGORY_SENTINEL],
299            vec!["(all categories)".to_string()],
300        )
301    } else {
302        if n_k != dataset.categories().len() {
303            return Err(EvalError::InvalidConfig {
304                detail: format!(
305                    "K-axis size {} disagrees with dataset.categories().len() {}",
306                    n_k,
307                    dataset.categories().len()
308                ),
309            });
310        }
311        let mut sorted: Vec<&crate::dataset::CategoryMeta> = dataset.categories().iter().collect();
312        sorted.sort_unstable_by_key(|c| c.id.0);
313        (
314            sorted.iter().map(|c| c.id.0).collect(),
315            sorted.iter().map(|c| c.name.clone()).collect(),
316        )
317    };
318
319    let mut ap = Vec::with_capacity(n_k);
320    let mut ap50 = Vec::with_capacity(n_k);
321    let mut ap75 = Vec::with_capacity(n_k);
322    let mut ap_s = Vec::with_capacity(n_k);
323    let mut ap_m = Vec::with_capacity(n_k);
324    let mut ap_l = Vec::with_capacity(n_k);
325    let mut ar_max_1 = Vec::with_capacity(n_k);
326    let mut ar_max_10 = Vec::with_capacity(n_k);
327    let mut ar_max_100 = Vec::with_capacity(n_k);
328
329    for k in 0..n_k {
330        ap.push(mean_precision(accum, 0..n_t, k, A_ALL, m_last));
331        ap50.push(mean_precision(accum, t50..t50 + 1, k, A_ALL, m_last));
332        ap75.push(mean_precision(accum, t75..t75 + 1, k, A_ALL, m_last));
333        ap_s.push(mean_precision(accum, 0..n_t, k, A_SMALL, m_last));
334        ap_m.push(mean_precision(accum, 0..n_t, k, A_MEDIUM, m_last));
335        ap_l.push(mean_precision(accum, 0..n_t, k, A_LARGE, m_last));
336        ar_max_1.push(mean_recall(accum, 0..n_t, k, A_ALL, m1));
337        ar_max_10.push(mean_recall(accum, 0..n_t, k, A_ALL, m10));
338        ar_max_100.push(mean_recall(accum, 0..n_t, k, A_ALL, m100));
339    }
340
341    Ok(PerClassTable {
342        category_id: category_ids,
343        category_name: category_names,
344        ap,
345        ap50,
346        ap75,
347        ap_s,
348        ap_m,
349        ap_l,
350        ar_max_1,
351        ar_max_10,
352        ar_max_100,
353        n_gt: support.n_gt.clone(),
354        n_dt: support.n_dt.clone(),
355    })
356}
357
358/// Mean of precision[t_range, :, k, area_idx, m_idx], filtering -1.0
359/// (quirk C5). Returns `None` when the slice is all-sentinel.
360///
361/// Uses [`pairwise_sum`] so the result is bit-equal to numpy's
362/// `np.mean(s[s > -1])` and to [`crate::summarize::Summary::stats`]
363/// for K=1 collapsed runs (quirk **C8**).
364fn mean_precision(
365    accum: &Accumulated,
366    t_range: std::ops::Range<usize>,
367    k_idx: usize,
368    area_idx: usize,
369    m_idx: usize,
370) -> Option<f64> {
371    let r = accum.precision.shape()[1];
372    let mut filtered: Vec<f64> = Vec::with_capacity(t_range.len() * r);
373    for t in t_range {
374        accum
375            .precision
376            .index_axis(Axis(0), t)
377            .index_axis(Axis(1), k_idx)
378            .index_axis(Axis(1), area_idx)
379            .index_axis(Axis(1), m_idx)
380            .iter()
381            .copied()
382            .for_each(|v| {
383                if v > -1.0 {
384                    filtered.push(v);
385                }
386            });
387    }
388    if filtered.is_empty() {
389        None
390    } else {
391        Some(pairwise_sum(&filtered) / filtered.len() as f64)
392    }
393}
394
395/// Mean of recall[t_range, k, area_idx, m_idx], filtering -1.0 (quirk
396/// C5). Returns `None` when the slice is all-sentinel.
397fn mean_recall(
398    accum: &Accumulated,
399    t_range: std::ops::Range<usize>,
400    k_idx: usize,
401    area_idx: usize,
402    m_idx: usize,
403) -> Option<f64> {
404    let mut filtered: Vec<f64> = Vec::with_capacity(t_range.len());
405    for t in t_range {
406        let v = accum.recall[[t, k_idx, area_idx, m_idx]];
407        if v > -1.0 {
408            filtered.push(v);
409        }
410    }
411    if filtered.is_empty() {
412        None
413    } else {
414        Some(pairwise_sum(&filtered) / filtered.len() as f64)
415    }
416}
417
418fn find_iou_index(iou_thresholds: &[f64], target: f64) -> Result<usize, EvalError> {
419    iou_thresholds
420        .iter()
421        .position(|&v| (v - target).abs() < IOU_LOOKUP_TOL)
422        .ok_or_else(|| EvalError::InvalidConfig {
423            detail: format!("iou_thresholds missing required value {target}"),
424        })
425}
426
427fn find_max_dets_index(max_dets: &[usize], target: usize) -> Result<usize, EvalError> {
428    max_dets
429        .iter()
430        .position(|&v| v == target)
431        .ok_or_else(|| EvalError::InvalidConfig {
432            detail: format!("max_dets missing required value {target}"),
433        })
434}
435
436/// Walk the [`crate::EvalGrid`] at `area_index = ALL` and aggregate
437/// non-ignore GT and DT counts per category. The result is
438/// K-axis-indexed (matching the grid's K-axis), so [`build_per_class`]
439/// can consume it directly.
440///
441/// Cells absent from the grid (`None` entries — image had no GTs and
442/// no DTs in this category) contribute zero to both counts. The
443/// non-ignore GT count comes from the cell's `gt_ignore` array; DT
444/// count from the cell's `dt_scores` length (post-area-filter,
445/// post-maxDets cap).
446///
447/// Out-of-range `area_index_all` (typically `0` for the COCO
448/// detection grid) yields an all-zero support struct.
449pub fn aggregate_per_class_support(
450    grid: &crate::evaluate::EvalGrid,
451    area_index_all: usize,
452) -> PerClassSupport {
453    let mut support = PerClassSupport::zeros(grid.n_categories);
454    if area_index_all >= grid.n_area_ranges {
455        return support;
456    }
457    for k in 0..grid.n_categories {
458        let mut n_gt = 0u32;
459        let mut n_dt = 0u32;
460        for i in 0..grid.n_images {
461            if let Some(cell) = grid.cell(k, area_index_all, i) {
462                n_gt = n_gt.saturating_add(
463                    cell.gt_ignore
464                        .iter()
465                        .filter(|&&ignored| !ignored)
466                        .count()
467                        .try_into()
468                        .unwrap_or(u32::MAX),
469                );
470                n_dt = n_dt.saturating_add(cell.dt_scores.len().try_into().unwrap_or(u32::MAX));
471            }
472        }
473        support.n_gt[k] = n_gt;
474        support.n_dt[k] = n_dt;
475    }
476    support
477}
478
479/// One row per image — rollup of locked-spine outputs across categories
480/// at area=ALL.
481///
482/// No `ap` / `ap_50` columns (per-image AP is degenerate; see
483/// `docs/explanation/why-no-per-image-ap.md`).
484///
485/// Quirk **G2** is honored automatically: a DT matched to a crowd GT
486/// carries `dt_ignore=true`, so TP/FP filters via `!dt_ignore` exclude
487/// it. Crowd GTs do not contribute to `n_gt` either (excluded by
488/// `gt_ignore`).
489///
490/// `n_dt` reflects the post-maxDets cap stored on the cells (typical
491/// COCO cap of 100 swallows nearly all images).
492#[derive(Debug, Clone)]
493pub struct PerImageTable {
494    /// COCO image id, sourced from the dataset's id-ascending image
495    /// order (matching the grid's I-axis ordering).
496    pub image_id: Vec<i64>,
497    /// Non-ignore GT count, summed across categories at area=ALL.
498    pub n_gt: Vec<u32>,
499    /// DT count, summed across categories at area=ALL (post-maxDets;
500    /// see struct doc).
501    pub n_dt: Vec<u32>,
502    /// True positives at IoU=0.50 — DTs with `dt_matched && !dt_ignore`.
503    pub tp_at_50: Vec<u32>,
504    /// False positives at IoU=0.50 — DTs with `!dt_matched && !dt_ignore`.
505    pub fp_at_50: Vec<u32>,
506    /// False negatives at IoU=0.50 — `n_gt - tp_at_50`.
507    pub fn_at_50: Vec<u32>,
508    /// True positives at IoU=0.75.
509    pub tp_at_75: Vec<u32>,
510    /// False positives at IoU=0.75.
511    pub fp_at_75: Vec<u32>,
512    /// False negatives at IoU=0.75.
513    pub fn_at_75: Vec<u32>,
514    /// Mean true-positive count across the T-axis, floored.
515    pub tp_mean_iou: Vec<u32>,
516}
517
518impl PerImageTable {
519    /// Number of rows.
520    pub fn len(&self) -> usize {
521        self.image_id.len()
522    }
523
524    /// True when no rows.
525    pub fn is_empty(&self) -> bool {
526        self.image_id.is_empty()
527    }
528
529    /// Names of every column in pinned order. Matches the Arrow schema
530    /// emitted by `vernier-ffi`.
531    pub const COLUMN_NAMES: &'static [&'static str] = &[
532        "image_id",
533        "n_gt",
534        "n_dt",
535        "tp_at_50",
536        "fp_at_50",
537        "fn_at_50",
538        "tp_at_75",
539        "fp_at_75",
540        "fn_at_75",
541        "tp_mean_iou",
542    ];
543}
544
545/// Build a [`PerImageTable`] from an [`EvalGrid`] (cells store) and a
546/// dataset (image_id source).
547///
548/// The grid must use the COCO detection area grid (4 buckets;
549/// area=ALL pinned at index 0); other layouts return
550/// [`EvalError::DimensionMismatch`]. `iou_thresholds` must contain
551/// 0.5 and 0.75; otherwise [`EvalError::InvalidConfig`].
552///
553/// I/O complexity is O(K * I * D) — one walk over every populated
554/// cell at area=ALL, with two threshold passes per cell.
555pub fn build_per_image(
556    grid: &EvalGrid,
557    dataset: &CocoDataset,
558    iou_thresholds: &[f64],
559) -> Result<PerImageTable, EvalError> {
560    if grid.n_area_ranges != 4 {
561        return Err(EvalError::DimensionMismatch {
562            detail: format!(
563                "per_image requires the COCO detection area grid (4 buckets); got {}",
564                grid.n_area_ranges
565            ),
566        });
567    }
568    let n_t = iou_thresholds.len();
569    if n_t == 0 {
570        return Err(EvalError::DimensionMismatch {
571            detail: "iou_thresholds empty".into(),
572        });
573    }
574    let t50 = find_iou_index(iou_thresholds, 0.5)?;
575    let t75 = find_iou_index(iou_thresholds, 0.75)?;
576    const A_ALL: usize = 0;
577
578    // Image id list: id-ascending sort matches the grid's I-axis.
579    let mut images: Vec<&crate::dataset::ImageMeta> = dataset.images().iter().collect();
580    images.sort_unstable_by_key(|im| im.id.0);
581    if images.len() != grid.n_images {
582        return Err(EvalError::InvalidConfig {
583            detail: format!(
584                "dataset image count {} disagrees with grid I-axis {}",
585                images.len(),
586                grid.n_images
587            ),
588        });
589    }
590    let n_i = grid.n_images;
591
592    let mut image_id = Vec::with_capacity(n_i);
593    let mut n_gt = vec![0u32; n_i];
594    let mut n_dt = vec![0u32; n_i];
595    let mut tp_at_50 = vec![0u32; n_i];
596    let mut fp_at_50 = vec![0u32; n_i];
597    let mut tp_at_75 = vec![0u32; n_i];
598    let mut fp_at_75 = vec![0u32; n_i];
599    // Sum of TPs across the T-axis per image. Divided by n_t (floor)
600    // at the end to produce `tp_mean_iou`.
601    let mut tp_t_sum = vec![0u64; n_i];
602
603    for im in &images {
604        image_id.push(im.id.0);
605    }
606
607    for k in 0..grid.n_categories {
608        for i in 0..n_i {
609            let Some(cell) = grid.cell(k, A_ALL, i) else {
610                continue;
611            };
612            // n_gt: non-ignore GTs.
613            n_gt[i] = n_gt[i].saturating_add(saturating_u32_count(
614                cell.gt_ignore.iter().filter(|&&b| !b).count(),
615            ));
616            let n_dt_cell = cell.dt_scores.len();
617            n_dt[i] = n_dt[i].saturating_add(saturating_u32_count(n_dt_cell));
618
619            // dt_ignore folds in quirks B6 (matched to ignore/crowd
620            // GT) and B7 (out-of-area unmatched), so the !dt_ignore
621            // gate is the right one for TP/FP under quirk G2.
622            for d in 0..n_dt_cell {
623                if !cell.dt_ignore[[t50, d]] {
624                    if cell.dt_matched[[t50, d]] {
625                        tp_at_50[i] = tp_at_50[i].saturating_add(1);
626                    } else {
627                        fp_at_50[i] = fp_at_50[i].saturating_add(1);
628                    }
629                }
630                if !cell.dt_ignore[[t75, d]] {
631                    if cell.dt_matched[[t75, d]] {
632                        tp_at_75[i] = tp_at_75[i].saturating_add(1);
633                    } else {
634                        fp_at_75[i] = fp_at_75[i].saturating_add(1);
635                    }
636                }
637            }
638
639            for t in 0..n_t {
640                let mut tp_t = 0u64;
641                for d in 0..n_dt_cell {
642                    if !cell.dt_ignore[[t, d]] && cell.dt_matched[[t, d]] {
643                        tp_t += 1;
644                    }
645                }
646                tp_t_sum[i] = tp_t_sum[i].saturating_add(tp_t);
647            }
648        }
649    }
650
651    let fn_at_50: Vec<u32> = (0..n_i)
652        .map(|i| n_gt[i].saturating_sub(tp_at_50[i]))
653        .collect();
654    let fn_at_75: Vec<u32> = (0..n_i)
655        .map(|i| n_gt[i].saturating_sub(tp_at_75[i]))
656        .collect();
657    let tp_mean_iou: Vec<u32> = (0..n_i)
658        .map(|i| {
659            let mean = tp_t_sum[i] / n_t as u64;
660            mean.try_into().unwrap_or(u32::MAX)
661        })
662        .collect();
663
664    Ok(PerImageTable {
665        image_id,
666        n_gt,
667        n_dt,
668        tp_at_50,
669        fp_at_50,
670        fn_at_50,
671        tp_at_75,
672        fp_at_75,
673        fn_at_75,
674        tp_mean_iou,
675    })
676}
677
678fn saturating_u32_count(n: usize) -> u32 {
679    n.try_into().unwrap_or(u32::MAX)
680}
681
682/// Per-`(category, image)` IoU matrices retained from a
683/// [`crate::evaluate_with`] pass when the caller passed
684/// [`crate::EvaluateParams::retain_iou`] = `true`.
685///
686/// Keyed by `(k, i)` (not `(k, a, i)`): IoU is geometry-only, so the
687/// same matrix serves every area range. The streaming evaluator
688/// preserves the same key shape so its retention store and the batch
689/// path stay drop-in compatible.
690#[derive(Debug, Clone, Default)]
691pub struct RetainedIous {
692    inner: HashMap<(usize, usize), Array2<f64>>,
693}
694
695impl RetainedIous {
696    /// Construct an empty store.
697    pub(crate) fn new() -> Self {
698        Self::default()
699    }
700
701    /// Construct from an already-built map. The map's keys must be
702    /// `(k_index, i_index)`; the Array2 shape is `(n_gt, n_dt)` for
703    /// that cell.
704    pub(crate) fn from_map(map: HashMap<(usize, usize), Array2<f64>>) -> Self {
705        Self { inner: map }
706    }
707
708    /// Number of retained IoU matrices.
709    pub fn len(&self) -> usize {
710        self.inner.len()
711    }
712
713    /// True when no matrices retained.
714    pub fn is_empty(&self) -> bool {
715        self.inner.is_empty()
716    }
717
718    /// Insert (or overwrite) the IoU matrix for `(k, i)`.
719    pub(crate) fn insert(&mut self, k: usize, i: usize, iou: Array2<f64>) {
720        self.inner.insert((k, i), iou);
721    }
722
723    /// Borrow the IoU matrix for `(k, i)` if retained.
724    pub fn get(&self, k: usize, i: usize) -> Option<ArrayView2<'_, f64>> {
725        self.inner.get(&(k, i)).map(|m| m.view())
726    }
727
728    /// Move-out variant of [`Self::get`]. Used by streaming to roll
729    /// per-batch matrices into the long-lived store.
730    pub(crate) fn remove(&mut self, k: usize, i: usize) -> Option<Array2<f64>> {
731        self.inner.remove(&(k, i))
732    }
733
734    /// Iterate `(k, i, view)` triplets in arbitrary order. The
735    /// distributed-eval encoder (ADR-0031) walks this to materialize
736    /// the wire-format `retained_ious` section, then sorts.
737    pub(crate) fn iter(&self) -> impl Iterator<Item = (usize, usize, ArrayView2<'_, f64>)> + '_ {
738        self.inner.iter().map(|(&(k, i), arr)| (k, i, arr.view()))
739    }
740}
741
742/// Per-image cross-class IoU matrices and the parallel category-index
743/// vectors that label each row/column.
744///
745/// Per ADR-0023, this is the side-pass output the TIDE Cls/Both bin
746/// assignment and the confusion-matrix capability both consume:
747/// IoU(DT_class_A, GT_class_B) for every `(A, B)` pair on a given
748/// image, materialized once per call.
749///
750/// Keyed by `image_idx` — the position of the image in the
751/// id-ascending ordering used by [`crate::evaluate_with`]. For each
752/// populated image:
753///
754/// - `inner[i]` is the dense `(D_total, G_total)` IoU matrix produced
755///   by the kernel on the un-class-filtered per-image lists.
756/// - `dt_classes[i]` carries the category index per row, in the same
757///   order as the matrix's row axis.
758/// - `gt_classes[i]` carries the category index per column.
759///
760/// The "category index" is the position of the category in the
761/// deterministic id-ascending category ordering [`crate::evaluate_with`]
762/// uses for its `K` axis — the same indexing the rest of the spine
763/// reasons in. It is *not* the COCO category id.
764///
765/// Distinct from [`RetainedIous`]: that store carries same-class IoU
766/// keyed `(category_index, image_index)` for the result-tables
767/// product, whereas this store carries the cross-class matrix keyed
768/// purely by image. The two have different consumers and different
769/// keying; they are deliberately not unified (per ADR-0023).
770///
771/// Images with no GTs and no DTs are absent from the maps.
772#[derive(Debug, Clone, Default)]
773pub struct CrossClassIous {
774    inner: HashMap<usize, Array2<f64>>,
775    dt_classes: HashMap<usize, Vec<usize>>,
776    gt_classes: HashMap<usize, Vec<usize>>,
777}
778
779impl CrossClassIous {
780    /// Construct an empty store.
781    pub fn new() -> Self {
782        Self::default()
783    }
784
785    /// Number of images with retained cross-class data.
786    pub fn len(&self) -> usize {
787        self.inner.len()
788    }
789
790    /// True when no images are retained.
791    pub fn is_empty(&self) -> bool {
792        self.inner.is_empty()
793    }
794
795    /// Insert (or overwrite) the cross-class IoU matrix and the
796    /// parallel category-index vectors for `image_idx`.
797    ///
798    /// `iou.shape() == (dt_classes.len(), gt_classes.len())` is
799    /// expected by every accessor; callers that violate this will
800    /// observe inconsistent reads but no panic.
801    pub(crate) fn insert(
802        &mut self,
803        image_idx: usize,
804        iou: Array2<f64>,
805        dt_classes: Vec<usize>,
806        gt_classes: Vec<usize>,
807    ) {
808        self.inner.insert(image_idx, iou);
809        self.dt_classes.insert(image_idx, dt_classes);
810        self.gt_classes.insert(image_idx, gt_classes);
811    }
812
813    /// Borrow the cross-class IoU matrix for `image_idx`, if present.
814    /// Rows index DTs (in the [`Self::dt_classes`] order); columns
815    /// index GTs (in the [`Self::gt_classes`] order).
816    pub fn get(&self, image_idx: usize) -> Option<ArrayView2<'_, f64>> {
817        self.inner.get(&image_idx).map(|m| m.view())
818    }
819
820    /// Borrow the per-row DT category indices for `image_idx`.
821    pub fn dt_classes(&self, image_idx: usize) -> Option<&[usize]> {
822        self.dt_classes.get(&image_idx).map(Vec::as_slice)
823    }
824
825    /// Borrow the per-column GT category indices for `image_idx`.
826    pub fn gt_classes(&self, image_idx: usize) -> Option<&[usize]> {
827        self.gt_classes.get(&image_idx).map(Vec::as_slice)
828    }
829}
830
831/// Match status for a detection at a given IoU threshold.
832///
833/// Arrow-encoded as `dictionary<utf8>` with entries pinned to
834/// `[tp, fp, ignored]` so consumers comparing against a static
835/// dictionary index are stable across runs.
836#[derive(Debug, Clone, Copy, PartialEq, Eq)]
837pub enum MatchStatus {
838    /// Matched a non-ignore GT (DT counts as TP).
839    TruePositive,
840    /// Did not match any GT (DT counts as FP).
841    FalsePositive,
842    /// DT carries `dt_ignore=true` — quirk **B6** (matched to crowd
843    /// or ignore-GT) or **B7** (out-of-area unmatched).
844    Ignored,
845}
846
847impl MatchStatus {
848    /// Stable dictionary index — pinned to `[tp, fp, ignored]`.
849    pub fn dict_index(self) -> u32 {
850        match self {
851            Self::TruePositive => 0,
852            Self::FalsePositive => 1,
853            Self::Ignored => 2,
854        }
855    }
856
857    /// Pinned dictionary values, indexed by [`Self::dict_index`].
858    pub const DICT_VALUES: &'static [&'static str] = &["tp", "fp", "ignored"];
859}
860
861/// Optional bbox geometry block on [`PerDetectionTable`]. Populated
862/// only when [`TablesConfig::per_detection_with_geometry`] is `true`.
863#[derive(Debug, Clone, Default)]
864pub struct BboxColumns {
865    /// Flat `[x, y, w, h]` per row, length 4 × n_rows. The Arrow
866    /// encoding is `fixed_size_list<f64, 4>`.
867    pub xywh: Vec<[f64; 4]>,
868}
869
870/// One row per detection. Built from the cells store at area=ALL, the
871/// dataset (for image/category id lookups via the grid), the detection
872/// list (for area + optional bbox), and optionally the retained IoU
873/// matrices (for `best_iou`).
874#[derive(Debug, Clone)]
875pub struct PerDetectionTable {
876    /// Detection id (COCO `id` field, post-auto-assignment if absent).
877    pub detection_id: Vec<i64>,
878    /// Image this detection lands on.
879    pub image_id: Vec<i64>,
880    /// Detection's claimed category id.
881    pub category_id: Vec<i64>,
882    /// Confidence score.
883    pub score: Vec<f64>,
884    /// Kernel-defined detection area (for bbox: `bbox.w * bbox.h`).
885    pub area: Vec<f64>,
886    /// Match status at IoU=0.50.
887    pub match_status_at_50: Vec<MatchStatus>,
888    /// GT id matched at IoU=0.50, or `None` for FP / ignored rows.
889    pub matched_gt_id_at_50: Vec<Option<i64>>,
890    /// Max IoU to any same-class GT in the same image. `None` when
891    /// the IoU matrix wasn't retained, or when there were no
892    /// same-class GTs in the image.
893    pub best_iou: Vec<Option<f64>>,
894    /// Optional bbox geometry. `None` unless
895    /// [`TablesConfig::per_detection_with_geometry`] was set.
896    pub bbox: Option<BboxColumns>,
897}
898
899impl PerDetectionTable {
900    /// Number of rows.
901    pub fn len(&self) -> usize {
902        self.detection_id.len()
903    }
904    /// True if no rows.
905    pub fn is_empty(&self) -> bool {
906        self.detection_id.is_empty()
907    }
908}
909
910/// One row per `(DT, GT)` pair within a `(image, category)` cell that
911/// passes the IoU floor. Always class-restricted: pairs across
912/// categories are excluded.
913#[derive(Debug, Clone, Default)]
914pub struct PerPairTable {
915    /// Detection id.
916    pub detection_id: Vec<i64>,
917    /// Ground-truth id.
918    pub ground_truth_id: Vec<i64>,
919    /// Image id (shared by DT and GT — pairs are class-and-image-restricted).
920    pub image_id: Vec<i64>,
921    /// Category id (shared by DT and GT).
922    pub category_id: Vec<i64>,
923    /// Raw IoU as the kernel produced it.
924    pub iou: Vec<f64>,
925}
926
927impl PerPairTable {
928    /// Number of rows.
929    pub fn len(&self) -> usize {
930        self.detection_id.len()
931    }
932    /// True if no rows.
933    pub fn is_empty(&self) -> bool {
934        self.detection_id.is_empty()
935    }
936}
937
938/// Build a [`PerDetectionTable`].
939///
940/// Walks the cells store at `area_idx=0` (COCO ALL bucket); for each
941/// cell, emits one row per detection (in the matching engine's
942/// sorted-DT order). When `retained_ious` is `Some`, populates
943/// `best_iou` from the per-cell matrix; otherwise leaves it `None`.
944///
945/// `iou_thresholds` must contain 0.5 — used to read the
946/// `match_status_at_50` and `matched_gt_id_at_50` columns.
947pub fn build_per_detection(
948    grid: &EvalGrid,
949    detections: &CocoDetections,
950    iou_thresholds: &[f64],
951    retained_ious: Option<&RetainedIous>,
952    config: &TablesConfig,
953) -> Result<PerDetectionTable, EvalError> {
954    if grid.n_area_ranges == 0 {
955        return Err(EvalError::DimensionMismatch {
956            detail: "per_detection requires at least one area range".into(),
957        });
958    }
959    let t50 = find_iou_index(iou_thresholds, 0.5)?;
960    const A_ALL: usize = 0;
961
962    // Detection ids are stable (auto-assigned when absent), so the
963    // matching engine's sorted-DT order resolves uniquely back to a
964    // single detection by id.
965    let det_index: HashMap<i64, &crate::dataset::CocoDetection> = detections
966        .detections()
967        .iter()
968        .map(|d| (d.id.0, d))
969        .collect();
970
971    let with_geometry = config.per_detection_with_geometry;
972    let mut detection_id = Vec::new();
973    let mut image_id = Vec::new();
974    let mut category_id = Vec::new();
975    let mut score = Vec::new();
976    let mut area = Vec::new();
977    let mut match_status_at_50 = Vec::new();
978    let mut matched_gt_id_at_50 = Vec::new();
979    let mut best_iou = Vec::new();
980    let mut bbox_xywh: Vec<[f64; 4]> = Vec::new();
981
982    for k in 0..grid.n_categories {
983        for i in 0..grid.n_images {
984            let Some(cell) = grid.cell(k, A_ALL, i) else {
985                continue;
986            };
987            let Some(meta) = grid.cell_meta(k, A_ALL, i) else {
988                continue;
989            };
990            let iou_view = retained_ious.and_then(|r| r.get(k, i));
991
992            for d in 0..cell.dt_scores.len() {
993                let dt_id = meta.dt_ids[d];
994                detection_id.push(dt_id);
995                image_id.push(meta.image_id);
996                category_id.push(meta.category_id);
997                score.push(cell.dt_scores[d]);
998
999                let det = det_index.get(&dt_id);
1000                area.push(det.map(|d| d.area).unwrap_or(f64::NAN));
1001                if with_geometry {
1002                    let b = det
1003                        .map(|d| d.bbox)
1004                        .unwrap_or_else(|| Bbox::from([f64::NAN; 4]));
1005                    bbox_xywh.push([b.x, b.y, b.w, b.h]);
1006                }
1007
1008                let dt_ignored = cell.dt_ignore[[t50, d]];
1009                let dt_matched_flag = cell.dt_matched[[t50, d]];
1010                let status = if dt_ignored {
1011                    MatchStatus::Ignored
1012                } else if dt_matched_flag {
1013                    MatchStatus::TruePositive
1014                } else {
1015                    MatchStatus::FalsePositive
1016                };
1017                match_status_at_50.push(status);
1018                let matched_gt = meta.dt_matches[[t50, d]];
1019                matched_gt_id_at_50.push(if matched_gt == 0 || dt_ignored {
1020                    None
1021                } else {
1022                    Some(matched_gt)
1023                });
1024
1025                // IoU matrix is shaped (n_gt, n_dt); take the max
1026                // over the GT axis at column d.
1027                let bi = iou_view.and_then(|view| {
1028                    if view.ncols() == 0 || view.nrows() == 0 || d >= view.ncols() {
1029                        return None;
1030                    }
1031                    let mut best: Option<f64> = None;
1032                    for g in 0..view.nrows() {
1033                        let v = view[[g, d]];
1034                        if best.is_none_or(|b| v > b) {
1035                            best = Some(v);
1036                        }
1037                    }
1038                    best
1039                });
1040                best_iou.push(bi);
1041            }
1042        }
1043    }
1044
1045    let bbox = if with_geometry {
1046        Some(BboxColumns { xywh: bbox_xywh })
1047    } else {
1048        None
1049    };
1050    Ok(PerDetectionTable {
1051        detection_id,
1052        image_id,
1053        category_id,
1054        score,
1055        area,
1056        match_status_at_50,
1057        matched_gt_id_at_50,
1058        best_iou,
1059        bbox,
1060    })
1061}
1062
1063/// Build a [`PerPairTable`] from retained IoU matrices.
1064///
1065/// Emits one row per `(DT, GT)` pair where IoU >=
1066/// `config.per_pair_iou_floor`. Pairs across categories are excluded
1067/// by construction (the IoU matrix is per-cell).
1068///
1069/// Overflow is checked *inside* the per-cell push loop, before the
1070/// column vecs grow past `config.per_pair_max_rows` — at LVIS scale a
1071/// post-build check would OOM first.
1072pub fn build_per_pair(
1073    grid: &EvalGrid,
1074    retained_ious: &RetainedIous,
1075    config: &TablesConfig,
1076) -> Result<PerPairTable, EvalError> {
1077    if grid.n_area_ranges == 0 {
1078        return Err(EvalError::DimensionMismatch {
1079            detail: "per_pair requires at least one area range".into(),
1080        });
1081    }
1082    const A_ALL: usize = 0;
1083    let mut out = PerPairTable::default();
1084    let cap = config.per_pair_max_rows;
1085    let floor = config.per_pair_iou_floor;
1086
1087    for k in 0..grid.n_categories {
1088        for i in 0..grid.n_images {
1089            let Some(meta) = grid.cell_meta(k, A_ALL, i) else {
1090                continue;
1091            };
1092            let Some(view) = retained_ious.get(k, i) else {
1093                continue;
1094            };
1095            // The IoU matrix is built on the unfiltered kernel set,
1096            // while meta.{gt,dt}_ids reflect the area-bucket-filtered
1097            // set; clamp both axes to the smaller length defensively.
1098            let n_gt_use = view.nrows().min(meta.gt_ids.len());
1099            let n_dt_use = view.ncols().min(meta.dt_ids.len());
1100            for g in 0..n_gt_use {
1101                for d in 0..n_dt_use {
1102                    let v = view[[g, d]];
1103                    if v < floor {
1104                        continue;
1105                    }
1106                    if out.detection_id.len() >= cap {
1107                        return Err(EvalError::PerPairOverflow {
1108                            observed: out.detection_id.len() + 1,
1109                            cap,
1110                        });
1111                    }
1112                    out.detection_id.push(meta.dt_ids[d]);
1113                    out.ground_truth_id.push(meta.gt_ids[g]);
1114                    out.image_id.push(meta.image_id);
1115                    out.category_id.push(meta.category_id);
1116                    out.iou.push(v);
1117                }
1118            }
1119        }
1120    }
1121    Ok(out)
1122}
1123
1124/// Bundle of computed tables, populated only for the flags set on the
1125/// triggering [`TablesRequest`]. Unset flags leave the corresponding
1126/// field at `None` — both producers and consumers preserve that
1127/// invariant.
1128#[derive(Debug, Clone, Default)]
1129pub struct Tables {
1130    /// Per-image rollup table when [`TablesRequest::per_image`] was set.
1131    pub per_image: Option<PerImageTable>,
1132    /// Per-class table when [`TablesRequest::per_class`] was set.
1133    pub per_class: Option<PerClassTable>,
1134    /// Per-detection table when [`TablesRequest::per_detection`] was set.
1135    pub per_detection: Option<PerDetectionTable>,
1136    /// Per-(DT, GT)-pair table when [`TablesRequest::per_pair`] was set.
1137    pub per_pair: Option<PerPairTable>,
1138}
1139
1140/// Umbrella entry that builds every requested table from the
1141/// locked-spine outputs. Single fan-out point both batch and streaming
1142/// drive through.
1143///
1144/// `detections` is required when `request.per_detection` is set
1145/// (`area` + optional `bbox` columns). `retained_ious` is required for
1146/// `request.per_pair`, and consumed by `per_detection.best_iou` when
1147/// present. Missing artifacts return [`EvalError::InvalidConfig`].
1148#[allow(clippy::too_many_arguments)]
1149pub fn build_tables(
1150    grid: &EvalGrid,
1151    accum: &Accumulated,
1152    dataset: &CocoDataset,
1153    detections: Option<&CocoDetections>,
1154    retained_ious: Option<&RetainedIous>,
1155    iou_thresholds: &[f64],
1156    max_dets: &[usize],
1157    request: TablesRequest,
1158    config: &TablesConfig,
1159) -> Result<Tables, EvalError> {
1160    let mut out = Tables::default();
1161    if request.per_class {
1162        let support = aggregate_per_class_support(grid, 0);
1163        out.per_class = Some(build_per_class(
1164            accum,
1165            dataset,
1166            iou_thresholds,
1167            max_dets,
1168            &support,
1169        )?);
1170    }
1171    if request.per_image {
1172        out.per_image = Some(build_per_image(grid, dataset, iou_thresholds)?);
1173    }
1174    if request.per_detection {
1175        let dets = detections.ok_or_else(|| EvalError::InvalidConfig {
1176            detail: "per_detection requires detections to be threaded through \
1177                     build_tables; pass Some(&CocoDetections)"
1178                .into(),
1179        })?;
1180        out.per_detection = Some(build_per_detection(
1181            grid,
1182            dets,
1183            iou_thresholds,
1184            retained_ious,
1185            config,
1186        )?);
1187    }
1188    if request.per_pair {
1189        let ious = retained_ious.ok_or_else(|| EvalError::InvalidConfig {
1190            detail: "per_pair requires retained IoU matrices; build the upstream \
1191                     evaluator with EvaluateParams::retain_iou=true (or pass \
1192                     retain_iou=True at StreamingEvaluator construction)"
1193                .into(),
1194        })?;
1195        out.per_pair = Some(build_per_pair(grid, ious, config)?);
1196    }
1197    Ok(out)
1198}
1199
1200#[cfg(test)]
1201mod tests {
1202    use super::*;
1203    use crate::accumulate::Accumulated;
1204    use crate::dataset::{CategoryId, CategoryMeta, CocoDataset, ImageMeta};
1205    use crate::parity::iou_thresholds;
1206    use ndarray::{Array4, Array5};
1207
1208    fn dataset_with_two_categories() -> CocoDataset {
1209        let images = vec![ImageMeta {
1210            id: crate::dataset::ImageId(1),
1211            width: 100,
1212            height: 100,
1213            file_name: None,
1214        }];
1215        let categories = vec![
1216            CategoryMeta {
1217                id: CategoryId(2),
1218                name: "cat".into(),
1219                supercategory: None,
1220            },
1221            CategoryMeta {
1222                id: CategoryId(1),
1223                name: "dog".into(),
1224                supercategory: None,
1225            },
1226        ];
1227        CocoDataset::from_parts(images, Vec::new(), categories).unwrap()
1228    }
1229
1230    #[test]
1231    fn tables_request_requires_iou_retention_only_for_dt_pair() {
1232        assert!(!TablesRequest::CHEAP.requires_iou_retention());
1233        assert!(TablesRequest {
1234            per_detection: true,
1235            ..TablesRequest::default()
1236        }
1237        .requires_iou_retention());
1238        assert!(TablesRequest {
1239            per_pair: true,
1240            ..TablesRequest::default()
1241        }
1242        .requires_iou_retention());
1243    }
1244
1245    #[test]
1246    fn build_per_class_emits_one_row_per_category_in_id_ascending_order() {
1247        // Dataset has cats with ids 2, 1; sorted-ascending puts dog (1)
1248        // at K=0 and cat (2) at K=1. The per-K AP value is encoded into
1249        // the precision tensor at [t, r, k, area=ALL, m=last] so we can
1250        // assert the K-axis ordering is honored.
1251        let dataset = dataset_with_two_categories();
1252        let iou_thr = iou_thresholds();
1253        let max_dets = [1usize, 10, 100];
1254        let n_t = iou_thr.len();
1255        let n_r = 101;
1256        let n_k = 2;
1257        let n_a = 4;
1258        let n_m = 3;
1259
1260        let mut precision = Array5::<f64>::from_elem((n_t, n_r, n_k, n_a, n_m), -1.0);
1261        let mut recall = Array4::<f64>::from_elem((n_t, n_k, n_a, n_m), -1.0);
1262
1263        // dog (k=0): AP=0.6 at (all, m=last). cat (k=1): AP=0.8.
1264        precision
1265            .index_axis_mut(Axis(2), 0)
1266            .index_axis_mut(Axis(2), 0) // area=all
1267            .index_axis_mut(Axis(2), 2) // m=last
1268            .fill(0.6);
1269        precision
1270            .index_axis_mut(Axis(2), 1)
1271            .index_axis_mut(Axis(2), 0)
1272            .index_axis_mut(Axis(2), 2)
1273            .fill(0.8);
1274
1275        // AR_max_100 different per category to verify recall pathway.
1276        recall[[0, 0, 0, 2]] = 0.5;
1277        recall[[0, 1, 0, 2]] = 0.9;
1278
1279        let accum = Accumulated {
1280            precision,
1281            recall,
1282            scores: Array5::<f64>::from_elem((n_t, n_r, n_k, n_a, n_m), -1.0),
1283        };
1284        let support = PerClassSupport {
1285            n_gt: vec![3, 4],
1286            n_dt: vec![5, 6],
1287        };
1288
1289        let table = build_per_class(&accum, &dataset, iou_thr, &max_dets, &support).unwrap();
1290        assert_eq!(table.len(), 2);
1291
1292        // K=0 → category id 1 (dog), K=1 → category id 2 (cat).
1293        assert_eq!(table.category_id, vec![1, 2]);
1294        assert_eq!(
1295            table.category_name,
1296            vec!["dog".to_string(), "cat".to_string()]
1297        );
1298        assert!((table.ap[0].unwrap() - 0.6).abs() < 1e-12);
1299        assert!((table.ap[1].unwrap() - 0.8).abs() < 1e-12);
1300        assert_eq!(table.n_gt, vec![3, 4]);
1301        assert_eq!(table.n_dt, vec![5, 6]);
1302
1303        // Recall slice was only populated at t=0; mean of one
1304        // non-sentinel value is that value.
1305        assert!((table.ar_max_100[0].unwrap() - 0.5).abs() < 1e-12);
1306        assert!((table.ar_max_100[1].unwrap() - 0.9).abs() < 1e-12);
1307    }
1308
1309    #[test]
1310    fn build_per_class_emits_null_for_all_sentinel_cells() {
1311        let dataset = dataset_with_two_categories();
1312        let iou_thr = iou_thresholds();
1313        let max_dets = [1usize, 10, 100];
1314        let n_t = iou_thr.len();
1315        let accum = Accumulated {
1316            precision: Array5::<f64>::from_elem((n_t, 101, 2, 4, 3), -1.0),
1317            recall: Array4::<f64>::from_elem((n_t, 2, 4, 3), -1.0),
1318            scores: Array5::<f64>::from_elem((n_t, 101, 2, 4, 3), -1.0),
1319        };
1320        let support = PerClassSupport::zeros(2);
1321        let table = build_per_class(&accum, &dataset, iou_thr, &max_dets, &support).unwrap();
1322        assert!(table.ap.iter().all(Option::is_none));
1323        assert!(table.ar_max_100.iter().all(Option::is_none));
1324    }
1325
1326    #[test]
1327    fn build_per_class_collapsed_use_cats_false_returns_single_row() {
1328        let dataset = dataset_with_two_categories();
1329        let iou_thr = iou_thresholds();
1330        let max_dets = [1usize, 10, 100];
1331        let n_t = iou_thr.len();
1332        // K=1 — the use_cats=false collapsed shape.
1333        let accum = Accumulated {
1334            precision: Array5::<f64>::from_elem((n_t, 101, 1, 4, 3), 0.7),
1335            recall: Array4::<f64>::from_elem((n_t, 1, 4, 3), 0.7),
1336            scores: Array5::<f64>::from_elem((n_t, 101, 1, 4, 3), 0.7),
1337        };
1338        let support = PerClassSupport {
1339            n_gt: vec![100],
1340            n_dt: vec![200],
1341        };
1342        let table = build_per_class(&accum, &dataset, iou_thr, &max_dets, &support).unwrap();
1343        assert_eq!(table.len(), 1);
1344        assert_eq!(table.category_id, vec![COLLAPSED_CATEGORY_SENTINEL]);
1345        assert_eq!(table.category_name, vec!["(all categories)".to_string()]);
1346        assert!((table.ap[0].unwrap() - 0.7).abs() < 1e-12);
1347    }
1348
1349    #[test]
1350    fn build_per_class_rejects_a_axis_size_other_than_4() {
1351        let dataset = dataset_with_two_categories();
1352        let iou_thr = iou_thresholds();
1353        let max_dets = [20usize];
1354        let n_t = iou_thr.len();
1355        // 3-bucket A-axis (the keypoints layout) must be rejected.
1356        let accum = Accumulated {
1357            precision: Array5::<f64>::from_elem((n_t, 101, 2, 3, 1), 0.5),
1358            recall: Array4::<f64>::from_elem((n_t, 2, 3, 1), 0.5),
1359            scores: Array5::<f64>::from_elem((n_t, 101, 2, 3, 1), 0.5),
1360        };
1361        let support = PerClassSupport::zeros(2);
1362        let err = build_per_class(&accum, &dataset, iou_thr, &max_dets, &support).unwrap_err();
1363        assert!(matches!(err, EvalError::DimensionMismatch { .. }));
1364    }
1365
1366    fn perfect_match_grid_two_images() -> (EvalGrid, CocoDataset) {
1367        // Two images, one category. Image 1 has a perfect-match DT,
1368        // image 2 has an unmatched DT (FP) and an unmatched GT (FN).
1369        // Bbox kernel for simplicity.
1370        use crate::dataset::{Bbox as DsBbox, CocoAnnotation, DetectionInput};
1371        use crate::evaluate::{evaluate_bbox, AreaRange, EvaluateParams};
1372        use crate::parity::{iou_thresholds, ParityMode};
1373        let images = vec![
1374            ImageMeta {
1375                id: crate::dataset::ImageId(1),
1376                width: 100,
1377                height: 100,
1378                file_name: None,
1379            },
1380            ImageMeta {
1381                id: crate::dataset::ImageId(2),
1382                width: 100,
1383                height: 100,
1384                file_name: None,
1385            },
1386        ];
1387        let categories = vec![CategoryMeta {
1388            id: CategoryId(1),
1389            name: "thing".into(),
1390            supercategory: None,
1391        }];
1392        let anns = vec![
1393            CocoAnnotation {
1394                id: crate::dataset::AnnId(1),
1395                image_id: crate::dataset::ImageId(1),
1396                category_id: CategoryId(1),
1397                area: 100.0,
1398                is_crowd: false,
1399                ignore_flag: None,
1400                bbox: DsBbox {
1401                    x: 0.0,
1402                    y: 0.0,
1403                    w: 10.0,
1404                    h: 10.0,
1405                },
1406                segmentation: None,
1407                keypoints: None,
1408                num_keypoints: None,
1409            },
1410            CocoAnnotation {
1411                id: crate::dataset::AnnId(2),
1412                image_id: crate::dataset::ImageId(2),
1413                category_id: CategoryId(1),
1414                area: 100.0,
1415                is_crowd: false,
1416                ignore_flag: None,
1417                bbox: DsBbox {
1418                    x: 0.0,
1419                    y: 0.0,
1420                    w: 10.0,
1421                    h: 10.0,
1422                },
1423                segmentation: None,
1424                keypoints: None,
1425                num_keypoints: None,
1426            },
1427        ];
1428        let dataset = CocoDataset::from_parts(images, anns, categories).unwrap();
1429
1430        let dt_inputs = vec![
1431            DetectionInput {
1432                id: None,
1433                image_id: crate::dataset::ImageId(1),
1434                category_id: CategoryId(1),
1435                score: 0.9,
1436                bbox: DsBbox {
1437                    x: 0.0,
1438                    y: 0.0,
1439                    w: 10.0,
1440                    h: 10.0,
1441                },
1442                segmentation: None,
1443                keypoints: None,
1444                num_keypoints: None,
1445            },
1446            DetectionInput {
1447                id: None,
1448                image_id: crate::dataset::ImageId(2),
1449                category_id: CategoryId(1),
1450                score: 0.8,
1451                // Far from GT — unmatched FP.
1452                bbox: DsBbox {
1453                    x: 50.0,
1454                    y: 50.0,
1455                    w: 10.0,
1456                    h: 10.0,
1457                },
1458                segmentation: None,
1459                keypoints: None,
1460                num_keypoints: None,
1461            },
1462        ];
1463        let detections = crate::dataset::CocoDetections::from_inputs(dt_inputs).unwrap();
1464        let area = AreaRange::coco_default();
1465        let grid = evaluate_bbox(
1466            &dataset,
1467            &detections,
1468            EvaluateParams {
1469                iou_thresholds: iou_thresholds(),
1470                area_ranges: &area,
1471                max_dets_per_image: 100,
1472                use_cats: true,
1473                retain_iou: false,
1474            },
1475            ParityMode::Corrected,
1476        )
1477        .unwrap();
1478        (grid, dataset)
1479    }
1480
1481    #[test]
1482    fn build_per_image_counts_tp_fp_fn_against_perfect_and_unmatched_pairs() {
1483        let (grid, dataset) = perfect_match_grid_two_images();
1484        let table = build_per_image(&grid, &dataset, crate::parity::iou_thresholds()).unwrap();
1485        assert_eq!(table.len(), 2);
1486        assert_eq!(table.image_id, vec![1, 2]);
1487        // Both images have one non-ignore GT.
1488        assert_eq!(table.n_gt, vec![1, 1]);
1489        // Image 1: 1 DT (perfect match → TP). Image 2: 1 DT (unmatched FP).
1490        assert_eq!(table.n_dt, vec![1, 1]);
1491        assert_eq!(table.tp_at_50, vec![1, 0]);
1492        assert_eq!(table.fp_at_50, vec![0, 1]);
1493        assert_eq!(table.fn_at_50, vec![0, 1]);
1494        assert_eq!(table.tp_at_75, vec![1, 0]);
1495        assert_eq!(table.fp_at_75, vec![0, 1]);
1496        assert_eq!(table.fn_at_75, vec![0, 1]);
1497        // Image 1's TPs hold across all 10 thresholds (perfect IoU=1.0);
1498        // image 2 has zero TPs across all thresholds.
1499        assert_eq!(table.tp_mean_iou, vec![1, 0]);
1500    }
1501
1502    #[test]
1503    fn build_per_image_excludes_crowd_matched_dts_from_tp() {
1504        // Crowd GT + a DT overlapping it → DT carries dt_ignore (B6),
1505        // so neither TP nor FP is incremented. n_gt also drops the
1506        // crowd GT (gt_ignore=true).
1507        use crate::dataset::{Bbox as DsBbox, CocoAnnotation, DetectionInput};
1508        use crate::evaluate::{evaluate_bbox, AreaRange, EvaluateParams};
1509        use crate::parity::{iou_thresholds, ParityMode};
1510        let images = vec![ImageMeta {
1511            id: crate::dataset::ImageId(1),
1512            width: 100,
1513            height: 100,
1514            file_name: None,
1515        }];
1516        let categories = vec![CategoryMeta {
1517            id: CategoryId(1),
1518            name: "thing".into(),
1519            supercategory: None,
1520        }];
1521        let anns = vec![CocoAnnotation {
1522            id: crate::dataset::AnnId(1),
1523            image_id: crate::dataset::ImageId(1),
1524            category_id: CategoryId(1),
1525            area: 100.0,
1526            // Crowd region — gt_ignore becomes true.
1527            is_crowd: true,
1528            ignore_flag: None,
1529            bbox: DsBbox {
1530                x: 0.0,
1531                y: 0.0,
1532                w: 10.0,
1533                h: 10.0,
1534            },
1535            segmentation: None,
1536            keypoints: None,
1537            num_keypoints: None,
1538        }];
1539        let dataset = CocoDataset::from_parts(images, anns, categories).unwrap();
1540        let dt_inputs = vec![DetectionInput {
1541            id: None,
1542            image_id: crate::dataset::ImageId(1),
1543            category_id: CategoryId(1),
1544            score: 0.9,
1545            bbox: DsBbox {
1546                x: 0.0,
1547                y: 0.0,
1548                w: 10.0,
1549                h: 10.0,
1550            },
1551            segmentation: None,
1552            keypoints: None,
1553            num_keypoints: None,
1554        }];
1555        let detections = crate::dataset::CocoDetections::from_inputs(dt_inputs).unwrap();
1556        let area = AreaRange::coco_default();
1557        let grid = evaluate_bbox(
1558            &dataset,
1559            &detections,
1560            EvaluateParams {
1561                iou_thresholds: iou_thresholds(),
1562                area_ranges: &area,
1563                max_dets_per_image: 100,
1564                use_cats: true,
1565                retain_iou: false,
1566            },
1567            ParityMode::Corrected,
1568        )
1569        .unwrap();
1570        let table = build_per_image(&grid, &dataset, iou_thresholds()).unwrap();
1571        assert_eq!(table.n_gt, vec![0]);
1572        assert_eq!(table.tp_at_50, vec![0]);
1573        assert_eq!(table.fp_at_50, vec![0]);
1574        assert_eq!(table.fn_at_50, vec![0]);
1575    }
1576
1577    #[test]
1578    fn build_per_image_rejects_non_detection_grid() {
1579        // Build an EvalGrid with n_area_ranges=3 (kp shape) by hand.
1580        let grid = EvalGrid {
1581            eval_imgs: vec![None; 3],
1582            eval_imgs_meta: vec![None; 3],
1583            n_categories: 1,
1584            n_area_ranges: 3,
1585            n_images: 1,
1586            retained_ious: None,
1587        };
1588        let dataset = dataset_with_two_categories();
1589        let err = build_per_image(&grid, &dataset, crate::parity::iou_thresholds()).unwrap_err();
1590        assert!(matches!(err, EvalError::DimensionMismatch { .. }));
1591    }
1592
1593    #[test]
1594    fn build_tables_dispatches_per_image_and_per_class() {
1595        let (grid, dataset) = perfect_match_grid_two_images();
1596        let max_dets = [1usize, 10, 100];
1597        // Drive the same pipeline the FFI runs end-to-end.
1598        let p = crate::accumulate::AccumulateParams {
1599            iou_thresholds: crate::parity::iou_thresholds(),
1600            recall_thresholds: crate::parity::recall_thresholds(),
1601            max_dets: &max_dets,
1602            n_categories: grid.n_categories,
1603            n_area_ranges: grid.n_area_ranges,
1604            n_images: grid.n_images,
1605        };
1606        let accum =
1607            crate::accumulate::accumulate(&grid.eval_imgs, p, crate::parity::ParityMode::Corrected)
1608                .unwrap();
1609
1610        let tables = build_tables(
1611            &grid,
1612            &accum,
1613            &dataset,
1614            None,
1615            None,
1616            crate::parity::iou_thresholds(),
1617            &max_dets,
1618            TablesRequest::CHEAP,
1619            &TablesConfig::default(),
1620        )
1621        .unwrap();
1622        assert!(tables.per_image.is_some());
1623        assert!(tables.per_class.is_some());
1624    }
1625
1626    #[test]
1627    fn retain_iou_flag_does_not_perturb_the_summary() {
1628        // ADR-0019 Week 2.3: `retain_iou=true` is opt-in extra
1629        // bookkeeping; the matching engine and accumulator must
1630        // produce bit-identical Summary output to the no-retention
1631        // path. Pin the contract on the perfect-match fixture.
1632        use crate::dataset::{Bbox as DsBbox, CocoAnnotation, DetectionInput};
1633        use crate::evaluate::{evaluate_bbox, AreaRange, EvaluateParams};
1634        use crate::parity::{iou_thresholds, ParityMode};
1635        let images = vec![ImageMeta {
1636            id: crate::dataset::ImageId(1),
1637            width: 100,
1638            height: 100,
1639            file_name: None,
1640        }];
1641        let categories = vec![CategoryMeta {
1642            id: CategoryId(1),
1643            name: "thing".into(),
1644            supercategory: None,
1645        }];
1646        let anns = vec![CocoAnnotation {
1647            id: crate::dataset::AnnId(1),
1648            image_id: crate::dataset::ImageId(1),
1649            category_id: CategoryId(1),
1650            area: 100.0,
1651            is_crowd: false,
1652            ignore_flag: None,
1653            bbox: DsBbox {
1654                x: 0.0,
1655                y: 0.0,
1656                w: 10.0,
1657                h: 10.0,
1658            },
1659            segmentation: None,
1660            keypoints: None,
1661            num_keypoints: None,
1662        }];
1663        let dataset = CocoDataset::from_parts(images, anns, categories).unwrap();
1664        let dt_inputs = vec![DetectionInput {
1665            id: None,
1666            image_id: crate::dataset::ImageId(1),
1667            category_id: CategoryId(1),
1668            score: 0.9,
1669            bbox: DsBbox {
1670                x: 0.0,
1671                y: 0.0,
1672                w: 10.0,
1673                h: 10.0,
1674            },
1675            segmentation: None,
1676            keypoints: None,
1677            num_keypoints: None,
1678        }];
1679        let detections = crate::dataset::CocoDetections::from_inputs(dt_inputs).unwrap();
1680        let area = AreaRange::coco_default();
1681        let max_dets = [1usize, 10, 100];
1682
1683        let mut params_off = EvaluateParams {
1684            iou_thresholds: iou_thresholds(),
1685            area_ranges: &area,
1686            max_dets_per_image: 100,
1687            use_cats: true,
1688            retain_iou: false,
1689        };
1690        let grid_off =
1691            evaluate_bbox(&dataset, &detections, params_off, ParityMode::Corrected).unwrap();
1692        params_off.retain_iou = true;
1693        let grid_on =
1694            evaluate_bbox(&dataset, &detections, params_off, ParityMode::Corrected).unwrap();
1695
1696        // The `eval_imgs` slice is what `accumulate` consumes; require
1697        // the count of populated cells matches and that the retention
1698        // field is present iff the flag is on.
1699        assert_eq!(grid_off.eval_imgs.len(), grid_on.eval_imgs.len());
1700        assert!(grid_off.retained_ious.is_none());
1701        assert!(grid_on.retained_ious.is_some());
1702        let retained = grid_on.retained_ious.as_ref().unwrap();
1703        assert_eq!(retained.len(), 1);
1704        assert!(retained.get(0, 0).is_some());
1705
1706        // Drive the same accumulate→summarize over both grids; assert
1707        // bit-equal stats. This is the headline contract of the
1708        // retain_iou flag.
1709        let p = crate::accumulate::AccumulateParams {
1710            iou_thresholds: iou_thresholds(),
1711            recall_thresholds: crate::parity::recall_thresholds(),
1712            max_dets: &max_dets,
1713            n_categories: grid_off.n_categories,
1714            n_area_ranges: grid_off.n_area_ranges,
1715            n_images: grid_off.n_images,
1716        };
1717        let acc_off =
1718            crate::accumulate::accumulate(&grid_off.eval_imgs, p, ParityMode::Corrected).unwrap();
1719        let acc_on =
1720            crate::accumulate::accumulate(&grid_on.eval_imgs, p, ParityMode::Corrected).unwrap();
1721        let sum_off =
1722            crate::summarize::summarize_detection(&acc_off, iou_thresholds(), &max_dets).unwrap();
1723        let sum_on =
1724            crate::summarize::summarize_detection(&acc_on, iou_thresholds(), &max_dets).unwrap();
1725        for (a, b) in sum_off.stats().iter().zip(sum_on.stats().iter()) {
1726            assert_eq!(a.to_bits(), b.to_bits(), "stat drift: off={a} on={b}");
1727        }
1728    }
1729
1730    #[test]
1731    fn build_tables_per_detection_without_detections_returns_invalid_config() {
1732        // Caller asks for per_detection but doesn't supply CocoDetections —
1733        // surface a clear error pointing at the missing argument.
1734        let (grid, dataset) = perfect_match_grid_two_images();
1735        let max_dets = [1usize, 10, 100];
1736        let p = crate::accumulate::AccumulateParams {
1737            iou_thresholds: crate::parity::iou_thresholds(),
1738            recall_thresholds: crate::parity::recall_thresholds(),
1739            max_dets: &max_dets,
1740            n_categories: grid.n_categories,
1741            n_area_ranges: grid.n_area_ranges,
1742            n_images: grid.n_images,
1743        };
1744        let accum =
1745            crate::accumulate::accumulate(&grid.eval_imgs, p, crate::parity::ParityMode::Corrected)
1746                .unwrap();
1747        let request = TablesRequest {
1748            per_detection: true,
1749            ..TablesRequest::default()
1750        };
1751        let err = build_tables(
1752            &grid,
1753            &accum,
1754            &dataset,
1755            None,
1756            None,
1757            crate::parity::iou_thresholds(),
1758            &max_dets,
1759            request,
1760            &TablesConfig::default(),
1761        )
1762        .unwrap_err();
1763        assert!(matches!(err, EvalError::InvalidConfig { .. }));
1764    }
1765
1766    #[test]
1767    fn build_tables_per_pair_without_retention_returns_invalid_config() {
1768        let (grid, dataset) = perfect_match_grid_two_images();
1769        let max_dets = [1usize, 10, 100];
1770        let p = crate::accumulate::AccumulateParams {
1771            iou_thresholds: crate::parity::iou_thresholds(),
1772            recall_thresholds: crate::parity::recall_thresholds(),
1773            max_dets: &max_dets,
1774            n_categories: grid.n_categories,
1775            n_area_ranges: grid.n_area_ranges,
1776            n_images: grid.n_images,
1777        };
1778        let accum =
1779            crate::accumulate::accumulate(&grid.eval_imgs, p, crate::parity::ParityMode::Corrected)
1780                .unwrap();
1781        let request = TablesRequest {
1782            per_pair: true,
1783            ..TablesRequest::default()
1784        };
1785        let err = build_tables(
1786            &grid,
1787            &accum,
1788            &dataset,
1789            None,
1790            None,
1791            crate::parity::iou_thresholds(),
1792            &max_dets,
1793            request,
1794            &TablesConfig::default(),
1795        )
1796        .unwrap_err();
1797        let msg = format!("{err}");
1798        assert!(matches!(err, EvalError::InvalidConfig { .. }));
1799        assert!(
1800            msg.contains("retain_iou"),
1801            "error must name retain_iou: {msg}"
1802        );
1803    }
1804
1805    #[test]
1806    fn build_per_pair_overflow_fires_inside_push_loop() {
1807        // Tiny IoU floor + tiny cap: matrix has 4 elements, all >= 0.0,
1808        // so every pair would push if we let it. Cap=2 fires on the
1809        // 3rd push. The check happens before the column grows past
1810        // the cap (per ADR landmine #4).
1811        let mut store = RetainedIous::new();
1812        let iou = ndarray::Array2::<f64>::from_shape_vec((2, 2), vec![0.5, 0.6, 0.7, 0.8]).unwrap();
1813        store.insert(0, 0, iou);
1814        let grid = EvalGrid {
1815            eval_imgs: vec![None],
1816            eval_imgs_meta: vec![Some(Box::new(crate::evaluate::EvalImageMeta {
1817                image_id: 1,
1818                category_id: 1,
1819                area_rng: [0.0, f64::INFINITY],
1820                max_det: 100,
1821                dt_ids: vec![10, 20],
1822                gt_ids: vec![100, 200],
1823                dt_matches: ndarray::Array2::<i64>::zeros((10, 2)),
1824                gt_matches: ndarray::Array2::<i64>::zeros((10, 2)),
1825            }))],
1826            n_categories: 1,
1827            n_area_ranges: 1,
1828            n_images: 1,
1829            retained_ious: Some(store.clone()),
1830        };
1831        let cfg = TablesConfig {
1832            per_pair_iou_floor: 0.0,
1833            per_pair_max_rows: 2,
1834            ..TablesConfig::default()
1835        };
1836        let err = build_per_pair(&grid, &store, &cfg).unwrap_err();
1837        assert!(matches!(
1838            err,
1839            EvalError::PerPairOverflow {
1840                observed: 3,
1841                cap: 2
1842            }
1843        ));
1844    }
1845
1846    #[test]
1847    fn build_per_pair_filters_below_iou_floor_and_emits_above() {
1848        // Same matrix as the overflow test but with floor=0.65 — keeps
1849        // [0.7, 0.8] pairs only.
1850        let mut store = RetainedIous::new();
1851        let iou = ndarray::Array2::<f64>::from_shape_vec((2, 2), vec![0.5, 0.6, 0.7, 0.8]).unwrap();
1852        store.insert(0, 0, iou);
1853        let grid = EvalGrid {
1854            eval_imgs: vec![None],
1855            eval_imgs_meta: vec![Some(Box::new(crate::evaluate::EvalImageMeta {
1856                image_id: 1,
1857                category_id: 1,
1858                area_rng: [0.0, f64::INFINITY],
1859                max_det: 100,
1860                dt_ids: vec![10, 20],
1861                gt_ids: vec![100, 200],
1862                dt_matches: ndarray::Array2::<i64>::zeros((10, 2)),
1863                gt_matches: ndarray::Array2::<i64>::zeros((10, 2)),
1864            }))],
1865            n_categories: 1,
1866            n_area_ranges: 1,
1867            n_images: 1,
1868            retained_ious: Some(store.clone()),
1869        };
1870        let cfg = TablesConfig {
1871            per_pair_iou_floor: 0.65,
1872            ..TablesConfig::default()
1873        };
1874        let table = build_per_pair(&grid, &store, &cfg).unwrap();
1875        assert_eq!(table.len(), 2);
1876        assert_eq!(table.iou.to_vec(), vec![0.7, 0.8]);
1877        // Rows are emitted in (gt, dt) order: (g=1,d=0)=0.7 and (g=1,d=1)=0.8.
1878        assert_eq!(table.detection_id, vec![10, 20]);
1879        assert_eq!(table.ground_truth_id, vec![200, 200]);
1880    }
1881
1882    #[test]
1883    fn build_per_detection_marks_perfect_match_as_tp_and_unmatched_as_fp() {
1884        let (grid, dataset) = perfect_match_grid_two_images();
1885        let dt_inputs = vec![
1886            crate::dataset::DetectionInput {
1887                id: None,
1888                image_id: crate::dataset::ImageId(1),
1889                category_id: CategoryId(1),
1890                score: 0.9,
1891                bbox: crate::dataset::Bbox {
1892                    x: 0.0,
1893                    y: 0.0,
1894                    w: 10.0,
1895                    h: 10.0,
1896                },
1897                segmentation: None,
1898                keypoints: None,
1899                num_keypoints: None,
1900            },
1901            crate::dataset::DetectionInput {
1902                id: None,
1903                image_id: crate::dataset::ImageId(2),
1904                category_id: CategoryId(1),
1905                score: 0.8,
1906                bbox: crate::dataset::Bbox {
1907                    x: 50.0,
1908                    y: 50.0,
1909                    w: 10.0,
1910                    h: 10.0,
1911                },
1912                segmentation: None,
1913                keypoints: None,
1914                num_keypoints: None,
1915            },
1916        ];
1917        let detections = crate::dataset::CocoDetections::from_inputs(dt_inputs).unwrap();
1918        let _ = dataset; // dataset is unused here — per_detection doesn't need it.
1919        let table = build_per_detection(
1920            &grid,
1921            &detections,
1922            crate::parity::iou_thresholds(),
1923            None,
1924            &TablesConfig::default(),
1925        )
1926        .unwrap();
1927        assert_eq!(table.len(), 2);
1928        // Sort rows by score-desc to map to the matching engine's
1929        // sorted order. Image 1 (score=0.9) is the perfect-match
1930        // (TP); image 2 (score=0.8) is the unmatched FP.
1931        let statuses: Vec<MatchStatus> = table.match_status_at_50.clone();
1932        // Two rows total; one TP, one FP.
1933        let tp_count = statuses
1934            .iter()
1935            .filter(|s| **s == MatchStatus::TruePositive)
1936            .count();
1937        let fp_count = statuses
1938            .iter()
1939            .filter(|s| **s == MatchStatus::FalsePositive)
1940            .count();
1941        assert_eq!(tp_count, 1);
1942        assert_eq!(fp_count, 1);
1943        // best_iou is None when retained_ious=None.
1944        assert!(table.best_iou.iter().all(Option::is_none));
1945        // bbox is None when geometry flag is off (default).
1946        assert!(table.bbox.is_none());
1947    }
1948
1949    #[test]
1950    fn build_per_class_rejects_max_dets_missing_canonical_ladder() {
1951        let dataset = dataset_with_two_categories();
1952        let iou_thr = iou_thresholds();
1953        let max_dets = [10usize, 100]; // missing 1
1954        let n_t = iou_thr.len();
1955        let accum = Accumulated {
1956            precision: Array5::<f64>::from_elem((n_t, 101, 2, 4, 2), 0.5),
1957            recall: Array4::<f64>::from_elem((n_t, 2, 4, 2), 0.5),
1958            scores: Array5::<f64>::from_elem((n_t, 101, 2, 4, 2), 0.5),
1959        };
1960        let support = PerClassSupport::zeros(2);
1961        let err = build_per_class(&accum, &dataset, iou_thr, &max_dets, &support).unwrap_err();
1962        assert!(matches!(err, EvalError::InvalidConfig { .. }));
1963    }
1964}