Skip to main content

vernier_core/
evaluate.rs

1//! Per-image evaluation orchestrator.
2//!
3//! The bridge between the dataset layer ([`crate::CocoDataset`] /
4//! [`crate::CocoDetections`]) and the IoU-type-agnostic spine
5//! ([`crate::matching`] → [`crate::accumulate`]). Pycocotools fuses
6//! these in `evaluate()` (cocoeval.py 174-216); we keep the layers
7//! separate so the spine stays untouchable per ADR-0005.
8//!
9//! The pass is generic over [`EvalKernel`] — a `Similarity` supertrait
10//! that adds the dataset-bridging methods that turn a `(image, category)`
11//! cell into kernel-typed annotations. Bbox and segm reuse the same
12//! orchestrator with [`BboxIou`] and [`SegmIou`] respectively; future
13//! kernels (OKS, Boundary IoU) plug in by adding one
14//! `impl EvalKernel for FooIou` block — `match_image`, `accumulate`,
15//! and `summarize_*` stay untouched.
16//!
17//! ## What this layer does
18//!
19//! For each `(image, category)` cell:
20//!
21//! 1. Gather GTs and DTs from the dataset indices.
22//! 2. Pre-filter DTs to the top `max_dets_per_image` by score (the
23//!    matching engine and accumulator both rely on this cap; smaller
24//!    `max_dets` values are sliced downstream by `accumulate`).
25//! 3. Build the kernel's annotation slices via
26//!    [`EvalKernel::build_gt_anns`] / [`EvalKernel::build_dt_anns`] and
27//!    compute the GT × DT IoU matrix once via [`Similarity::compute`].
28//! 4. For each area range, build the per-call `_ignore` vector
29//!    (quirk **D3**) from the dataset's base ignore (D1) plus the area
30//!    filter (D6/D7), run the [`crate::matching`] engine, apply quirk **B7** by
31//!    flipping `dt_ignore` for unmatched DTs whose area is outside the
32//!    active range, and pack the result as a [`crate::accumulate::PerImageEval`] at
33//!    `[k][a][i]`.
34//!
35//! ## Quirk dispositions handled here
36//!
37//! - **D3** (`aligned`): per-call `_ignore` computed without mutating
38//!   the dataset.
39//! - **D6/D7** (`strict`): area filter uses non-strict `<=` / `>=` on
40//!   both bounds (mirrors `cocoeval.py:251`'s
41//!   `g['area'] < aRng[0] or g['area'] > aRng[1]` exclusion). An
42//!   annotation whose area equals a bucket boundary lands in *both*
43//!   adjacent buckets. Inequality direction matches the eval-time filter
44//!   in pycocotools, *not* `getAnnIds(areaRng=...)`.
45//! - **B7** (`strict`): unmatched DTs whose area is out of range get
46//!   `dt_ignore=true` so they do not contribute to the precision/recall
47//!   curve in this area cell.
48//! - **AA3** (`strict`, ADR-0026): when the dataset carries LVIS
49//!   federated metadata and the current `(image, category)` cell is in
50//!   `not_exhaustive_category_ids[image]`, every unmatched DT in the
51//!   cell has its `dt_ignore` set to `true`. Mirrors lvis-api
52//!   `eval.py:269-278`'s OR into the area-bucket `dt_ig_mask`. The
53//!   matching engine is unchanged: the flag piggybacks on the same
54//!   `dt_ignore` field B7 already drives.
55//! - **AA4** (`strict`, ADR-0026): on a federated dataset and with
56//!   `use_cats=true`, a cell `(image I, category C)` is evaluated only
57//!   when `C ∈ pos[I] ∪ neg[I]`. Cells with no GT (so `C ∉ pos[I]`)
58//!   and no `neg` listing produce no `eval_imgs` entry — the existing
59//!   `Option<PerImageEval>` distinction (`None` vs an empty cell) is
60//!   the same one lvis-api's `eval.py:336` filter relies on.
61//! - **L4** (`aligned`): `use_cats=false` collapses every category onto
62//!   a single virtual `k=0` bucket, with `category_id` carried through
63//!   matching as a no-op.
64//! - **E2 / J4** (`strict`): DTs never carry an `is_crowd` flag — the
65//!   [`crate::dataset::CocoDetection`] type lacks the field. Only GT crowdness
66//!   drives the E1 asymmetry inside the kernel.
67//! - **J3** (`strict`): DT areas are read from
68//!   [`crate::dataset::CocoDetection::area`], which the dataset layer derives
69//!   from the bbox at construction.
70//! - **J2** (`strict`): under [`ParityMode::Strict`], a DT lacking a
71//!   `segmentation` field under `iouType="segm"` has its bbox
72//!   synthesized into a 4-point rectangle polygon
73//!   `[[x1,y1, x1,y2, x2,y2, x2,y1]]` and rasterized — bit-for-bit the
74//!   path `pycocotools/coco.py:341` follows. Under
75//!   [`ParityMode::Corrected`] (the default for net-new users) the
76//!   synthesis is refused with [`EvalError::InvalidAnnotation`]: silent
77//!   coercion of bbox results to rectangle masks is a footgun, and
78//!   users who want strict parity opt in.
79//! - **J6** (`corrected`): per-entry dispatch — every detection is
80//!   inspected independently for the segm/bbox kind. Under
81//!   [`ParityMode::Corrected`] heterogeneous DT lists (some entries
82//!   with `segmentation`, some without) are rejected up-front rather
83//!   than silently routed through the first-entry-decides dispatch
84//!   pycocotools follows at `coco.py:330-363`.
85
86use ndarray::{Array2, ArrayView2, ArrayViewMut2};
87
88use crate::accumulate::PerImageEval;
89use crate::dataset::{
90    Bbox, CategoryId, CocoAnnotation, CocoDataset, CocoDetection, CocoDetections, EvalDataset,
91    ImageId, ImageMeta,
92};
93use crate::error::EvalError;
94use crate::matching::{match_image, MatchResult};
95use crate::parity::ParityMode;
96use crate::segmentation::Segmentation;
97use crate::similarity::{
98    boundary_iou_compute, segm_iou_compute, BboxAnn, BboxIou, BoundaryComputeScratch,
99    BoundaryGtCache, BoundaryIou, OksAnn, OksSimilarity, SegmAnn, SegmComputeScratch, SegmGtCache,
100    SegmIou, Similarity,
101};
102use std::cell::RefCell;
103use std::collections::{HashMap, HashSet};
104use std::sync::Arc;
105use thread_local::ThreadLocal;
106use vernier_mask::Rle;
107
108/// Either a borrowed or `Arc`-owned reference to a per-kernel GT cache.
109///
110/// The borrowed variant feeds the one-shot batch entry points
111/// ([`evaluate_boundary_cached`], [`evaluate_segm_cached`]) where the
112/// cache's lifetime trivially exceeds the call. The `Arc` variant feeds
113/// the streaming substrate ([`crate::stream::StreamingEvaluator`]),
114/// where the kernel lives on a worker thread that needs `'static` and
115/// the cache is the same `Arc` the FFI [`crate::CocoDataset`] handle
116/// holds (ADR-0020).
117#[derive(Clone)]
118pub enum GtCacheRef<'a, T: ?Sized> {
119    /// Caller-owned cache passed by reference; lifetime tied to the
120    /// borrow. Used by the batch entry points.
121    Borrowed(&'a T),
122    /// Atomically refcounted cache, shared with the FFI `CocoDataset`
123    /// handle (ADR-0020). Used by streaming so the kernel can be
124    /// `'static`.
125    Owned(Arc<T>),
126}
127
128impl<T: ?Sized> GtCacheRef<'_, T> {
129    /// Borrow the underlying cache irrespective of variant.
130    pub fn get(&self) -> &T {
131        match self {
132            GtCacheRef::Borrowed(r) => r,
133            GtCacheRef::Owned(a) => a.as_ref(),
134        }
135    }
136}
137
138/// Sentinel `category_id` emitted on every cell when `use_cats=false`.
139/// Mirrors pycocotools' `p.catIds = [-1]` collapse (quirk **L4**).
140pub const COLLAPSED_CATEGORY_SENTINEL: i64 = -1;
141
142/// Sentinel upper bound for "unbounded" area buckets, mirroring the
143/// `1e10` pycocotools uses for `all` / `large`.
144pub const AREA_UNBOUNDED: f64 = 1e10;
145
146/// Closed `[lo, hi]` area bucket — both bounds are inclusive per quirks
147/// **D6/D7**, so an annotation with area exactly equal to a bound lands
148/// in this bucket (and in the adjacent one when the boundary is shared).
149///
150/// `index` is the position on the `Accumulated` A-axis the resulting
151/// [`PerImageEval`] feeds into; matched at summarize time against
152/// [`crate::summarize::AreaRng::index`].
153#[derive(Debug, Clone, Copy, PartialEq, rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)]
154#[rkyv(derive(Debug))]
155pub struct AreaRange {
156    /// A-axis position. `0` is conventionally the `all` bucket, matching
157    /// [`crate::summarize::AreaRng::ALL`].
158    pub index: usize,
159    /// Lower bound (inclusive — quirks D6/D7).
160    pub lo: f64,
161    /// Upper bound (inclusive — quirks D6/D7). Use [`AREA_UNBOUNDED`]
162    /// for "no upper bound".
163    pub hi: f64,
164}
165
166impl AreaRange {
167    /// Pycocotools' default detection grid: `all`, `small`, `medium`,
168    /// `large`. Indices line up with [`crate::summarize::AreaRng`]'s `ALL` /
169    /// `SMALL` / `MEDIUM` / `LARGE` constants.
170    pub fn coco_default() -> [Self; 4] {
171        [
172            Self {
173                index: 0,
174                lo: 0.0,
175                hi: AREA_UNBOUNDED,
176            },
177            Self {
178                index: 1,
179                lo: 0.0,
180                hi: 32.0 * 32.0,
181            },
182            Self {
183                index: 2,
184                lo: 32.0 * 32.0,
185                hi: 96.0 * 96.0,
186            },
187            Self {
188                index: 3,
189                lo: 96.0 * 96.0,
190                hi: AREA_UNBOUNDED,
191            },
192        ]
193    }
194
195    /// Keypoints area grid (per ADR-0012, quirk **D5**): `all`, `medium`,
196    /// `large` — pycocotools drops the `small` bucket for kp eval. The
197    /// A-axis is compressed to 3 entries with indices `0 = all`,
198    /// `1 = medium`, `2 = large`. Pair with
199    /// [`crate::summarize::StatRequest::coco_keypoints_default`] so the
200    /// summarizer's `req.area.index` lookups land on the right slice.
201    pub fn keypoints_default() -> [Self; 3] {
202        [
203            Self {
204                index: 0,
205                lo: 0.0,
206                hi: AREA_UNBOUNDED,
207            },
208            Self {
209                index: 1,
210                lo: 32.0 * 32.0,
211                hi: 96.0 * 96.0,
212            },
213            Self {
214                index: 2,
215                lo: 96.0 * 96.0,
216                hi: AREA_UNBOUNDED,
217            },
218        ]
219    }
220
221    fn contains(&self, area: f64) -> bool {
222        // D6 (strict): pycocotools (cocoeval.py:251) keeps a GT/DT in a
223        // bucket when `not (area < lo or area > hi)`, i.e. non-strict
224        // inclusion on both ends. An area equal to a bucket boundary
225        // (e.g. 32² = 1024) therefore lands in *both* adjacent buckets.
226        area >= self.lo && area <= self.hi
227    }
228}
229
230/// Inputs to [`evaluate_bbox`] / [`evaluate_segm`] / [`evaluate_boundary`] / [`evaluate_with`].
231/// IoU-agnostic — kernel-specific configuration (sigmas, prefilter
232/// thresholds, …) lives on the [`EvalKernel`] passed alongside.
233#[derive(Debug, Clone, Copy)]
234pub struct EvaluateParams<'p> {
235    /// IoU thresholds, length `T`. Use [`crate::parity::iou_thresholds`] for the
236    /// canonical 10-point COCO ladder.
237    pub iou_thresholds: &'p [f64],
238    /// Area ranges. The `index` field of each entry is the A-axis
239    /// position the resulting [`PerImageEval`] is filed under; the
240    /// orchestrator emits exactly `area_ranges.len()` cells per
241    /// `(image, category)`.
242    pub area_ranges: &'p [AreaRange],
243    /// Top-N filter applied to DTs per `(image, category)` cell before
244    /// matching. Should be the largest entry of the eventual
245    /// [`crate::accumulate::AccumulateParams::max_dets`] ladder; smaller caps are
246    /// sliced downstream.
247    pub max_dets_per_image: usize,
248    /// Quirk **L4** (`aligned`): when `false`, every category is
249    /// collapsed onto a single bucket `k=0` and `category_id` is ignored
250    /// for gather purposes.
251    pub use_cats: bool,
252    /// When `true`, [`evaluate_with`] retains the per-`(category,
253    /// image)` IoU matrix on [`EvalGrid::retained_ious`] so the
254    /// `per_pair` / `per_detection` result tables can read it. Default
255    /// `false`; the no-retention path allocates nothing extra and is
256    /// bit-identical to the 0.0.1 release.
257    pub retain_iou: bool,
258}
259
260/// Owned counterpart to [`EvaluateParams`].
261///
262/// The streaming evaluator holds its config across many `update()`
263/// calls and cannot borrow per-call slices the way the batch entry
264/// points do. [`Self::borrow`] reconstructs an [`EvaluateParams`] view
265/// that reuses this struct's storage, so handing the owned form to the
266/// unchanged `evaluate_with` path is zero-cost.
267#[derive(Debug, Clone, rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)]
268#[rkyv(derive(Debug))]
269pub struct OwnedEvaluateParams {
270    /// IoU thresholds, length `T`.
271    pub iou_thresholds: Vec<f64>,
272    /// Area ranges (owned).
273    pub area_ranges: Vec<AreaRange>,
274    /// Top-N filter applied to DTs per `(image, category)` cell before matching.
275    pub max_dets_per_image: usize,
276    /// Quirk **L4** collapse flag.
277    pub use_cats: bool,
278    /// IoU-matrix retention flag — see [`EvaluateParams::retain_iou`].
279    pub retain_iou: bool,
280}
281
282impl OwnedEvaluateParams {
283    /// Borrowed view. Reuses `self`'s storage; no allocation.
284    pub fn borrow(&self) -> EvaluateParams<'_> {
285        EvaluateParams {
286            iou_thresholds: &self.iou_thresholds,
287            area_ranges: &self.area_ranges,
288            max_dets_per_image: self.max_dets_per_image,
289            use_cats: self.use_cats,
290            retain_iou: self.retain_iou,
291        }
292    }
293
294    /// 32-byte BLAKE3 fingerprint of these params. Stable for equal
295    /// values; carried in distributed-eval partial headers (ADR-0031)
296    /// so heterogeneous-config partials are refused at merge time.
297    ///
298    /// The archived rkyv form is deterministic per the field order
299    /// declared on this struct, so the hash is stable as long as the
300    /// struct shape is. Adding fields invalidates the hash — that is
301    /// what bumps the partial format version.
302    ///
303    /// # Errors
304    ///
305    /// [`EvalError::InvalidConfig`] if rkyv refuses to serialize the
306    /// archived form. In practice this can only happen for the same
307    /// reasons `to_bytes` itself fails (allocator OOM); we map it to
308    /// the existing variant rather than introducing a new one.
309    pub fn params_hash(&self) -> Result<[u8; 32], EvalError> {
310        let bytes =
311            rkyv::to_bytes::<rkyv::rancor::Error>(self).map_err(|e| EvalError::InvalidConfig {
312                detail: format!("rkyv serialization of OwnedEvaluateParams failed: {e}"),
313            })?;
314        Ok(*blake3::hash(&bytes).as_bytes())
315    }
316}
317
318/// Discriminator for the four kernel families on the IoU axis (per
319/// ADR-0012's iou-type taxonomy). Carried in distributed-eval partials
320/// (ADR-0031) so a head-rank reconstruction refuses to merge bbox and
321/// segm partials silently.
322///
323/// Variant order is **wire-format load-bearing**: the rkyv archived
324/// discriminant is keyed off it. Adding new kernels appends; never
325/// reorder, never remove. Use a new ADR + format-version bump if the
326/// space ever needs to change.
327#[derive(
328    Debug, Clone, Copy, PartialEq, Eq, Hash, rkyv::Archive, rkyv::Serialize, rkyv::Deserialize,
329)]
330#[rkyv(derive(Debug, PartialEq, Eq))]
331pub enum KernelKind {
332    /// `BboxIou` — axis-aligned box IoU (the default).
333    Bbox,
334    /// `SegmIou` (and `SegmIouCached`) — RLE/polygon mask IoU.
335    Segm,
336    /// `BoundaryIou` (and `BoundaryIouCached`) — boundary-IoU per ADR-0017.
337    Boundary,
338    /// `OksSimilarity` — OKS-based keypoints similarity (ADR-0012).
339    Keypoints,
340}
341
342impl KernelKind {
343    /// `u32` discriminator carried in the wire envelope header
344    /// (ADR-0032). Stable values: `Bbox=0, Segm=1, Boundary=2,
345    /// Keypoints=3` — the same values ADR-0031 wrote as a `u8`.
346    /// Adding new kernels appends; never reorder.
347    pub const fn discriminator(self) -> u32 {
348        match self {
349            Self::Bbox => 0,
350            Self::Segm => 1,
351            Self::Boundary => 2,
352            Self::Keypoints => 3,
353        }
354    }
355}
356
357/// `build_gt_anns` / `build_dt_anns` call counter, gated on `bench-timings`.
358#[cfg(feature = "bench-timings")]
359pub(crate) mod build_anns_count {
360    use crate::bench_counters::BenchCounterSet;
361
362    static COUNTERS: BenchCounterSet<1> = BenchCounterSet::new();
363
364    #[inline]
365    pub(crate) fn bump() {
366        COUNTERS.bump(0);
367    }
368
369    pub(crate) fn read_and_reset() -> u64 {
370        COUNTERS.read_and_reset()[0]
371    }
372}
373
374/// Bridges a [`CocoDataset`] / [`CocoDetections`] cell to a kernel's
375/// annotation type.
376///
377/// Per ADR-0005, the per-image pass is generic over this trait so a new
378/// IoU type plugs in via one `impl EvalKernel for FooIou` block — the
379/// matching engine, accumulator, and summarizer never see the new type.
380///
381/// Implementors do the per-cell rasterization / lookup that a [`Similarity`]
382/// kernel can't (because [`Similarity`] is dataset-agnostic by design).
383/// `image` carries the `(h, w)` segm impls need for [`crate::segmentation::Segmentation::to_rle`].
384pub trait EvalKernel: Similarity {
385    /// Discriminator carried in the distributed-eval wire format
386    /// (ADR-0031) so heterogeneous partials are refused at merge time.
387    /// Required (no default): every kernel must declare its kind.
388    fn kind(&self) -> KernelKind;
389
390    /// Build the kernel's GT annotation slice for one `(image, category)`
391    /// cell. `indices` selects from `gt_anns` in the order the cell
392    /// matcher will see.
393    fn build_gt_anns(
394        &self,
395        gt_anns: &[CocoAnnotation],
396        indices: &[usize],
397        image: &ImageMeta,
398    ) -> Result<Vec<Self::Annotation>, EvalError>;
399
400    /// Build the kernel's DT annotation slice for one `(image, category)`
401    /// cell, in score-descending sorted order matching `dt_indices`.
402    ///
403    /// `parity_mode` is threaded through so kernels with parity-aware
404    /// fallbacks (segm's J2 bbox→polygon synthesis under
405    /// [`ParityMode::Strict`]) can dispatch on it without reaching back
406    /// up the call stack.
407    fn build_dt_anns(
408        &self,
409        dt_anns: &[CocoDetection],
410        indices: &[usize],
411        image: &ImageMeta,
412        parity_mode: ParityMode,
413    ) -> Result<Vec<Self::Annotation>, EvalError>;
414
415    /// Pooled-buffer sibling of [`Self::build_gt_anns`]. The default
416    /// body forwards to [`Self::build_gt_anns`] and assigns the result
417    /// into `buf`, which preserves the existing per-call allocation
418    /// behavior for kernels that don't override. Kernels whose per-cell
419    /// compute is dominated by the `Vec<Self::Annotation>` allocation —
420    /// today that's [`crate::similarity::BboxIou`] only — override to
421    /// `clear()` + `extend()` against the caller's pooled buffer,
422    /// eliminating the per-cell malloc/free pair on the GT side.
423    ///
424    /// # Errors
425    /// Propagates whatever [`Self::build_gt_anns`] returns.
426    fn build_gt_anns_into(
427        &self,
428        buf: &mut Vec<Self::Annotation>,
429        gt_anns: &[CocoAnnotation],
430        indices: &[usize],
431        image: &ImageMeta,
432    ) -> Result<(), EvalError> {
433        *buf = self.build_gt_anns(gt_anns, indices, image)?;
434        Ok(())
435    }
436
437    /// Pooled-buffer sibling of [`Self::build_dt_anns`]. See
438    /// [`Self::build_gt_anns_into`] for the override contract.
439    ///
440    /// # Errors
441    /// Propagates whatever [`Self::build_dt_anns`] returns.
442    fn build_dt_anns_into(
443        &self,
444        buf: &mut Vec<Self::Annotation>,
445        dt_anns: &[CocoDetection],
446        indices: &[usize],
447        image: &ImageMeta,
448        parity_mode: ParityMode,
449    ) -> Result<(), EvalError> {
450        *buf = self.build_dt_anns(dt_anns, indices, image, parity_mode)?;
451        Ok(())
452    }
453
454    /// Optional kernel-specific GT ignore override. Default `false` (no
455    /// kernel reason to ignore).
456    ///
457    /// The orchestrator OR-s the result with the dataset-level
458    /// [`CocoAnnotation::effective_ignore`] (quirk **D1**) when building
459    /// `gt_base_ignore`. [`OksSimilarity`] overrides this to fold in
460    /// quirk **D2** (`strict`): GT with zero visible keypoints is
461    /// treated as an implicit ignore region, OR-ed with the existing
462    /// ignore. Bbox / segm / boundary kernels keep the default — D2 is
463    /// keypoints-specific and must not bleed across kernels.
464    fn extra_gt_ignore(&self, _ann: &CocoAnnotation) -> bool {
465        false
466    }
467
468    /// Marker: is this kernel the keypoints (OKS) kernel?
469    ///
470    /// The streaming evaluator dispatches its summarizer choice on this
471    /// flag: keypoints kernels resolve to the 10-stat
472    /// [`crate::summarize::StatRequest::coco_keypoints_default`] plan, every other
473    /// kernel resolves to the 12-stat detection plan. Default `false`;
474    /// [`OksSimilarity`] overrides to `true`. Additive trait method —
475    /// existing implementors keep the default.
476    fn is_keypoints(&self) -> bool {
477        false
478    }
479}
480
481impl EvalKernel for BboxIou {
482    fn kind(&self) -> KernelKind {
483        KernelKind::Bbox
484    }
485
486    fn build_gt_anns(
487        &self,
488        gt_anns: &[CocoAnnotation],
489        indices: &[usize],
490        _image: &ImageMeta,
491    ) -> Result<Vec<BboxAnn>, EvalError> {
492        Ok(indices
493            .iter()
494            .map(|&j| BboxAnn {
495                bbox: gt_anns[j].bbox,
496                is_crowd: gt_anns[j].is_crowd,
497            })
498            .collect())
499    }
500
501    fn build_dt_anns(
502        &self,
503        dt_anns: &[CocoDetection],
504        indices: &[usize],
505        _image: &ImageMeta,
506        _parity_mode: ParityMode,
507    ) -> Result<Vec<BboxAnn>, EvalError> {
508        // E2/J4: DT never carries crowd.
509        Ok(indices
510            .iter()
511            .map(|&j| BboxAnn {
512                bbox: dt_anns[j].bbox,
513                is_crowd: false,
514            })
515            .collect())
516    }
517
518    fn build_gt_anns_into(
519        &self,
520        buf: &mut Vec<BboxAnn>,
521        gt_anns: &[CocoAnnotation],
522        indices: &[usize],
523        _image: &ImageMeta,
524    ) -> Result<(), EvalError> {
525        buf.clear();
526        buf.extend(indices.iter().map(|&j| BboxAnn {
527            bbox: gt_anns[j].bbox,
528            is_crowd: gt_anns[j].is_crowd,
529        }));
530        Ok(())
531    }
532
533    fn build_dt_anns_into(
534        &self,
535        buf: &mut Vec<BboxAnn>,
536        dt_anns: &[CocoDetection],
537        indices: &[usize],
538        _image: &ImageMeta,
539        _parity_mode: ParityMode,
540    ) -> Result<(), EvalError> {
541        // E2/J4: DT never carries crowd.
542        buf.clear();
543        buf.extend(indices.iter().map(|&j| BboxAnn {
544            bbox: dt_anns[j].bbox,
545            is_crowd: false,
546        }));
547        Ok(())
548    }
549}
550
551impl EvalKernel for SegmIou {
552    fn kind(&self) -> KernelKind {
553        KernelKind::Segm
554    }
555
556    fn build_gt_anns(
557        &self,
558        gt_anns: &[CocoAnnotation],
559        indices: &[usize],
560        image: &ImageMeta,
561    ) -> Result<Vec<SegmAnn>, EvalError> {
562        build_segm_gt_anns(gt_anns, indices, image)
563    }
564
565    fn build_dt_anns(
566        &self,
567        dt_anns: &[CocoDetection],
568        indices: &[usize],
569        image: &ImageMeta,
570        parity_mode: ParityMode,
571    ) -> Result<Vec<SegmAnn>, EvalError> {
572        build_segm_dt_anns(dt_anns, indices, image, parity_mode)
573    }
574}
575
576impl EvalKernel for BoundaryIou {
577    fn kind(&self) -> KernelKind {
578        KernelKind::Boundary
579    }
580
581    fn build_gt_anns(
582        &self,
583        gt_anns: &[CocoAnnotation],
584        indices: &[usize],
585        image: &ImageMeta,
586    ) -> Result<Vec<SegmAnn>, EvalError> {
587        build_segm_gt_anns(gt_anns, indices, image)
588    }
589
590    fn build_dt_anns(
591        &self,
592        dt_anns: &[CocoDetection],
593        indices: &[usize],
594        image: &ImageMeta,
595        parity_mode: ParityMode,
596    ) -> Result<Vec<SegmAnn>, EvalError> {
597        build_segm_dt_anns(dt_anns, indices, image, parity_mode)
598    }
599}
600
601impl EvalKernel for OksSimilarity {
602    fn kind(&self) -> KernelKind {
603        KernelKind::Keypoints
604    }
605
606    fn build_gt_anns(
607        &self,
608        gt_anns: &[CocoAnnotation],
609        indices: &[usize],
610        _image: &ImageMeta,
611    ) -> Result<Vec<OksAnn>, EvalError> {
612        indices
613            .iter()
614            .map(|&j| {
615                let ann = &gt_anns[j];
616                let kps = ann
617                    .keypoints
618                    .as_deref()
619                    .ok_or_else(|| missing_keypoints_err("GT", ann.id.0, ann.image_id.0))?;
620                let num_keypoints = ann
621                    .num_keypoints
622                    .unwrap_or_else(|| count_visible_keypoints(kps));
623                Ok(OksAnn {
624                    category_id: ann.category_id.0,
625                    keypoints: kps.to_vec(),
626                    num_keypoints,
627                    bbox: ann.bbox.into(),
628                    area: ann.area,
629                })
630            })
631            .collect()
632    }
633
634    fn build_dt_anns(
635        &self,
636        dt_anns: &[CocoDetection],
637        indices: &[usize],
638        _image: &ImageMeta,
639        _parity_mode: ParityMode,
640    ) -> Result<Vec<OksAnn>, EvalError> {
641        // E2/J4: DT never carries crowd. There is no parity-mode J2
642        // analog for keypoints — pycocotools has no bbox→keypoint
643        // synthesis path, so a missing `keypoints` field is always an
644        // [`EvalError::InvalidAnnotation`] regardless of mode.
645        indices
646            .iter()
647            .map(|&j| {
648                let dt = &dt_anns[j];
649                let kps = dt
650                    .keypoints
651                    .as_deref()
652                    .ok_or_else(|| missing_keypoints_err("DT", dt.id.0, dt.image_id.0))?;
653                let num_keypoints = dt
654                    .num_keypoints
655                    .unwrap_or_else(|| count_visible_keypoints(kps));
656                Ok(OksAnn {
657                    category_id: dt.category_id.0,
658                    keypoints: kps.to_vec(),
659                    num_keypoints,
660                    bbox: dt.bbox.into(),
661                    area: dt.area,
662                })
663            })
664            .collect()
665    }
666
667    fn extra_gt_ignore(&self, ann: &CocoAnnotation) -> bool {
668        // D2 (`strict`): GT with zero visible keypoints is an implicit
669        // ignore region. Annotations without a `keypoints` field at all
670        // are treated as zero-visible — `build_gt_anns` will reject
671        // them downstream, but this hook runs before that and must
672        // stay total.
673        let visible = ann
674            .num_keypoints
675            .or_else(|| ann.keypoints.as_deref().map(count_visible_keypoints))
676            .unwrap_or(0);
677        visible == 0
678    }
679
680    fn is_keypoints(&self) -> bool {
681        true
682    }
683}
684
685/// Count of *visible* keypoints (`v > 0`) in a flat
686/// `[x, y, v, ...]` triplet vector. Used as the fallback for
687/// pycocotools-precomputed `num_keypoints` on inputs that omit it.
688fn count_visible_keypoints(kps: &[f64]) -> u32 {
689    kps.chunks_exact(3).filter(|t| t[2] > 0.0).count() as u32
690}
691
692/// OKS path equivalent of [`missing_segmentation_err`] — names the
693/// offending kind/id/image when a `keypoints` field is required and
694/// absent. Unlike segm there is no parity-mode escape hatch.
695fn missing_keypoints_err(kind: &str, ann_id: i64, image_id: i64) -> EvalError {
696    EvalError::InvalidAnnotation {
697        detail: format!(
698            "{kind} id={ann_id} on image {image_id} has no `keypoints` field; \
699             OKS eval requires keypoints on every entry. There is no \
700             pycocotools-equivalent bbox-synthesis fallback for keypoints \
701             (unlike segm quirk J2)."
702        ),
703    }
704}
705
706fn build_segm_gt_anns(
707    gt_anns: &[CocoAnnotation],
708    indices: &[usize],
709    image: &ImageMeta,
710) -> Result<Vec<SegmAnn>, EvalError> {
711    indices
712        .iter()
713        .map(|&j| {
714            let ann = &gt_anns[j];
715            let seg = ann
716                .segmentation
717                .as_ref()
718                .ok_or_else(|| missing_segmentation_err("GT", ann.id.0, image.id.0))?;
719            Ok(SegmAnn {
720                rle: seg.to_rle(image.height, image.width)?,
721                is_crowd: ann.is_crowd,
722                ann_id: ann.id.0,
723            })
724        })
725        .collect()
726}
727
728fn build_segm_dt_anns(
729    dt_anns: &[CocoDetection],
730    indices: &[usize],
731    image: &ImageMeta,
732    parity_mode: ParityMode,
733) -> Result<Vec<SegmAnn>, EvalError> {
734    indices
735        .iter()
736        .map(|&j| {
737            let dt = &dt_anns[j];
738            let rle = match (&dt.segmentation, parity_mode) {
739                (Some(seg), _) => seg.to_rle(image.height, image.width)?,
740                // J2 (`strict`): pycocotools' coco.py:341 synthesizes
741                // a rectangular polygon `[[x1,y1, x1,y2, x2,y2, x2,y1]]`
742                // from the bbox when a DT under iouType="segm" lacks
743                // a `segmentation` field. We reproduce that path
744                // bit-for-bit so strict-mode parity covers bbox-only
745                // result files.
746                (None, ParityMode::Strict) => {
747                    synthesize_dt_segm_from_bbox(&dt.bbox, image.height, image.width)?
748                }
749                // J2 (`corrected`) + J6 (`corrected`): silent coercion
750                // of bbox results to rectangle masks is a footgun.
751                // Refusing here also turns a heterogeneous DT list
752                // (some entries with segm, some without) under
753                // iouType="segm" into a clean, per-entry-pinpointed
754                // error rather than the first-entry-decides dispatch
755                // pycocotools follows.
756                (None, ParityMode::Corrected) => {
757                    return Err(missing_segmentation_err("DT", dt.id.0, image.id.0));
758                }
759            };
760            Ok(SegmAnn {
761                rle,
762                is_crowd: false,
763                ann_id: dt.id.0,
764            })
765        })
766        .collect()
767}
768
769/// J2 (`strict`): synthesize a 4-point rectangle polygon from a DT bbox
770/// and rasterize it at the image's `(h, w)`. Mirrors
771/// `pycocotools/coco.py:341` exactly:
772/// `[[x1,y1, x1,y2, x2,y2, x2,y1]]` where `(x1, y1)` is the top-left and
773/// `(x2, y2) = (x1 + w, y1 + h)`.
774fn synthesize_dt_segm_from_bbox(bbox: &Bbox, h: u32, w: u32) -> Result<Rle, EvalError> {
775    let x1 = bbox.x;
776    let y1 = bbox.y;
777    let x2 = bbox.x + bbox.w;
778    let y2 = bbox.y + bbox.h;
779    let polygon = vec![x1, y1, x1, y2, x2, y2, x2, y1];
780    let segm = Segmentation::Polygons(vec![polygon]);
781    segm.to_rle(h, w)
782}
783
784/// J2 (`corrected`) / J6 (`corrected`) error path: a DT lacks the
785/// `segmentation` field under `iouType="segm"`. The detail names the
786/// offending kind (`GT` or `DT`), id, and image so a heterogeneous
787/// DT list pinpoints the first entry without segm rather than failing
788/// with a global "wrong shape" error.
789fn missing_segmentation_err(kind: &str, ann_id: i64, image_id: i64) -> EvalError {
790    EvalError::InvalidAnnotation {
791        detail: format!(
792            "{kind} id={ann_id} on image {image_id} has no `segmentation` field; \
793             segm eval in corrected mode requires one on every entry. \
794             pycocotools synthesizes a bbox-rectangle polygon here \
795             (quirks J2/J6); pass `ParityMode::Strict` to opt into that \
796             behavior."
797        ),
798    }
799}
800
801/// Pycocotools-shaped per-cell bookkeeping that the matching engine
802/// strips out when packing [`PerImageEval`]. Surfaced separately so the
803/// accumulator stays narrow per ADR-0005, and FFI / `COCOeval` drop-in
804/// consumers can reconstruct `evalImgs` dicts without re-running eval.
805///
806/// All `dt_*` axes are in score-descending sorted order (stable
807/// mergesort, quirk **A1**); all `gt_*` axes are in ignore-ascending
808/// sorted order (quirk **A4**). `dt_matches` and `gt_matches` carry
809/// pycocotools' value semantics: `i64` annotation ids on a hit, `0` on a
810/// miss (matching `dtm`/`gtm` initialization in `cocoeval.py`).
811#[derive(Debug, Clone)]
812pub struct EvalImageMeta {
813    /// COCO image id for this cell.
814    pub image_id: i64,
815    /// COCO category id, or [`COLLAPSED_CATEGORY_SENTINEL`] when
816    /// `use_cats=false`.
817    pub category_id: i64,
818    /// Active area range as `[lo, hi]`, mirroring pycocotools' `aRng`.
819    pub area_rng: [f64; 2],
820    /// `max_dets_per_image` cap that produced this cell's DT slice.
821    pub max_det: usize,
822    /// DT annotation ids in sorted-DT order, length `D`.
823    pub dt_ids: Vec<i64>,
824    /// GT annotation ids in sorted-GT order, length `G`.
825    pub gt_ids: Vec<i64>,
826    /// Shape `(T, D)`. GT id matched at `(threshold, sorted-DT k)`, or
827    /// `0` if unmatched (pycocotools sentinel; safe because COCO ids are
828    /// `>= 1` per spec, and vernier's auto-id assignment also starts at 1).
829    pub dt_matches: Array2<i64>,
830    /// Shape `(T, G)`. DT id matched at `(threshold, sorted-GT k)`, or
831    /// `0` if unmatched (same `>= 1` invariant as `dt_matches`).
832    pub gt_matches: Array2<i64>,
833}
834
835/// Output of [`evaluate_bbox`] / [`evaluate_segm`] / [`evaluate_boundary`]
836/// — the flat `(K, A, I)` grid of
837/// [`PerImageEval`] cells the accumulator consumes, plus the dimensions
838/// needed to construct [`crate::accumulate::AccumulateParams`].
839#[derive(Debug, Clone)]
840pub struct EvalGrid {
841    /// `Some(cell)` per `(k, a, i)` triple where the cell ran; `None`
842    /// where pycocotools would emit `None` (image absent from
843    /// detections, no GTs and no DTs in the cell). Layout is K-major,
844    /// then A, then I — `eval_imgs[k * A * I + a * I + i]`.
845    ///
846    /// Cells are heap-boxed: `Option<Box<PerImageEval>>` is 8 bytes
847    /// (Box's `NonNull` niche absorbs the discriminant), so the dense
848    /// `n_categories * n_area_ranges * n_images` grid only pays for a
849    /// pointer per slot at zero-init time. On val2017 (1.6M slots,
850    /// 14k populated) this drops the upfront alloc from 268 MB to
851    /// 12.8 MB and the zero-init from ~120 ms to ~5 ms — see
852    /// `docs/engineering/benchmarking/2026-05-bbox-cdf.md`.
853    pub eval_imgs: Vec<Option<Box<PerImageEval>>>,
854    /// Pycocotools-shaped bookkeeping for each populated cell (same
855    /// `[k][a][i]` layout as `eval_imgs`; `None` wherever `eval_imgs` is
856    /// `None`). Boxed for the same reason as `eval_imgs`.
857    pub eval_imgs_meta: Vec<Option<Box<EvalImageMeta>>>,
858    /// `K` axis size: the number of categories used for evaluation, or
859    /// `1` when `use_cats=false`.
860    pub n_categories: usize,
861    /// `A` axis size: equal to `params.area_ranges.len()`.
862    pub n_area_ranges: usize,
863    /// `I` axis size: number of images iterated over (every image in the
864    /// GT dataset, in deterministic id-ascending order).
865    pub n_images: usize,
866    /// Per-`(category, image)` IoU matrices retained when the caller
867    /// passed [`EvaluateParams::retain_iou`] = `true`. `None` on the
868    /// default no-retention path; one discriminant byte wide there.
869    pub retained_ious: Option<crate::tables::RetainedIous>,
870}
871
872impl EvalGrid {
873    /// Cell at `(category_index, area_index, image_index)`. Returns
874    /// `None` when the indices are in bounds but no cell ran (image
875    /// absent from detections, or no GTs and no DTs in the cell);
876    /// returns `None` for out-of-bounds indices as well.
877    pub fn cell(&self, k: usize, a: usize, i: usize) -> Option<&PerImageEval> {
878        let idx = self.flat_index(k, a, i)?;
879        self.eval_imgs.get(idx).and_then(Option::as_deref)
880    }
881
882    /// Pycocotools-shaped bookkeeping at `(category_index, area_index,
883    /// image_index)`. `None` exactly when [`EvalGrid::cell`] is `None`.
884    pub fn cell_meta(&self, k: usize, a: usize, i: usize) -> Option<&EvalImageMeta> {
885        let idx = self.flat_index(k, a, i)?;
886        self.eval_imgs_meta.get(idx).and_then(Option::as_deref)
887    }
888
889    fn flat_index(&self, k: usize, a: usize, i: usize) -> Option<usize> {
890        if k >= self.n_categories || a >= self.n_area_ranges || i >= self.n_images {
891            return None;
892        }
893        Some(k * self.n_area_ranges * self.n_images + a * self.n_images + i)
894    }
895}
896
897/// Run the per-image evaluation pass with the given [`EvalKernel`].
898///
899/// Iterates `(image, category)` cells, computes the IoU matrix once per
900/// cell via the kernel, runs the [`crate::matching`] engine once per area range,
901/// and packs the results into a flat `[k][a][i]`-ordered grid suitable
902/// for [`crate::accumulate`].
903///
904/// Most callers want [`evaluate_bbox`], [`evaluate_segm`], or
905/// [`evaluate_boundary`]; this entry point is exposed for downstream
906/// code that ships its own kernel.
907///
908/// # Errors
909///
910/// Propagates [`EvalError`] from the underlying [`Similarity`],
911/// [`EvalKernel::build_gt_anns`] / [`EvalKernel::build_dt_anns`], and
912/// [`crate::matching`] calls.
913pub fn evaluate_with<K: EvalKernel>(
914    gt: &CocoDataset,
915    dt: &CocoDetections,
916    params: EvaluateParams<'_>,
917    parity_mode: ParityMode,
918    kernel: &K,
919) -> Result<EvalGrid, EvalError> {
920    // Image and category ordering: id-ascending, deterministic across runs.
921    let mut images: Vec<&ImageMeta> = gt.images().iter().collect();
922    images.sort_unstable_by_key(|im| im.id.0);
923    let n_i = images.len();
924    let n_a = params.area_ranges.len();
925
926    // L4: collapse to a single virtual bucket when `use_cats=false`.
927    let category_buckets: Vec<Option<CategoryId>> = if params.use_cats {
928        let mut cats: Vec<_> = gt.categories().iter().map(|c| c.id).collect();
929        cats.sort_unstable_by_key(|id| id.0);
930        cats.into_iter().map(Some).collect()
931    } else {
932        vec![None]
933    };
934    let n_k = category_buckets.len();
935
936    let mut eval_imgs: Vec<Option<Box<PerImageEval>>> = vec![None; n_k * n_a * n_i];
937    let mut eval_imgs_meta: Vec<Option<Box<EvalImageMeta>>> = vec![None; n_k * n_a * n_i];
938    // Optional IoU retention, keyed by `(k, i)` — IoU is geometry-only,
939    // so storing per-area would duplicate ~4× under the COCO grid.
940    let mut retained_ious_map: Option<std::collections::HashMap<(usize, usize), Array2<f64>>> =
941        if params.retain_iou {
942            Some(std::collections::HashMap::new())
943        } else {
944            None
945        };
946
947    // ADR-0026 federated metadata is consumed only when `use_cats=true`;
948    // LVIS evaluation is per-category by construction. With
949    // `use_cats=false` the federated maps are intentionally ignored
950    // and the eval falls back to COCO semantics (the L4 `k=0` collapse
951    // never carries federated state). Pre-resolve the per-image
952    // (neg, not_exhaustive) set references once — the inner cell
953    // loop hits this `n_k` times per image, and the HashMap lookups
954    // dominate runtime on long-tail datasets (1203 cats * 19809 images
955    // = ~24M redundant probes on full LVIS val).
956    let federated_per_image: Vec<Option<(&HashSet<CategoryId>, &HashSet<CategoryId>)>> =
957        match (params.use_cats, gt.federated()) {
958            (true, Some(fed)) => images
959                .iter()
960                .map(|im| {
961                    let neg = fed.neg_category_ids.get(&im.id)?;
962                    let nel = fed.not_exhaustive_category_ids.get(&im.id)?;
963                    Some((neg, nel))
964                })
965                .collect(),
966            _ => Vec::new(),
967        };
968
969    // Pre-grown scratch shared across every `(k, i)` cell. `clear()`
970    // + `extend()` per cell amortizes the ~14k allocator round-trips
971    // val2017 would otherwise pay for these gathers.
972    let mut scratch = CellScratch::new();
973    // Pooled `Vec<K::Annotation>` for `build_*_anns_into` overrides
974    // (today: bbox only). Grown once to worst-case cell shape, reused
975    // for every cell after — see [`KernelScratch`].
976    let mut kernel_scratch = KernelScratch::<K::Annotation>::default();
977    let gt_anns = gt.annotations();
978    let dt_anns = dt.detections();
979
980    // Quirk **AG6** (strict, ADR-0026): the LVIS oracle's
981    // `LVIS.get_ann_ids` applies a strict `area > 0` filter
982    // (`lvis/lvis.py:94`) and silently drops GTs whose JSON `area`
983    // is zero. Post-filter `img_pl` then drives `_prepare`'s federated
984    // DT filter, so on "all-zero-area" `(image, category)` cells the
985    // DT is dropped along with the GT, and on "mixed" cells the
986    // orphan DTs become FPs. Reproduce in strict mode for federated
987    // datasets only — COCO and `Corrected` mode keep zero-area
988    // annotations (vernier's default behavior). The filter slots in
989    // before the cell-empty short-circuit so the AA4 cell-skip path
990    // naturally fires when every GT in a cell is zero-area.
991    let strict_lvis_zero_area_filter =
992        matches!(parity_mode, ParityMode::Strict) && gt.federated().is_some();
993
994    for (k, cat) in category_buckets.iter().enumerate() {
995        let nk = k * n_a * n_i;
996        let category_id = cat.map_or(COLLAPSED_CATEGORY_SENTINEL, |c| c.0);
997        for (i, image) in images.iter().enumerate() {
998            let image_id = image.id;
999            let gt_indices_raw = gt_indices_for_cell(gt, image_id, *cat);
1000            let gt_indices_buf: Vec<usize>;
1001            let gt_indices: &[usize] = if strict_lvis_zero_area_filter
1002                && gt_indices_raw.iter().any(|&j| gt_anns[j].area <= 0.0)
1003            {
1004                gt_indices_buf = gt_indices_raw
1005                    .iter()
1006                    .copied()
1007                    .filter(|&j| gt_anns[j].area > 0.0)
1008                    .collect();
1009                &gt_indices_buf
1010            } else {
1011                gt_indices_raw
1012            };
1013            let raw_dt_indices = raw_dt_indices_for_cell(dt, image_id, *cat);
1014            if gt_indices.is_empty() && raw_dt_indices.is_empty() {
1015                continue;
1016            }
1017
1018            // AA4 cell-skip and AA3 `not_exhaustive` flag. The outer
1019            // resolution of `federated_per_image` ensures every entry
1020            // is `Some` exactly when federated semantics apply to
1021            // this cell.
1022            let mut not_exhaustive_for_cell = false;
1023            if let (Some(c), Some(Some((neg_set, nel_set)))) = (cat, federated_per_image.get(i)) {
1024                // pos[I] is derived from GTs at load: `C ∈ pos[I]`
1025                // exactly when `gt_indices` is non-empty for this
1026                // cell. Skip when the cell is outside `pos ∪ neg`.
1027                if gt_indices.is_empty() && !neg_set.contains(c) {
1028                    continue;
1029                }
1030                not_exhaustive_for_cell = nel_set.contains(c);
1031            }
1032
1033            // Top-N DT filter — fills `scratch.dt_indices` in place,
1034            // reusing `dt_score_buf` and `dt_perm_buf` across cells.
1035            dt_top_indices_for_cell_into(
1036                &mut scratch.dt_indices,
1037                &mut scratch.dt_score_buf,
1038                &mut scratch.dt_perm_buf,
1039                dt_anns,
1040                raw_dt_indices,
1041                params.max_dets_per_image,
1042            );
1043
1044            // Area-invariant per-cell gathers — built once, reused
1045            // across every area range. All seven Vecs are fields of
1046            // `scratch`; `clear()` + `extend()` keeps the allocations
1047            // amortized.
1048            scratch.gt_areas.clear();
1049            scratch
1050                .gt_areas
1051                .extend(gt_indices.iter().map(|&j| gt_anns[j].area));
1052            scratch.gt_iscrowd.clear();
1053            scratch
1054                .gt_iscrowd
1055                .extend(gt_indices.iter().map(|&j| gt_anns[j].is_crowd));
1056            // D1: parity-mode fork lives on the annotation; pass through.
1057            // Kernel-specific ignore reasons (OKS quirk **D2**) are
1058            // OR-ed in via [`EvalKernel::extra_gt_ignore`].
1059            scratch.gt_base_ignore.clear();
1060            scratch.gt_base_ignore.extend(gt_indices.iter().map(|&j| {
1061                gt_anns[j].effective_ignore(parity_mode) || kernel.extra_gt_ignore(&gt_anns[j])
1062            }));
1063            scratch.gt_ids.clear();
1064            scratch
1065                .gt_ids
1066                .extend(gt_indices.iter().map(|&j| gt_anns[j].id.0));
1067            scratch.dt_areas.clear();
1068            scratch
1069                .dt_areas
1070                .extend(scratch.dt_indices.iter().map(|&j| dt_anns[j].area));
1071            scratch.dt_scores.clear();
1072            scratch
1073                .dt_scores
1074                .extend(scratch.dt_indices.iter().map(|&j| dt_anns[j].score));
1075            scratch.dt_ids.clear();
1076            scratch
1077                .dt_ids
1078                .extend(scratch.dt_indices.iter().map(|&j| dt_anns[j].id.0));
1079
1080            #[cfg(feature = "bench-timings")]
1081            {
1082                build_anns_count::bump();
1083                build_anns_count::bump();
1084            }
1085            kernel.build_gt_anns_into(&mut kernel_scratch.gt, gt_anns, gt_indices, image)?;
1086            kernel.build_dt_anns_into(
1087                &mut kernel_scratch.dt,
1088                dt_anns,
1089                &scratch.dt_indices,
1090                image,
1091                parity_mode,
1092            )?;
1093
1094            // IoU scratch backing — `Vec<f64>` reused across cells, sized
1095            // to `g * d` per cell. Zero-fill keeps the empty-side fallback
1096            // (`g == 0` or `d == 0`) bit-identical to `Array2::zeros`.
1097            let g = kernel_scratch.gt.len();
1098            let d = kernel_scratch.dt.len();
1099            scratch.iou_buf.clear();
1100            scratch.iou_buf.resize(g * d, 0.0);
1101            if g > 0 && d > 0 {
1102                let mut iou_view = ArrayViewMut2::from_shape((g, d), &mut scratch.iou_buf[..])
1103                    .map_err(|e| EvalError::DimensionMismatch {
1104                        detail: format!("iou scratch view: {e}"),
1105                    })?;
1106                kernel.compute(&kernel_scratch.gt, &kernel_scratch.dt, &mut iou_view)?;
1107            }
1108
1109            let iou_view = ArrayView2::from_shape((g, d), &scratch.iou_buf[..]).map_err(|e| {
1110                EvalError::DimensionMismatch {
1111                    detail: format!("iou scratch view: {e}"),
1112                }
1113            })?;
1114            let buffers = CellBuffers {
1115                image_id: image_id.0,
1116                category_id,
1117                max_det: params.max_dets_per_image,
1118                gt_areas: &scratch.gt_areas,
1119                gt_iscrowd: &scratch.gt_iscrowd,
1120                gt_base_ignore: &scratch.gt_base_ignore,
1121                gt_ids: &scratch.gt_ids,
1122                dt_areas: &scratch.dt_areas,
1123                dt_scores: &scratch.dt_scores,
1124                dt_ids: &scratch.dt_ids,
1125                iou: iou_view,
1126                not_exhaustive: not_exhaustive_for_cell,
1127            };
1128            for (a, area) in params.area_ranges.iter().enumerate() {
1129                let (cell, meta) = evaluate_cell(
1130                    &mut scratch.gt_ignore_buf,
1131                    &buffers,
1132                    area,
1133                    params.iou_thresholds,
1134                    parity_mode,
1135                )?;
1136                let flat = nk + a * n_i + i;
1137                eval_imgs[flat] = Some(Box::new(cell));
1138                eval_imgs_meta[flat] = Some(Box::new(meta));
1139            }
1140
1141            // Retain a clone of the IoU matrix exactly when the caller
1142            // asked. The check is at end-of-cell so the area-range
1143            // loop above runs on the borrow `buffers.iou`; cloning
1144            // here costs O(G*D) f64s, only when retention is active.
1145            if let Some(map) = retained_ious_map.as_mut() {
1146                let cloned =
1147                    Array2::from_shape_vec((g, d), scratch.iou_buf.clone()).map_err(|e| {
1148                        EvalError::DimensionMismatch {
1149                            detail: format!("retained iou clone: {e}"),
1150                        }
1151                    })?;
1152                map.insert((k, i), cloned);
1153            }
1154        }
1155    }
1156
1157    Ok(EvalGrid {
1158        eval_imgs,
1159        eval_imgs_meta,
1160        n_categories: n_k,
1161        n_area_ranges: n_a,
1162        n_images: n_i,
1163        retained_ious: retained_ious_map.map(crate::tables::RetainedIous::from_map),
1164    })
1165}
1166
1167/// Run the per-image evaluation pass *and* the cross-class IoU side
1168/// pass (per ADR-0023) in a single call.
1169///
1170/// Returns the standard [`EvalGrid`] alongside a
1171/// [`crate::tables::CrossClassIous`] populated by walking each image's
1172/// un-class-filtered GT and DT lists through the same kernel
1173/// [`evaluate_with`] uses internally. Future TIDE callers consume both
1174/// outputs from one call so they do not pay the matching cost twice.
1175///
1176/// The matching engine is unchanged — the side pass is a separate
1177/// kernel pass at the orchestrator level, preserving the ADR-0005
1178/// invariant that matching is generic over the IoU matrix only. The
1179/// side pass shares `params.max_dets_per_image` with the matching path
1180/// so the DT row indexing across the two passes is consistent.
1181///
1182/// # Errors
1183///
1184/// Propagates [`EvalError`] from either pass.
1185pub(crate) fn evaluate_with_retention<K: EvalKernel>(
1186    gt: &CocoDataset,
1187    dt: &CocoDetections,
1188    params: EvaluateParams<'_>,
1189    parity_mode: ParityMode,
1190    kernel: &K,
1191) -> Result<(EvalGrid, crate::tables::CrossClassIous), EvalError> {
1192    let grid = evaluate_with(gt, dt, params, parity_mode, kernel)?;
1193    let cross_class = crate::tide::compute_cross_class_ious(
1194        gt,
1195        dt,
1196        kernel,
1197        parity_mode,
1198        params.max_dets_per_image,
1199    )?;
1200    Ok((grid, cross_class))
1201}
1202
1203/// Run the per-image bbox evaluation pass. Thin wrapper over
1204/// [`evaluate_with`] with the [`BboxIou`] kernel.
1205///
1206/// # Errors
1207///
1208/// Propagates [`EvalError`] from the underlying kernel and matching
1209/// calls.
1210pub fn evaluate_bbox(
1211    gt: &CocoDataset,
1212    dt: &CocoDetections,
1213    params: EvaluateParams<'_>,
1214    parity_mode: ParityMode,
1215) -> Result<EvalGrid, EvalError> {
1216    evaluate_with(gt, dt, params, parity_mode, &BboxIou)
1217}
1218
1219/// Run the per-image segmentation-mask evaluation pass. Thin wrapper
1220/// over [`evaluate_with`] with the [`SegmIou`] kernel.
1221///
1222/// GTs must carry a `segmentation` field. DT handling is parity-mode
1223/// aware (quirks **J2** / **J6**):
1224///
1225/// - [`ParityMode::Strict`] reproduces `pycocotools/coco.py:341` —
1226///   DTs missing a `segmentation` field have a 4-point rectangle
1227///   polygon synthesized from their bbox and rasterized.
1228/// - [`ParityMode::Corrected`] (the default for net-new users) raises
1229///   [`EvalError::InvalidAnnotation`] instead, which also rejects
1230///   heterogeneous DT lists (some entries with segm, some without)
1231///   per-entry rather than via pycocotools' first-entry-decides
1232///   dispatch.
1233///
1234/// # Errors
1235///
1236/// Propagates [`EvalError`] from the underlying kernel and matching
1237/// calls.
1238pub fn evaluate_segm(
1239    gt: &CocoDataset,
1240    dt: &CocoDetections,
1241    params: EvaluateParams<'_>,
1242    parity_mode: ParityMode,
1243) -> Result<EvalGrid, EvalError> {
1244    evaluate_with(gt, dt, params, parity_mode, &segm_kernel(None))
1245}
1246
1247/// Cached variant of [`evaluate_segm`]: reuses GT bbox + area across
1248/// calls via a caller-owned [`SegmGtCache`].
1249///
1250/// Use this when the same GT dataset is evaluated repeatedly against
1251/// changing detections — e.g. validation passes inside a training
1252/// loop. The first call populates the cache; each subsequent call
1253/// skips the `Rle::bbox` and `Rle::area` walks on the GT side. DT-side
1254/// derivations are always fresh (predictions change per call).
1255///
1256/// The cache is keyed by GT [`crate::dataset::CocoAnnotation::id`].
1257///
1258/// # Errors
1259///
1260/// Propagates [`EvalError`] from the underlying kernel and matching
1261/// calls.
1262pub fn evaluate_segm_cached(
1263    gt: &CocoDataset,
1264    dt: &CocoDetections,
1265    params: EvaluateParams<'_>,
1266    parity_mode: ParityMode,
1267    cache: &SegmGtCache,
1268) -> Result<EvalGrid, EvalError> {
1269    evaluate_with(gt, dt, params, parity_mode, &segm_kernel(Some(cache)))
1270}
1271
1272pub(crate) fn segm_kernel(gt_cache: Option<&SegmGtCache>) -> SegmIouCached<'_> {
1273    SegmIouCached {
1274        scratch: ThreadLocal::new(),
1275        gt_cache: gt_cache.map(GtCacheRef::Borrowed),
1276    }
1277}
1278
1279/// Kernel used by [`evaluate_segm`] and [`evaluate_segm_cached`] — same
1280/// semantics as [`SegmIou`] but threads a `SegmComputeScratch`
1281/// across every `compute` call (so the dataset-wide pass amortizes
1282/// per-cell `Vec` allocations across the ~36 k anns of a val2017 pass)
1283/// and optionally consults a [`SegmGtCache`] for cross-call GT
1284/// bbox+area reuse.
1285///
1286/// The cache reference is generalised through [`GtCacheRef`] so the same
1287/// kernel feeds both the borrowed batch path (`evaluate_segm_cached`)
1288/// and the `Arc`-owned streaming path
1289/// ([`Self::with_arc_cache`] + [`crate::stream::StreamingEvaluator`]).
1290///
1291/// Scratch is held in a [`ThreadLocal`] so rayon workers under the
1292/// ADR-0047 parallel path each get their own buffer — the pre-ADR-0047
1293/// `Mutex<Scratch>` shape serialised every per-cell `compute()` call
1294/// across workers, capping speedup well below the physical core count.
1295/// Single-threaded use lazily initialises one slot and pays no
1296/// per-call synchronisation.
1297pub struct SegmIouCached<'a> {
1298    scratch: ThreadLocal<RefCell<SegmComputeScratch>>,
1299    gt_cache: Option<GtCacheRef<'a, SegmGtCache>>,
1300}
1301
1302impl SegmIouCached<'static> {
1303    /// Construct a streaming-friendly kernel that owns its GT cache via
1304    /// [`Arc`] (ADR-0020). The kernel is `'static`, so a
1305    /// [`crate::stream::StreamingEvaluator`] can store it across the
1306    /// worker thread's lifetime; the same `Arc` is held by the FFI
1307    /// `CocoDataset` handle, so derivations populated on one path are
1308    /// visible to the other.
1309    pub fn with_arc_cache(cache: Arc<SegmGtCache>) -> Self {
1310        Self {
1311            scratch: ThreadLocal::new(),
1312            gt_cache: Some(GtCacheRef::Owned(cache)),
1313        }
1314    }
1315}
1316
1317impl Similarity for SegmIouCached<'_> {
1318    type Annotation = SegmAnn;
1319
1320    fn compute(
1321        &self,
1322        gts: &[SegmAnn],
1323        dts: &[SegmAnn],
1324        out: &mut ArrayViewMut2<'_, f64>,
1325    ) -> Result<(), EvalError> {
1326        let cell = self.scratch.get_or_default();
1327        let mut scratch = cell.borrow_mut();
1328        segm_iou_compute(
1329            gts,
1330            dts,
1331            out,
1332            &mut scratch,
1333            self.gt_cache.as_ref().map(GtCacheRef::get),
1334        )
1335    }
1336}
1337
1338impl EvalKernel for SegmIouCached<'_> {
1339    fn kind(&self) -> KernelKind {
1340        KernelKind::Segm
1341    }
1342
1343    fn build_gt_anns(
1344        &self,
1345        gt_anns: &[CocoAnnotation],
1346        indices: &[usize],
1347        image: &ImageMeta,
1348    ) -> Result<Vec<SegmAnn>, EvalError> {
1349        build_segm_gt_anns(gt_anns, indices, image)
1350    }
1351
1352    fn build_dt_anns(
1353        &self,
1354        dt_anns: &[CocoDetection],
1355        indices: &[usize],
1356        image: &ImageMeta,
1357        parity_mode: ParityMode,
1358    ) -> Result<Vec<SegmAnn>, EvalError> {
1359        build_segm_dt_anns(dt_anns, indices, image, parity_mode)
1360    }
1361}
1362
1363/// Run the per-image boundary-IoU evaluation pass (ADR-0010). Thin
1364/// wrapper over [`evaluate_with`] with the [`BoundaryIou`] kernel.
1365///
1366/// `dilation_ratio` controls the boundary band width per ADR-0010 §A2:
1367/// `0.02` is the COCO default and `0.008` is the LVIS variant.
1368///
1369/// GT/DT segmentation handling is identical to [`evaluate_segm`] — same
1370/// J2/J6 parity-mode dispatch on missing DT segmentations, same
1371/// "missing GT segmentation" error.
1372///
1373/// # Errors
1374///
1375/// Propagates [`EvalError`] from the underlying kernel and matching
1376/// calls.
1377pub fn evaluate_boundary(
1378    gt: &CocoDataset,
1379    dt: &CocoDetections,
1380    params: EvaluateParams<'_>,
1381    parity_mode: ParityMode,
1382    dilation_ratio: f64,
1383) -> Result<EvalGrid, EvalError> {
1384    evaluate_with(
1385        gt,
1386        dt,
1387        params,
1388        parity_mode,
1389        &boundary_kernel(dilation_ratio, None),
1390    )
1391}
1392
1393/// Cached variant of [`evaluate_boundary`]: reuses GT bands across
1394/// calls via a caller-owned [`BoundaryGtCache`].
1395///
1396/// Use this when the same GT dataset is evaluated repeatedly against
1397/// changing detections — e.g. validation passes inside a training
1398/// loop. The first call populates the cache; each subsequent call
1399/// skips GT band derivation. DT bands are always derived fresh
1400/// (predictions change per call).
1401///
1402/// The cache is keyed by GT [`crate::dataset::CocoAnnotation::id`].
1403/// If `dilation_ratio` differs from the previous call's, the cache
1404/// is cleared and re-populated — the bands depend on the ratio.
1405///
1406/// # Errors
1407///
1408/// Propagates [`EvalError`] from the underlying kernel and matching
1409/// calls.
1410pub fn evaluate_boundary_cached(
1411    gt: &CocoDataset,
1412    dt: &CocoDetections,
1413    params: EvaluateParams<'_>,
1414    parity_mode: ParityMode,
1415    dilation_ratio: f64,
1416    cache: &BoundaryGtCache,
1417) -> Result<EvalGrid, EvalError> {
1418    cache.align_ratio(dilation_ratio);
1419    evaluate_with(
1420        gt,
1421        dt,
1422        params,
1423        parity_mode,
1424        &boundary_kernel(dilation_ratio, Some(cache)),
1425    )
1426}
1427
1428pub(crate) fn boundary_kernel(
1429    dilation_ratio: f64,
1430    gt_cache: Option<&BoundaryGtCache>,
1431) -> BoundaryIouCached<'_> {
1432    BoundaryIouCached {
1433        dilation_ratio,
1434        scratch: ThreadLocal::new(),
1435        gt_cache: gt_cache.map(GtCacheRef::Borrowed),
1436    }
1437}
1438
1439/// Kernel used by [`evaluate_boundary`] and [`evaluate_boundary_cached`]
1440/// — same semantics as [`BoundaryIou`] but threads a
1441/// `BoundaryComputeScratch` across every `compute` call (so the
1442/// dataset-wide pass amortizes per-mask + per-cell allocations) and
1443/// optionally consults a [`BoundaryGtCache`] for cross-call GT band
1444/// reuse.
1445///
1446/// The cache reference is generalised through [`GtCacheRef`] so the same
1447/// kernel feeds both the borrowed batch path
1448/// (`evaluate_boundary_cached`) and the `Arc`-owned streaming path
1449/// ([`Self::with_arc_cache`] + [`crate::stream::StreamingEvaluator`]).
1450///
1451/// Scratch is held in a [`ThreadLocal`] so rayon workers under the
1452/// ADR-0047 parallel path each get their own buffer — the pre-ADR-0047
1453/// `Mutex<Scratch>` shape serialised every per-cell `compute()` call
1454/// across workers, which on val2017 boundary collapsed t=4 scaling to
1455/// ~1.1x. Single-threaded use lazily initialises one slot and pays no
1456/// per-call synchronisation.
1457pub struct BoundaryIouCached<'a> {
1458    dilation_ratio: f64,
1459    scratch: ThreadLocal<RefCell<BoundaryComputeScratch>>,
1460    gt_cache: Option<GtCacheRef<'a, BoundaryGtCache>>,
1461}
1462
1463impl BoundaryIouCached<'static> {
1464    /// Construct a streaming-friendly kernel that owns its GT cache via
1465    /// [`Arc`] (ADR-0020). The kernel is `'static`, so a
1466    /// [`crate::stream::StreamingEvaluator`] can store it across the
1467    /// worker thread's lifetime; the same `Arc` is held by the FFI
1468    /// `CocoDataset` handle, so derivations populated on one path are
1469    /// visible to the other.
1470    ///
1471    /// Aligns the cache to `dilation_ratio` immediately — mismatched
1472    /// ratio invalidates prior bands, mirroring
1473    /// [`evaluate_boundary_cached`]'s contract.
1474    pub fn with_arc_cache(dilation_ratio: f64, cache: Arc<BoundaryGtCache>) -> Self {
1475        cache.align_ratio(dilation_ratio);
1476        Self {
1477            dilation_ratio,
1478            scratch: ThreadLocal::new(),
1479            gt_cache: Some(GtCacheRef::Owned(cache)),
1480        }
1481    }
1482}
1483
1484impl Similarity for BoundaryIouCached<'_> {
1485    type Annotation = SegmAnn;
1486
1487    fn compute(
1488        &self,
1489        gts: &[SegmAnn],
1490        dts: &[SegmAnn],
1491        out: &mut ArrayViewMut2<'_, f64>,
1492    ) -> Result<(), EvalError> {
1493        let cell = self.scratch.get_or_default();
1494        let mut scratch = cell.borrow_mut();
1495        boundary_iou_compute(
1496            self.dilation_ratio,
1497            gts,
1498            dts,
1499            out,
1500            &mut scratch,
1501            self.gt_cache.as_ref().map(GtCacheRef::get),
1502        )
1503    }
1504}
1505
1506impl EvalKernel for BoundaryIouCached<'_> {
1507    fn kind(&self) -> KernelKind {
1508        KernelKind::Boundary
1509    }
1510
1511    fn build_gt_anns(
1512        &self,
1513        gt_anns: &[CocoAnnotation],
1514        indices: &[usize],
1515        image: &ImageMeta,
1516    ) -> Result<Vec<SegmAnn>, EvalError> {
1517        build_segm_gt_anns(gt_anns, indices, image)
1518    }
1519
1520    fn build_dt_anns(
1521        &self,
1522        dt_anns: &[CocoDetection],
1523        indices: &[usize],
1524        image: &ImageMeta,
1525        parity_mode: ParityMode,
1526    ) -> Result<Vec<SegmAnn>, EvalError> {
1527        build_segm_dt_anns(dt_anns, indices, image, parity_mode)
1528    }
1529}
1530
1531/// Run the per-image OKS (`iouType="keypoints"`) evaluation pass per
1532/// ADR-0012. Thin wrapper over [`evaluate_with`] with the
1533/// [`OksSimilarity`] kernel.
1534///
1535/// `sigmas` is the per-category sigma override map consumed by
1536/// [`OksSimilarity::new`]: an empty map means "use
1537/// [`crate::similarity::oks::COCO_PERSON_SIGMAS`] for every category" (quirk **F1**,
1538/// `corrected`). Sigma resolution rules — including the COCO-person
1539/// default and the 17-keypoint length contract — are documented on
1540/// [`OksSimilarity`].
1541///
1542/// ## Caller responsibilities
1543///
1544/// - **Area ranges (quirk D5).** The keypoints-canonical 3-entry grid
1545///   (`all`, `medium`, `large` — pycocotools omits `small`) lives on the
1546///   caller side; pass it through `params.area_ranges`. Reusing the
1547///   detection-canonical 4-entry grid silently introduces an empty
1548///   `small` bucket that diverges from the parity oracle.
1549/// - `params.use_cats=true` is the standard configuration for
1550///   keypoints; per-category sigmas resolve via [`OksSimilarity`]
1551///   regardless.
1552///
1553/// ## Quirks honored here
1554///
1555/// - **D2** (`strict`): GT with zero visible keypoints is treated as an
1556///   implicit ignore region, OR-ed with the dataset-level ignore
1557///   ([`CocoAnnotation::effective_ignore`]) via
1558///   [`EvalKernel::extra_gt_ignore`].
1559/// - **F1**/**F2**/**F3**/**F4**/**F5**: inherited from
1560///   [`OksSimilarity::compute`].
1561///
1562/// GTs and DTs must carry a `keypoints` field; absence raises
1563/// [`EvalError::InvalidAnnotation`]. There is no
1564/// parity-mode-conditional bbox synthesis fallback for keypoints (no
1565/// J2 analog).
1566///
1567/// # Errors
1568///
1569/// Propagates [`EvalError`] from the underlying kernel and matching
1570/// calls.
1571pub fn evaluate_keypoints(
1572    gt: &CocoDataset,
1573    dt: &CocoDetections,
1574    params: EvaluateParams<'_>,
1575    parity_mode: ParityMode,
1576    sigmas: HashMap<i64, Vec<f64>>,
1577) -> Result<EvalGrid, EvalError> {
1578    evaluate_with(gt, dt, params, parity_mode, &OksSimilarity::new(sigmas))
1579}
1580
1581pub(crate) fn gt_indices_for_cell(
1582    gt: &CocoDataset,
1583    image: ImageId,
1584    cat: Option<CategoryId>,
1585) -> &[usize] {
1586    match cat {
1587        Some(c) => gt.ann_indices_for(image, c),
1588        None => gt.ann_indices_for_image(image),
1589    }
1590}
1591
1592/// Raw (un-sorted, un-truncated) DT index slice for a cell. The hot
1593/// loop in [`evaluate_with`] uses this to short-circuit empty cells
1594/// before incurring the score gather + sort cost in
1595/// [`dt_top_indices_for_cell_into`].
1596pub(crate) fn raw_dt_indices_for_cell(
1597    dt: &CocoDetections,
1598    image: ImageId,
1599    cat: Option<CategoryId>,
1600) -> &[usize] {
1601    match cat {
1602        Some(c) => dt.indices_for(image, c),
1603        None => dt.indices_for_image(image),
1604    }
1605}
1606
1607pub(crate) fn dt_top_indices_for_cell(
1608    dt: &CocoDetections,
1609    image: ImageId,
1610    cat: Option<CategoryId>,
1611    max_dets: usize,
1612) -> Vec<usize> {
1613    let raw_indices = raw_dt_indices_for_cell(dt, image, cat);
1614    let mut out = Vec::new();
1615    let mut score_buf = Vec::new();
1616    let mut perm_buf = Vec::new();
1617    dt_top_indices_for_cell_into(
1618        &mut out,
1619        &mut score_buf,
1620        &mut perm_buf,
1621        dt.detections(),
1622        raw_indices,
1623        max_dets,
1624    );
1625    out
1626}
1627
1628/// Allocation-free counterpart to [`dt_top_indices_for_cell`]. Fills
1629/// `out` with the top-`max_dets` DT input indices ordered by descending
1630/// score (stable mergesort, quirk **A1**), reusing `score_buf` and
1631/// `perm_buf` across calls. The hot per-cell loop in [`evaluate_with`]
1632/// would otherwise pay three allocator round-trips per `(image,
1633/// category)` cell — across val2017's 14k non-empty cells that
1634/// dominates the score-sort wall time.
1635pub(crate) fn dt_top_indices_for_cell_into(
1636    out: &mut Vec<usize>,
1637    score_buf: &mut Vec<f64>,
1638    perm_buf: &mut Vec<usize>,
1639    dts: &[CocoDetection],
1640    raw_indices: &[usize],
1641    max_dets: usize,
1642) {
1643    score_buf.clear();
1644    score_buf.extend(raw_indices.iter().map(|&i| dts[i].score));
1645    perm_buf.clear();
1646    perm_buf.extend(0..score_buf.len());
1647    // Stable mergesort tiebreak (quirk A1) — must match
1648    // `argsort_score_desc` semantics bit-for-bit.
1649    perm_buf.sort_by(|&a, &b| {
1650        score_buf[b]
1651            .partial_cmp(&score_buf[a])
1652            .unwrap_or(std::cmp::Ordering::Equal)
1653    });
1654    out.clear();
1655    out.extend(perm_buf.iter().take(max_dets).map(|&k| raw_indices[k]));
1656}
1657
1658/// Per-cell scratch buffers reused across the `(image, category)` loop
1659/// in [`evaluate_with`]. All `Vec` fields are `clear()`-ed and re-grown
1660/// each cell so allocator round-trips are paid once per buffer at most
1661/// (subsequent cells stay within the high-water capacity). On val2017
1662/// this elides ~11 allocations per cell × 14k cells = ~154k allocator
1663/// round-trips.
1664#[derive(Default)]
1665pub(crate) struct CellScratch {
1666    /// Cell-level GT gathers — sized to `gt_indices.len()` per cell.
1667    pub(crate) gt_areas: Vec<f64>,
1668    pub(crate) gt_iscrowd: Vec<bool>,
1669    pub(crate) gt_base_ignore: Vec<bool>,
1670    pub(crate) gt_ids: Vec<i64>,
1671    /// Top-N filtered DT input indices. Filled by
1672    /// [`dt_top_indices_for_cell_into`].
1673    pub(crate) dt_indices: Vec<usize>,
1674    /// Cell-level DT gathers — sized to `dt_indices.len()` per cell.
1675    pub(crate) dt_areas: Vec<f64>,
1676    pub(crate) dt_scores: Vec<f64>,
1677    pub(crate) dt_ids: Vec<i64>,
1678    /// Backing storage for the `(g, d)` IoU matrix. Resized + zeroed
1679    /// per cell; the kernel writes through an `ArrayViewMut2` that
1680    /// borrows this buffer in place.
1681    pub(crate) iou_buf: Vec<f64>,
1682    /// Score gather scratch for [`dt_top_indices_for_cell_into`].
1683    pub(crate) dt_score_buf: Vec<f64>,
1684    /// Permutation scratch for [`dt_top_indices_for_cell_into`].
1685    pub(crate) dt_perm_buf: Vec<usize>,
1686    /// Per-area-range `gt_ignore` mask reused across each call to
1687    /// [`evaluate_cell`] (the four COCO area ranges times every cell —
1688    /// passing through scratch elides one `Vec<bool>` allocation per
1689    /// area-range pass).
1690    pub(crate) gt_ignore_buf: Vec<bool>,
1691}
1692
1693impl CellScratch {
1694    pub(crate) fn new() -> Self {
1695        Self::default()
1696    }
1697}
1698
1699/// Per-worker pool for the kernel-side annotation slices, reused
1700/// across cells. Pays off only when [`EvalKernel::build_gt_anns_into`]
1701/// / [`EvalKernel::build_dt_anns_into`] are overridden — today,
1702/// [`crate::similarity::BboxIou`] only (other kernels' per-cell
1703/// compute swamps the alloc).
1704pub(crate) struct KernelScratch<A> {
1705    pub(crate) gt: Vec<A>,
1706    pub(crate) dt: Vec<A>,
1707}
1708
1709impl<A> Default for KernelScratch<A> {
1710    fn default() -> Self {
1711        Self {
1712            gt: Vec::new(),
1713            dt: Vec::new(),
1714        }
1715    }
1716}
1717
1718/// Area-invariant per-cell buffers shared across every area-range pass.
1719pub(crate) struct CellBuffers<'a> {
1720    pub(crate) image_id: i64,
1721    pub(crate) category_id: i64,
1722    pub(crate) max_det: usize,
1723    pub(crate) gt_areas: &'a [f64],
1724    pub(crate) gt_iscrowd: &'a [bool],
1725    pub(crate) gt_base_ignore: &'a [bool],
1726    pub(crate) gt_ids: &'a [i64],
1727    pub(crate) dt_areas: &'a [f64],
1728    pub(crate) dt_scores: &'a [f64],
1729    pub(crate) dt_ids: &'a [i64],
1730    pub(crate) iou: ArrayView2<'a, f64>,
1731    /// LVIS federated AA3: when `true`, the entire `(image, category)`
1732    /// cell is in `not_exhaustive_category_ids[image]`, so every
1733    /// unmatched DT in the cell gets `dt_ignore = true` (mirrors
1734    /// lvis-api `eval.py:278`). `false` outside LVIS evaluation.
1735    pub(crate) not_exhaustive: bool,
1736}
1737
1738pub(crate) fn evaluate_cell(
1739    gt_ignore_buf: &mut Vec<bool>,
1740    buf: &CellBuffers<'_>,
1741    area: &AreaRange,
1742    iou_thresholds: &[f64],
1743    parity_mode: ParityMode,
1744) -> Result<(PerImageEval, EvalImageMeta), EvalError> {
1745    // D3 + D6/D7: per-call ignore = base | out-of-area. Filled into a
1746    // scratch buffer owned by the caller — this Vec is the same length
1747    // every cell-area pair on a given image, so reusing the allocation
1748    // across all 4 area ranges (and across cells of similar shape)
1749    // amortizes ~14k allocator round-trips on val2017.
1750    gt_ignore_buf.clear();
1751    gt_ignore_buf.extend(
1752        buf.gt_base_ignore
1753            .iter()
1754            .zip(buf.gt_areas)
1755            .map(|(&base, &a)| base || !area.contains(a)),
1756    );
1757    let gt_ignore: &[bool] = gt_ignore_buf.as_slice();
1758
1759    let MatchResult {
1760        dt_perm,
1761        gt_perm,
1762        dt_matches: dt_matches_pos,
1763        gt_matches: gt_matches_pos,
1764        mut dt_ignore,
1765    } = match_image(
1766        buf.iou,
1767        gt_ignore,
1768        buf.gt_iscrowd,
1769        buf.dt_scores,
1770        iou_thresholds,
1771        parity_mode,
1772    )?;
1773
1774    let n_t = iou_thresholds.len();
1775    let n_d = buf.dt_scores.len();
1776    let n_g = gt_ignore.len();
1777
1778    let dt_scores_sorted: Vec<f64> = dt_perm.iter().map(|&k| buf.dt_scores[k]).collect();
1779    let gt_ignore_sorted: Vec<bool> = gt_perm.iter().map(|&k| gt_ignore[k]).collect();
1780    let dt_ids_sorted: Vec<i64> = dt_perm.iter().map(|&k| buf.dt_ids[k]).collect();
1781    let gt_ids_sorted: Vec<i64> = gt_perm.iter().map(|&k| buf.gt_ids[k]).collect();
1782
1783    let mut dt_matched = Array2::<bool>::default((n_t, n_d));
1784    let mut dt_matches_id = Array2::<i64>::zeros((n_t, n_d));
1785    let mut gt_matches_id = Array2::<i64>::zeros((n_t, n_g));
1786    // d-outer / t-inner reorders the original loop so the per-d
1787    // `area.contains(buf.dt_areas[dt_perm[d]])` test runs once per
1788    // detection instead of `n_t` times — dropping the prior
1789    // `dt_in_range_sorted: Vec<bool>` allocation entirely. Writes to
1790    // the three result `Array2`s are independent across `(t, d)`, so
1791    // the reorder is bit-equivalent to the original.
1792    for d in 0..n_d {
1793        let in_range = area.contains(buf.dt_areas[dt_perm[d]]);
1794        for t in 0..n_t {
1795            let m = dt_matches_pos[(t, d)];
1796            let matched = m >= 0;
1797            dt_matched[(t, d)] = matched;
1798            if matched {
1799                dt_matches_id[(t, d)] = gt_ids_sorted[m as usize];
1800            }
1801            // B7: unmatched AND out-of-area → ignore.
1802            // AA3 (LVIS): unmatched in a not_exhaustive cell → ignore.
1803            // Both branches share the same `dt_ignore` field; the
1804            // matching engine never sees the LVIS-specific flag.
1805            if !matched && (!in_range || buf.not_exhaustive) {
1806                dt_ignore[(t, d)] = true;
1807            }
1808        }
1809    }
1810    for t in 0..n_t {
1811        for g in 0..n_g {
1812            let p = gt_matches_pos[(t, g)];
1813            if p >= 0 {
1814                gt_matches_id[(t, g)] = dt_ids_sorted[p as usize];
1815            }
1816        }
1817    }
1818
1819    let cell = PerImageEval {
1820        dt_scores: dt_scores_sorted,
1821        dt_matched,
1822        dt_ignore,
1823        gt_ignore: gt_ignore_sorted,
1824    };
1825    let meta = EvalImageMeta {
1826        image_id: buf.image_id,
1827        category_id: buf.category_id,
1828        area_rng: [area.lo, area.hi],
1829        max_det: buf.max_det,
1830        dt_ids: dt_ids_sorted,
1831        gt_ids: gt_ids_sorted,
1832        dt_matches: dt_matches_id,
1833        gt_matches: gt_matches_id,
1834    };
1835    Ok((cell, meta))
1836}
1837
1838#[cfg(test)]
1839mod tests {
1840    use super::*;
1841    use crate::accumulate::{accumulate, AccumulateParams};
1842    use crate::dataset::{AnnId, Bbox, CategoryMeta, CocoAnnotation, DetectionInput, ImageMeta};
1843    use crate::parity::{iou_thresholds, recall_thresholds};
1844    use crate::summarize::summarize_detection;
1845
1846    fn img(id: i64, w: u32, h: u32) -> ImageMeta {
1847        ImageMeta {
1848            id: ImageId(id),
1849            width: w,
1850            height: h,
1851            file_name: None,
1852        }
1853    }
1854
1855    fn cat(id: i64, name: &str) -> CategoryMeta {
1856        CategoryMeta {
1857            id: CategoryId(id),
1858            name: name.into(),
1859            supercategory: None,
1860        }
1861    }
1862
1863    fn ann(id: i64, image: i64, cat: i64, bbox: (f64, f64, f64, f64)) -> CocoAnnotation {
1864        CocoAnnotation {
1865            id: AnnId(id),
1866            image_id: ImageId(image),
1867            category_id: CategoryId(cat),
1868            area: bbox.2 * bbox.3,
1869            is_crowd: false,
1870            ignore_flag: None,
1871            bbox: Bbox {
1872                x: bbox.0,
1873                y: bbox.1,
1874                w: bbox.2,
1875                h: bbox.3,
1876            },
1877            segmentation: None,
1878            keypoints: None,
1879            num_keypoints: None,
1880        }
1881    }
1882
1883    fn dt_input(image: i64, cat: i64, score: f64, bbox: (f64, f64, f64, f64)) -> DetectionInput {
1884        DetectionInput {
1885            id: None,
1886            image_id: ImageId(image),
1887            category_id: CategoryId(cat),
1888            score,
1889            bbox: Bbox {
1890                x: bbox.0,
1891                y: bbox.1,
1892                w: bbox.2,
1893                h: bbox.3,
1894            },
1895            segmentation: None,
1896            keypoints: None,
1897            num_keypoints: None,
1898        }
1899    }
1900
1901    fn perfect_match_grid() -> EvalGrid {
1902        let images = vec![img(1, 100, 100)];
1903        let cats = vec![cat(1, "thing")];
1904        let anns = vec![
1905            ann(1, 1, 1, (0.0, 0.0, 10.0, 10.0)),
1906            ann(2, 1, 1, (50.0, 50.0, 10.0, 10.0)),
1907        ];
1908        let gt = CocoDataset::from_parts(images, anns, cats).unwrap();
1909        let dts = CocoDetections::from_inputs(vec![
1910            dt_input(1, 1, 0.9, (0.0, 0.0, 10.0, 10.0)),
1911            dt_input(1, 1, 0.8, (50.0, 50.0, 10.0, 10.0)),
1912        ])
1913        .unwrap();
1914        let area = AreaRange::coco_default();
1915        let params = EvaluateParams {
1916            iou_thresholds: iou_thresholds(),
1917            area_ranges: &area,
1918            max_dets_per_image: 100,
1919            use_cats: true,
1920            retain_iou: false,
1921        };
1922        evaluate_bbox(&gt, &dts, params, ParityMode::Strict).unwrap()
1923    }
1924
1925    #[test]
1926    fn d4_coco_default_area_ranges_pin_literal_values() {
1927        // D4: the four COCO buckets are (0, 1e10), (0, 1024),
1928        // (1024, 9216), (9216, 1e10), labelled "all" / "small" /
1929        // "medium" / "large". Pin the literal numbers — the 1e10 sentinel
1930        // and the 32² / 96² boundaries are the parity contract; bumping
1931        // either silently in source would shift bucket membership
1932        // throughout the suite.
1933        let ranges = AreaRange::coco_default();
1934        assert_eq!(ranges.len(), 4);
1935        assert_eq!(
1936            (ranges[0].lo, ranges[0].hi),
1937            (0.0, 1e10),
1938            "all bucket bounds"
1939        );
1940        assert_eq!(
1941            (ranges[1].lo, ranges[1].hi),
1942            (0.0, 1024.0),
1943            "small bucket bounds"
1944        );
1945        assert_eq!(
1946            (ranges[2].lo, ranges[2].hi),
1947            (1024.0, 9216.0),
1948            "medium bucket bounds"
1949        );
1950        assert_eq!(
1951            (ranges[3].lo, ranges[3].hi),
1952            (9216.0, 1e10),
1953            "large bucket bounds"
1954        );
1955
1956        // A-axis indices line up with crate::AreaRng's labelled
1957        // constants. The summarizer keys on `index`, so this is the
1958        // bridge between the orchestrator and the canonical labels.
1959        use crate::summarize::AreaRng;
1960        assert_eq!(ranges[0].index, AreaRng::ALL.index);
1961        assert_eq!(AreaRng::ALL.label.as_ref(), "all");
1962        assert_eq!(ranges[1].index, AreaRng::SMALL.index);
1963        assert_eq!(AreaRng::SMALL.label.as_ref(), "small");
1964        assert_eq!(ranges[2].index, AreaRng::MEDIUM.index);
1965        assert_eq!(AreaRng::MEDIUM.label.as_ref(), "medium");
1966        assert_eq!(ranges[3].index, AreaRng::LARGE.index);
1967        assert_eq!(AreaRng::LARGE.label.as_ref(), "large");
1968
1969        // The 1e10 upper bound is bit-equal to pycocotools' `1e5 ** 2`.
1970        // Pinning the bit pattern guarantees the strict-mode area filter
1971        // makes the same `>` / `<` decisions the Python reference does.
1972        let pyco_unbounded: f64 = 1e5_f64.powi(2);
1973        assert_eq!(pyco_unbounded.to_bits(), 1e10_f64.to_bits());
1974        assert_eq!(ranges[0].hi.to_bits(), 1e10_f64.to_bits());
1975        assert_eq!(ranges[3].hi.to_bits(), 1e10_f64.to_bits());
1976    }
1977
1978    #[test]
1979    fn perfect_match_produces_one_cell_per_area_range() {
1980        let grid = perfect_match_grid();
1981        assert_eq!(grid.n_categories, 1);
1982        assert_eq!(grid.n_area_ranges, 4);
1983        assert_eq!(grid.n_images, 1);
1984        // Both DTs perfectly overlap their GTs → all four area cells exist.
1985        let cells: Vec<_> = grid.eval_imgs.iter().filter(|c| c.is_some()).collect();
1986        assert_eq!(cells.len(), 4);
1987        // The "all" bucket (a=0) has both DTs matched at every threshold.
1988        let all_cell = grid.cell(0, 0, 0).unwrap();
1989        assert_eq!(all_cell.dt_scores.len(), 2);
1990        assert!(all_cell.dt_matched.iter().all(|&m| m));
1991        assert!(all_cell.dt_ignore.iter().all(|&ig| !ig));
1992    }
1993
1994    #[test]
1995    fn perfect_match_summarizes_to_one() {
1996        let grid = perfect_match_grid();
1997        let max_dets = vec![1usize, 10, 100];
1998        let acc = accumulate(
1999            &grid.eval_imgs,
2000            AccumulateParams {
2001                iou_thresholds: iou_thresholds(),
2002                recall_thresholds: recall_thresholds(),
2003                max_dets: &max_dets,
2004                n_categories: grid.n_categories,
2005                n_area_ranges: grid.n_area_ranges,
2006                n_images: grid.n_images,
2007            },
2008            ParityMode::Strict,
2009        )
2010        .unwrap();
2011        let summary = summarize_detection(&acc, iou_thresholds(), &max_dets).unwrap();
2012        let stats = summary.stats();
2013        // GTs are 10x10 → area 100, which falls inside `small` (< 32²)
2014        // and `all`. `medium` and `large` see no in-range GTs, so AP and
2015        // AR collapse to the -1 sentinel (quirk C5).
2016        assert!((stats[0] - 1.0).abs() < 1e-12, "AP={}", stats[0]);
2017        assert!((stats[3] - 1.0).abs() < 1e-12, "AP_S={}", stats[3]);
2018        assert_eq!(stats[4], -1.0, "AP_M should be -1 with no medium GTs");
2019        assert_eq!(stats[5], -1.0, "AP_L should be -1 with no large GTs");
2020        assert!((stats[8] - 1.0).abs() < 1e-12, "AR@100={}", stats[8]);
2021    }
2022
2023    #[test]
2024    fn b7_unmatched_dt_outside_area_range_is_ignored() {
2025        // GT and DT both 200x200 (40000 area, "large" bucket). The
2026        // small-area cell (a=1, range [0, 32²)) sees the GT as ignored
2027        // (D6/D7) and the unmatched DT as ignored (B7).
2028        let images = vec![img(1, 300, 300)];
2029        let cats = vec![cat(1, "thing")];
2030        let anns = vec![ann(1, 1, 1, (0.0, 0.0, 200.0, 200.0))];
2031        let gt = CocoDataset::from_parts(images, anns, cats).unwrap();
2032        let dts =
2033            CocoDetections::from_inputs(vec![dt_input(1, 1, 0.5, (200.0, 200.0, 50.0, 50.0))])
2034                .unwrap();
2035        let area = AreaRange::coco_default();
2036        let params = EvaluateParams {
2037            iou_thresholds: iou_thresholds(),
2038            area_ranges: &area,
2039            max_dets_per_image: 100,
2040            use_cats: true,
2041            retain_iou: false,
2042        };
2043        let grid = evaluate_bbox(&gt, &dts, params, ParityMode::Strict).unwrap();
2044        let small = grid.cell(0, 1, 0).unwrap();
2045        // GT is out-of-area, so gt_ignore=true.
2046        assert_eq!(small.gt_ignore, vec![true]);
2047        // DT is unmatched (no IoU with GT) AND out-of-area → B7 sets ignore.
2048        assert!(small.dt_ignore.iter().all(|&ig| ig));
2049        assert!(small.dt_matched.iter().all(|&m| !m));
2050    }
2051
2052    #[test]
2053    fn d6_boundary_area_lands_in_both_buckets() {
2054        // D6 (strict): pycocotools (cocoeval.py:251) uses non-strict
2055        // inclusion on both ends, so a GT/DT with area exactly equal to a
2056        // bucket boundary (32² = 1024) lands in *both* adjacent buckets.
2057        let images = vec![img(1, 100, 100)];
2058        let cats = vec![cat(1, "thing")];
2059        // 32x32 → area 1024 exactly.
2060        let anns = vec![ann(1, 1, 1, (0.0, 0.0, 32.0, 32.0))];
2061        let gt = CocoDataset::from_parts(images, anns, cats).unwrap();
2062        let dts =
2063            CocoDetections::from_inputs(vec![dt_input(1, 1, 0.5, (0.0, 0.0, 32.0, 32.0))]).unwrap();
2064        let area = AreaRange::coco_default();
2065        let params = EvaluateParams {
2066            iou_thresholds: iou_thresholds(),
2067            area_ranges: &area,
2068            max_dets_per_image: 100,
2069            use_cats: true,
2070            retain_iou: false,
2071        };
2072        let grid = evaluate_bbox(&gt, &dts, params, ParityMode::Strict).unwrap();
2073        // small (lo=0, hi=32²=1024): area 1024 == hi → included.
2074        let small = grid.cell(0, 1, 0).unwrap();
2075        assert_eq!(small.gt_ignore, vec![false]);
2076        // medium (lo=1024, hi=96²=9216): area 1024 == lo → included.
2077        let medium = grid.cell(0, 2, 0).unwrap();
2078        assert_eq!(medium.gt_ignore, vec![false]);
2079        // all (lo=0, hi=1e10): area 1024 lies inside.
2080        let all = grid.cell(0, 0, 0).unwrap();
2081        assert_eq!(all.gt_ignore, vec![false]);
2082        // large (lo=96²=9216, hi=1e10): area 1024 < 9216 → ignored.
2083        let large = grid.cell(0, 3, 0).unwrap();
2084        assert_eq!(large.gt_ignore, vec![true]);
2085    }
2086
2087    #[test]
2088    fn l4_use_cats_false_collapses_categories() {
2089        let images = vec![img(1, 100, 100)];
2090        let cats = vec![cat(1, "a"), cat(2, "b")];
2091        let anns = vec![
2092            ann(1, 1, 1, (0.0, 0.0, 10.0, 10.0)),
2093            ann(2, 1, 2, (50.0, 50.0, 10.0, 10.0)),
2094        ];
2095        let gt = CocoDataset::from_parts(images, anns, cats).unwrap();
2096        // DT with category=1 overlapping the cat-2 GT — only matches
2097        // when use_cats=false.
2098        let dts = CocoDetections::from_inputs(vec![dt_input(1, 1, 0.9, (50.0, 50.0, 10.0, 10.0))])
2099            .unwrap();
2100        let area = AreaRange::coco_default();
2101        let params = EvaluateParams {
2102            iou_thresholds: iou_thresholds(),
2103            area_ranges: &area,
2104            max_dets_per_image: 100,
2105            use_cats: false,
2106            retain_iou: false,
2107        };
2108        let grid = evaluate_bbox(&gt, &dts, params, ParityMode::Strict).unwrap();
2109        assert_eq!(grid.n_categories, 1);
2110        let all = grid.cell(0, 0, 0).unwrap();
2111        // Both GTs land in the single bucket; the DT matches the second.
2112        assert_eq!(all.gt_ignore.len(), 2);
2113        assert_eq!(all.dt_scores.len(), 1);
2114        assert!(all.dt_matched.iter().all(|&m| m));
2115    }
2116
2117    #[test]
2118    fn max_dets_per_image_caps_top_n_by_score() {
2119        let images = vec![img(1, 100, 100)];
2120        let cats = vec![cat(1, "thing")];
2121        let anns = vec![ann(1, 1, 1, (0.0, 0.0, 10.0, 10.0))];
2122        let gt = CocoDataset::from_parts(images, anns, cats).unwrap();
2123        let dts = CocoDetections::from_inputs(vec![
2124            dt_input(1, 1, 0.1, (50.0, 50.0, 5.0, 5.0)),
2125            dt_input(1, 1, 0.9, (0.0, 0.0, 10.0, 10.0)),
2126            dt_input(1, 1, 0.5, (50.0, 50.0, 5.0, 5.0)),
2127        ])
2128        .unwrap();
2129        let area = AreaRange::coco_default();
2130        let params = EvaluateParams {
2131            iou_thresholds: iou_thresholds(),
2132            area_ranges: &area,
2133            max_dets_per_image: 2,
2134            use_cats: true,
2135            retain_iou: false,
2136        };
2137        let grid = evaluate_bbox(&gt, &dts, params, ParityMode::Strict).unwrap();
2138        let all = grid.cell(0, 0, 0).unwrap();
2139        // Only the top-2 by score survive the cap.
2140        assert_eq!(all.dt_scores.len(), 2);
2141        assert_eq!(all.dt_scores[0], 0.9);
2142        assert_eq!(all.dt_scores[1], 0.5);
2143    }
2144
2145    #[test]
2146    fn d1_parity_mode_propagates_to_base_ignore() {
2147        // GT with iscrowd=0 and explicit ignore=1.
2148        // Strict (pycocotools): ignore := iscrowd → false, the GT
2149        // counts and the matching DT scores a TP.
2150        // Corrected: respects user's ignore=1 → true, the GT becomes
2151        // ignored and the DT picks it up via B6 (dt_ignore=true).
2152        const ANN_JSON: &str = r#"{
2153            "images": [{"id": 1, "width": 100, "height": 100}],
2154            "annotations": [
2155                {"id": 1, "image_id": 1, "category_id": 1,
2156                 "bbox": [0, 0, 10, 10], "area": 100,
2157                 "iscrowd": 0, "ignore": 1}
2158            ],
2159            "categories": [{"id": 1, "name": "thing"}]
2160        }"#;
2161        let gt = CocoDataset::from_json_bytes(ANN_JSON.as_bytes()).unwrap();
2162        let dts =
2163            CocoDetections::from_inputs(vec![dt_input(1, 1, 0.9, (0.0, 0.0, 10.0, 10.0))]).unwrap();
2164        let area = AreaRange::coco_default();
2165        let params = EvaluateParams {
2166            iou_thresholds: iou_thresholds(),
2167            area_ranges: &area,
2168            max_dets_per_image: 100,
2169            use_cats: true,
2170            retain_iou: false,
2171        };
2172
2173        let strict = evaluate_bbox(&gt, &dts, params, ParityMode::Strict).unwrap();
2174        let strict_all = strict.cell(0, 0, 0).unwrap();
2175        assert_eq!(strict_all.gt_ignore, vec![false]);
2176        assert!(strict_all.dt_ignore.iter().all(|&ig| !ig));
2177
2178        let corrected = evaluate_bbox(&gt, &dts, params, ParityMode::Corrected).unwrap();
2179        let corrected_all = corrected.cell(0, 0, 0).unwrap();
2180        assert_eq!(corrected_all.gt_ignore, vec![true]);
2181        // DT matched the now-ignored GT → B6 inherits the ignore flag.
2182        assert!(corrected_all.dt_ignore.iter().all(|&ig| ig));
2183    }
2184
2185    #[test]
2186    fn cell_meta_carries_pycocotools_shape() {
2187        let grid = perfect_match_grid();
2188        // The "all" bucket sees both DTs matched.
2189        let meta = grid.cell_meta(0, 0, 0).unwrap();
2190        assert_eq!(meta.image_id, 1);
2191        assert_eq!(meta.category_id, 1);
2192        assert_eq!(meta.area_rng, [0.0, AREA_UNBOUNDED]);
2193        assert_eq!(meta.max_det, 100);
2194        // DTs sorted score-desc: id=1 (score 0.9) before id=2 (score 0.8).
2195        assert_eq!(meta.dt_ids, vec![1, 2]);
2196        // GTs sorted ignore-asc: both non-ignore, stable order preserved.
2197        assert_eq!(meta.gt_ids, vec![1, 2]);
2198        let n_t = iou_thresholds().len();
2199        assert_eq!(meta.dt_matches.shape(), &[n_t, 2]);
2200        assert_eq!(meta.gt_matches.shape(), &[n_t, 2]);
2201        // dt_matches carries the matched GT id (or 0); both DTs perfectly
2202        // overlap their same-position GT at every threshold.
2203        for t in 0..n_t {
2204            assert_eq!(meta.dt_matches[(t, 0)], 1, "dt[0] -> gt[1] at t={t}");
2205            assert_eq!(meta.dt_matches[(t, 1)], 2, "dt[1] -> gt[2] at t={t}");
2206            assert_eq!(meta.gt_matches[(t, 0)], 1, "gt[1] -> dt[1] at t={t}");
2207            assert_eq!(meta.gt_matches[(t, 1)], 2, "gt[2] -> dt[2] at t={t}");
2208        }
2209    }
2210
2211    #[test]
2212    fn cell_meta_unmatched_dt_uses_zero_sentinel() {
2213        // Single GT, single DT with no overlap → unmatched at every threshold.
2214        let images = vec![img(1, 100, 100)];
2215        let cats = vec![cat(1, "thing")];
2216        let anns = vec![ann(7, 1, 1, (0.0, 0.0, 10.0, 10.0))];
2217        let gt = CocoDataset::from_parts(images, anns, cats).unwrap();
2218        let dts = CocoDetections::from_inputs(vec![dt_input(1, 1, 0.5, (50.0, 50.0, 10.0, 10.0))])
2219            .unwrap();
2220        let area = AreaRange::coco_default();
2221        let params = EvaluateParams {
2222            iou_thresholds: iou_thresholds(),
2223            area_ranges: &area,
2224            max_dets_per_image: 100,
2225            use_cats: true,
2226            retain_iou: false,
2227        };
2228        let grid = evaluate_bbox(&gt, &dts, params, ParityMode::Strict).unwrap();
2229        let meta = grid.cell_meta(0, 0, 0).unwrap();
2230        assert_eq!(meta.gt_ids, vec![7]);
2231        // Auto-assigned DT id starts at 1 (first detection).
2232        assert_eq!(meta.dt_ids.len(), 1);
2233        assert!(meta.dt_matches.iter().all(|&x| x == 0));
2234        assert!(meta.gt_matches.iter().all(|&x| x == 0));
2235    }
2236
2237    #[test]
2238    fn cell_meta_use_cats_false_emits_sentinel_category() {
2239        let images = vec![img(1, 100, 100)];
2240        let cats = vec![cat(1, "a"), cat(2, "b")];
2241        let anns = vec![ann(1, 1, 1, (0.0, 0.0, 10.0, 10.0))];
2242        let gt = CocoDataset::from_parts(images, anns, cats).unwrap();
2243        let dts =
2244            CocoDetections::from_inputs(vec![dt_input(1, 1, 0.9, (0.0, 0.0, 10.0, 10.0))]).unwrap();
2245        let area = AreaRange::coco_default();
2246        let params = EvaluateParams {
2247            iou_thresholds: iou_thresholds(),
2248            area_ranges: &area,
2249            max_dets_per_image: 100,
2250            use_cats: false,
2251            retain_iou: false,
2252        };
2253        let grid = evaluate_bbox(&gt, &dts, params, ParityMode::Strict).unwrap();
2254        let meta = grid.cell_meta(0, 0, 0).unwrap();
2255        assert_eq!(meta.category_id, COLLAPSED_CATEGORY_SENTINEL);
2256    }
2257
2258    #[test]
2259    fn missing_dt_image_yields_none_cells() {
2260        // Pycocotools' `evaluateImg` returns a record (not None) when
2261        // GTs exist but DTs do not — vernier matches that.
2262        let images = vec![img(1, 100, 100), img(2, 100, 100)];
2263        let cats = vec![cat(1, "thing")];
2264        let anns = vec![ann(1, 1, 1, (0.0, 0.0, 10.0, 10.0))];
2265        let gt = CocoDataset::from_parts(images, anns, cats).unwrap();
2266        let dts = CocoDetections::from_inputs(vec![]).unwrap();
2267        let area = AreaRange::coco_default();
2268        let params = EvaluateParams {
2269            iou_thresholds: iou_thresholds(),
2270            area_ranges: &area,
2271            max_dets_per_image: 100,
2272            use_cats: true,
2273            retain_iou: false,
2274        };
2275        let grid = evaluate_bbox(&gt, &dts, params, ParityMode::Strict).unwrap();
2276        for a in 0..4 {
2277            assert!(grid.cell(0, a, 0).is_some(), "image 1 area {a}");
2278            assert!(grid.cell(0, a, 1).is_none(), "image 2 area {a}");
2279        }
2280    }
2281
2282    fn square_polygon(x: f64, y: f64, side: f64) -> Segmentation {
2283        Segmentation::Polygons(vec![vec![
2284            x,
2285            y,
2286            x + side,
2287            y,
2288            x + side,
2289            y + side,
2290            x,
2291            y + side,
2292        ]])
2293    }
2294
2295    fn ann_with_segm(
2296        id: i64,
2297        image: i64,
2298        cat: i64,
2299        bbox: (f64, f64, f64, f64),
2300        segm: Segmentation,
2301    ) -> CocoAnnotation {
2302        CocoAnnotation {
2303            id: AnnId(id),
2304            image_id: ImageId(image),
2305            category_id: CategoryId(cat),
2306            area: bbox.2 * bbox.3,
2307            is_crowd: false,
2308            ignore_flag: None,
2309            bbox: Bbox {
2310                x: bbox.0,
2311                y: bbox.1,
2312                w: bbox.2,
2313                h: bbox.3,
2314            },
2315            segmentation: Some(segm),
2316            keypoints: None,
2317            num_keypoints: None,
2318        }
2319    }
2320
2321    fn dt_input_with_segm(
2322        image: i64,
2323        cat: i64,
2324        score: f64,
2325        bbox: (f64, f64, f64, f64),
2326        segm: Segmentation,
2327    ) -> DetectionInput {
2328        DetectionInput {
2329            id: None,
2330            image_id: ImageId(image),
2331            category_id: CategoryId(cat),
2332            score,
2333            bbox: Bbox {
2334                x: bbox.0,
2335                y: bbox.1,
2336                w: bbox.2,
2337                h: bbox.3,
2338            },
2339            segmentation: Some(segm),
2340            keypoints: None,
2341            num_keypoints: None,
2342        }
2343    }
2344
2345    #[test]
2346    fn segm_perfect_overlap_summarizes_to_one() {
2347        let images = vec![img(1, 100, 100)];
2348        let cats = vec![cat(1, "thing")];
2349        let anns = vec![ann_with_segm(
2350            1,
2351            1,
2352            1,
2353            (10.0, 10.0, 20.0, 20.0),
2354            square_polygon(10.0, 10.0, 20.0),
2355        )];
2356        let gt = CocoDataset::from_parts(images, anns, cats).unwrap();
2357        let dts = CocoDetections::from_inputs(vec![dt_input_with_segm(
2358            1,
2359            1,
2360            0.9,
2361            (10.0, 10.0, 20.0, 20.0),
2362            square_polygon(10.0, 10.0, 20.0),
2363        )])
2364        .unwrap();
2365        let area = AreaRange::coco_default();
2366        let params = EvaluateParams {
2367            iou_thresholds: iou_thresholds(),
2368            area_ranges: &area,
2369            max_dets_per_image: 100,
2370            use_cats: true,
2371            retain_iou: false,
2372        };
2373        let grid = evaluate_segm(&gt, &dts, params, ParityMode::Strict).unwrap();
2374        let max_dets = vec![1usize, 10, 100];
2375        let acc = accumulate(
2376            &grid.eval_imgs,
2377            AccumulateParams {
2378                iou_thresholds: iou_thresholds(),
2379                recall_thresholds: recall_thresholds(),
2380                max_dets: &max_dets,
2381                n_categories: grid.n_categories,
2382                n_area_ranges: grid.n_area_ranges,
2383                n_images: grid.n_images,
2384            },
2385            ParityMode::Strict,
2386        )
2387        .unwrap();
2388        let summary = summarize_detection(&acc, iou_thresholds(), &max_dets).unwrap();
2389        let stats = summary.stats();
2390        assert!((stats[0] - 1.0).abs() < 1e-12, "AP={}", stats[0]);
2391    }
2392
2393    #[test]
2394    fn segm_disjoint_masks_summarize_to_zero() {
2395        let images = vec![img(1, 100, 100)];
2396        let cats = vec![cat(1, "thing")];
2397        let anns = vec![ann_with_segm(
2398            1,
2399            1,
2400            1,
2401            (0.0, 0.0, 10.0, 10.0),
2402            square_polygon(0.0, 0.0, 10.0),
2403        )];
2404        let gt = CocoDataset::from_parts(images, anns, cats).unwrap();
2405        let dts = CocoDetections::from_inputs(vec![dt_input_with_segm(
2406            1,
2407            1,
2408            0.9,
2409            (50.0, 50.0, 10.0, 10.0),
2410            square_polygon(50.0, 50.0, 10.0),
2411        )])
2412        .unwrap();
2413        let area = AreaRange::coco_default();
2414        let params = EvaluateParams {
2415            iou_thresholds: iou_thresholds(),
2416            area_ranges: &area,
2417            max_dets_per_image: 100,
2418            use_cats: true,
2419            retain_iou: false,
2420        };
2421        let grid = evaluate_segm(&gt, &dts, params, ParityMode::Strict).unwrap();
2422        let all = grid.cell(0, 0, 0).unwrap();
2423        // No overlap → no match at any threshold.
2424        assert!(all.dt_matched.iter().all(|&m| !m));
2425    }
2426
2427    #[test]
2428    fn segm_missing_gt_segmentation_surfaces_typed_error() {
2429        // GT has no `segmentation` field; running segm eval against it
2430        // must surface InvalidAnnotation, not silently treat as empty.
2431        let images = vec![img(1, 100, 100)];
2432        let cats = vec![cat(1, "thing")];
2433        let anns = vec![ann(7, 1, 1, (0.0, 0.0, 10.0, 10.0))];
2434        let gt = CocoDataset::from_parts(images, anns, cats).unwrap();
2435        let dts = CocoDetections::from_inputs(vec![dt_input_with_segm(
2436            1,
2437            1,
2438            0.9,
2439            (0.0, 0.0, 10.0, 10.0),
2440            square_polygon(0.0, 0.0, 10.0),
2441        )])
2442        .unwrap();
2443        let area = AreaRange::coco_default();
2444        let params = EvaluateParams {
2445            iou_thresholds: iou_thresholds(),
2446            area_ranges: &area,
2447            max_dets_per_image: 100,
2448            use_cats: true,
2449            retain_iou: false,
2450        };
2451        let err = evaluate_segm(&gt, &dts, params, ParityMode::Strict).unwrap_err();
2452        match err {
2453            EvalError::InvalidAnnotation { detail } => {
2454                assert!(detail.contains("GT id=7"), "msg: {detail}");
2455            }
2456            other => panic!("expected InvalidAnnotation, got {other:?}"),
2457        }
2458    }
2459
2460    #[test]
2461    fn j2_bbox_only_dt_under_segm_iou_type_raises_in_corrected_mode() {
2462        // Quirk J2 (`corrected`): vernier refuses to silently coerce a
2463        // bbox-only DT into a rectangle mask under iouType="segm". The
2464        // typed error cites the offending DT id and image.
2465        let images = vec![img(1, 100, 100)];
2466        let cats = vec![cat(1, "thing")];
2467        let anns = vec![ann_with_segm(
2468            1,
2469            1,
2470            1,
2471            (0.0, 0.0, 10.0, 10.0),
2472            square_polygon(0.0, 0.0, 10.0),
2473        )];
2474        let gt = CocoDataset::from_parts(images, anns, cats).unwrap();
2475        // DT without a segmentation field — only bbox.
2476        let dts =
2477            CocoDetections::from_inputs(vec![dt_input(1, 1, 0.9, (0.0, 0.0, 10.0, 10.0))]).unwrap();
2478        let area = AreaRange::coco_default();
2479        let params = EvaluateParams {
2480            iou_thresholds: iou_thresholds(),
2481            area_ranges: &area,
2482            max_dets_per_image: 100,
2483            use_cats: true,
2484            retain_iou: false,
2485        };
2486        let err = evaluate_segm(&gt, &dts, params, ParityMode::Corrected).unwrap_err();
2487        match err {
2488            EvalError::InvalidAnnotation { detail } => {
2489                assert!(detail.contains("DT"), "expected DT in msg: {detail}");
2490                assert!(detail.contains("J2"), "expected J2 cite in msg: {detail}");
2491            }
2492            other => panic!("expected InvalidAnnotation, got {other:?}"),
2493        }
2494    }
2495
2496    #[test]
2497    fn j2_bbox_only_dt_under_segm_iou_type_synthesizes_in_strict_mode() {
2498        // Quirk J2 (`strict`): pycocotools/coco.py:341 synthesizes a
2499        // 4-point rectangle polygon `[[x1,y1, x1,y2, x2,y2, x2,y1]]`
2500        // from the DT bbox and rasterizes it. A GT polygon perfectly
2501        // covering the same rectangle therefore IoU=1 against the
2502        // synthesized DT mask.
2503        let images = vec![img(1, 100, 100)];
2504        let cats = vec![cat(1, "thing")];
2505        // GT polygon covers a 10×10 square at (0, 0).
2506        let anns = vec![ann_with_segm(
2507            1,
2508            1,
2509            1,
2510            (0.0, 0.0, 10.0, 10.0),
2511            square_polygon(0.0, 0.0, 10.0),
2512        )];
2513        let gt = CocoDataset::from_parts(images, anns, cats).unwrap();
2514        // DT bbox covers the same rectangle but carries no `segmentation`.
2515        let dts =
2516            CocoDetections::from_inputs(vec![dt_input(1, 1, 0.9, (0.0, 0.0, 10.0, 10.0))]).unwrap();
2517        let area = AreaRange::coco_default();
2518        let params = EvaluateParams {
2519            iou_thresholds: iou_thresholds(),
2520            area_ranges: &area,
2521            max_dets_per_image: 100,
2522            use_cats: true,
2523            retain_iou: false,
2524        };
2525        let grid = evaluate_segm(&gt, &dts, params, ParityMode::Strict).unwrap();
2526        let all = grid.cell(0, 0, 0).unwrap();
2527        // Synthesized rectangle exactly covers the GT polygon → match
2528        // at every threshold.
2529        assert!(all.dt_matched.iter().all(|&m| m), "expected matches");
2530    }
2531
2532    #[test]
2533    fn j6_heterogeneous_dt_list_first_with_segm_second_without_raises_in_corrected_mode() {
2534        // Quirk J6 (`corrected`): per-entry dispatch. A heterogeneous DT
2535        // list under iouType="segm" — DT[0] carries a `segmentation`,
2536        // DT[1] does not — is rejected up-front in corrected mode rather
2537        // than silently routed through pycocotools' first-entry-decides
2538        // dispatch (`coco.py:330-363`). Verifies that vernier inspects
2539        // each entry independently rather than dispatching from `anns[0]`.
2540        let images = vec![img(1, 100, 100)];
2541        let cats = vec![cat(1, "thing")];
2542        let anns = vec![ann_with_segm(
2543            1,
2544            1,
2545            1,
2546            (0.0, 0.0, 10.0, 10.0),
2547            square_polygon(0.0, 0.0, 10.0),
2548        )];
2549        let gt = CocoDataset::from_parts(images, anns, cats).unwrap();
2550        // DT[0] has segm, DT[1] does not. pycocotools' first-entry
2551        // dispatch would route into the segm path on `anns[0]`, then
2552        // crash on `anns[1]` reading `ann['segmentation']`. vernier
2553        // raises InvalidAnnotation pinpointing the offending entry.
2554        let dts = CocoDetections::from_inputs(vec![
2555            dt_input_with_segm(
2556                1,
2557                1,
2558                0.9,
2559                (0.0, 0.0, 10.0, 10.0),
2560                square_polygon(0.0, 0.0, 10.0),
2561            ),
2562            dt_input(1, 1, 0.8, (50.0, 50.0, 10.0, 10.0)),
2563        ])
2564        .unwrap();
2565        let area = AreaRange::coco_default();
2566        let params = EvaluateParams {
2567            iou_thresholds: iou_thresholds(),
2568            area_ranges: &area,
2569            max_dets_per_image: 100,
2570            use_cats: true,
2571            retain_iou: false,
2572        };
2573        let err = evaluate_segm(&gt, &dts, params, ParityMode::Corrected).unwrap_err();
2574        assert!(matches!(err, EvalError::InvalidAnnotation { .. }));
2575    }
2576
2577    #[test]
2578    fn j6_heterogeneous_dt_list_first_without_segm_second_with_raises_in_corrected_mode() {
2579        // Mirror of the previous test with the order reversed. If the
2580        // dispatch were first-entry-decides (the pycocotools quirk J6
2581        // documents), DT[0] without `segmentation` would route to a
2582        // bbox-synthesis path and DT[1]'s segm would be ignored. Vernier
2583        // inspects every entry: missing segm anywhere in corrected mode
2584        // raises.
2585        let images = vec![img(1, 100, 100)];
2586        let cats = vec![cat(1, "thing")];
2587        let anns = vec![ann_with_segm(
2588            1,
2589            1,
2590            1,
2591            (0.0, 0.0, 10.0, 10.0),
2592            square_polygon(0.0, 0.0, 10.0),
2593        )];
2594        let gt = CocoDataset::from_parts(images, anns, cats).unwrap();
2595        let dts = CocoDetections::from_inputs(vec![
2596            dt_input(1, 1, 0.9, (0.0, 0.0, 10.0, 10.0)),
2597            dt_input_with_segm(
2598                1,
2599                1,
2600                0.8,
2601                (50.0, 50.0, 10.0, 10.0),
2602                square_polygon(50.0, 50.0, 10.0),
2603            ),
2604        ])
2605        .unwrap();
2606        let area = AreaRange::coco_default();
2607        let params = EvaluateParams {
2608            iou_thresholds: iou_thresholds(),
2609            area_ranges: &area,
2610            max_dets_per_image: 100,
2611            use_cats: true,
2612            retain_iou: false,
2613        };
2614        let err = evaluate_segm(&gt, &dts, params, ParityMode::Corrected).unwrap_err();
2615        assert!(matches!(err, EvalError::InvalidAnnotation { .. }));
2616    }
2617
2618    #[test]
2619    fn j6_heterogeneous_dt_list_in_strict_mode_synthesizes_per_entry() {
2620        // Quirk J2 (`strict`) layered with J6: per-entry dispatch under
2621        // strict mode means DTs without `segmentation` get the
2622        // bbox→polygon synthesis (matching pycocotools), while DTs with
2623        // a `segmentation` keep theirs. No first-entry-decides
2624        // global dispatch — every entry is handled independently.
2625        let images = vec![img(1, 100, 100)];
2626        let cats = vec![cat(1, "thing")];
2627        let anns = vec![
2628            ann_with_segm(
2629                1,
2630                1,
2631                1,
2632                (0.0, 0.0, 10.0, 10.0),
2633                square_polygon(0.0, 0.0, 10.0),
2634            ),
2635            ann_with_segm(
2636                2,
2637                1,
2638                1,
2639                (50.0, 50.0, 10.0, 10.0),
2640                square_polygon(50.0, 50.0, 10.0),
2641            ),
2642        ];
2643        let gt = CocoDataset::from_parts(images, anns, cats).unwrap();
2644        // DT[0] has segm covering GT[0]; DT[1] has only bbox covering GT[1].
2645        let dts = CocoDetections::from_inputs(vec![
2646            dt_input_with_segm(
2647                1,
2648                1,
2649                0.9,
2650                (0.0, 0.0, 10.0, 10.0),
2651                square_polygon(0.0, 0.0, 10.0),
2652            ),
2653            dt_input(1, 1, 0.8, (50.0, 50.0, 10.0, 10.0)),
2654        ])
2655        .unwrap();
2656        let area = AreaRange::coco_default();
2657        let params = EvaluateParams {
2658            iou_thresholds: iou_thresholds(),
2659            area_ranges: &area,
2660            max_dets_per_image: 100,
2661            use_cats: true,
2662            retain_iou: false,
2663        };
2664        let grid = evaluate_segm(&gt, &dts, params, ParityMode::Strict).unwrap();
2665        let all = grid.cell(0, 0, 0).unwrap();
2666        // Both DTs match their respective GTs (DT[1] via synthesized
2667        // rectangle), so every threshold sees both as TPs.
2668        assert_eq!(all.dt_matched.shape(), &[iou_thresholds().len(), 2]);
2669        assert!(all.dt_matched.iter().all(|&m| m));
2670    }
2671
2672    #[test]
2673    fn boundary_perfect_overlap_summarizes_to_one() {
2674        // Pins the wrapper end-to-end (kernel → grid → accumulate →
2675        // summarize) at AP=1; a regression in any stage trips this.
2676        let images = vec![img(1, 100, 100)];
2677        let cats = vec![cat(1, "thing")];
2678        let anns = vec![ann_with_segm(
2679            1,
2680            1,
2681            1,
2682            (10.0, 10.0, 20.0, 20.0),
2683            square_polygon(10.0, 10.0, 20.0),
2684        )];
2685        let gt = CocoDataset::from_parts(images, anns, cats).unwrap();
2686        let dts = CocoDetections::from_inputs(vec![dt_input_with_segm(
2687            1,
2688            1,
2689            0.9,
2690            (10.0, 10.0, 20.0, 20.0),
2691            square_polygon(10.0, 10.0, 20.0),
2692        )])
2693        .unwrap();
2694        let area = AreaRange::coco_default();
2695        let params = EvaluateParams {
2696            iou_thresholds: iou_thresholds(),
2697            area_ranges: &area,
2698            max_dets_per_image: 100,
2699            use_cats: true,
2700            retain_iou: false,
2701        };
2702        let grid = evaluate_boundary(&gt, &dts, params, ParityMode::Strict, 0.02).unwrap();
2703        let max_dets = vec![1usize, 10, 100];
2704        let acc = accumulate(
2705            &grid.eval_imgs,
2706            AccumulateParams {
2707                iou_thresholds: iou_thresholds(),
2708                recall_thresholds: recall_thresholds(),
2709                max_dets: &max_dets,
2710                n_categories: grid.n_categories,
2711                n_area_ranges: grid.n_area_ranges,
2712                n_images: grid.n_images,
2713            },
2714            ParityMode::Strict,
2715        )
2716        .unwrap();
2717        let summary = summarize_detection(&acc, iou_thresholds(), &max_dets).unwrap();
2718        let stats = summary.stats();
2719        assert!((stats[0] - 1.0).abs() < 1e-12, "AP={}", stats[0]);
2720    }
2721
2722    #[test]
2723    fn boundary_disjoint_masks_summarize_to_zero() {
2724        // Disjoint masks → bbox prefilter zeros the cell; no match at
2725        // any threshold.
2726        let images = vec![img(1, 100, 100)];
2727        let cats = vec![cat(1, "thing")];
2728        let anns = vec![ann_with_segm(
2729            1,
2730            1,
2731            1,
2732            (0.0, 0.0, 10.0, 10.0),
2733            square_polygon(0.0, 0.0, 10.0),
2734        )];
2735        let gt = CocoDataset::from_parts(images, anns, cats).unwrap();
2736        let dts = CocoDetections::from_inputs(vec![dt_input_with_segm(
2737            1,
2738            1,
2739            0.9,
2740            (50.0, 50.0, 10.0, 10.0),
2741            square_polygon(50.0, 50.0, 10.0),
2742        )])
2743        .unwrap();
2744        let area = AreaRange::coco_default();
2745        let params = EvaluateParams {
2746            iou_thresholds: iou_thresholds(),
2747            area_ranges: &area,
2748            max_dets_per_image: 100,
2749            use_cats: true,
2750            retain_iou: false,
2751        };
2752        let grid = evaluate_boundary(&gt, &dts, params, ParityMode::Strict, 0.02).unwrap();
2753        let all = grid.cell(0, 0, 0).unwrap();
2754        assert!(all.dt_matched.iter().all(|&m| !m));
2755    }
2756
2757    /// Two-image, two-category fixture exercised by the cache tests
2758    /// below. Returns gt + two distinct DT sets so a "second eval" is
2759    /// genuinely the same GT against fresh DTs (the training-loop
2760    /// validation pattern the cache is for).
2761    fn boundary_cache_fixture() -> (
2762        CocoDataset,
2763        CocoDetections,
2764        CocoDetections,
2765        OwnedEvaluateParams,
2766    ) {
2767        let images = vec![img(1, 100, 100), img(2, 100, 100)];
2768        let cats = vec![cat(1, "thing"), cat(2, "other")];
2769        let anns = vec![
2770            ann_with_segm(
2771                10,
2772                1,
2773                1,
2774                (10.0, 10.0, 20.0, 20.0),
2775                square_polygon(10.0, 10.0, 20.0),
2776            ),
2777            ann_with_segm(
2778                11,
2779                1,
2780                2,
2781                (50.0, 50.0, 15.0, 15.0),
2782                square_polygon(50.0, 50.0, 15.0),
2783            ),
2784            ann_with_segm(
2785                12,
2786                2,
2787                1,
2788                (5.0, 5.0, 25.0, 25.0),
2789                square_polygon(5.0, 5.0, 25.0),
2790            ),
2791        ];
2792        let gt = CocoDataset::from_parts(images, anns, cats).unwrap();
2793        let dts_a = CocoDetections::from_inputs(vec![
2794            dt_input_with_segm(
2795                1,
2796                1,
2797                0.9,
2798                (10.0, 10.0, 20.0, 20.0),
2799                square_polygon(10.0, 10.0, 20.0),
2800            ),
2801            dt_input_with_segm(
2802                2,
2803                1,
2804                0.8,
2805                (5.0, 5.0, 25.0, 25.0),
2806                square_polygon(5.0, 5.0, 25.0),
2807            ),
2808        ])
2809        .unwrap();
2810        // dts_b shifts both predictions a little so the grid changes
2811        // but GT bands don't: this is the regime the cache is for.
2812        let dts_b = CocoDetections::from_inputs(vec![
2813            dt_input_with_segm(
2814                1,
2815                1,
2816                0.7,
2817                (12.0, 12.0, 20.0, 20.0),
2818                square_polygon(12.0, 12.0, 20.0),
2819            ),
2820            dt_input_with_segm(
2821                2,
2822                1,
2823                0.6,
2824                (8.0, 8.0, 25.0, 25.0),
2825                square_polygon(8.0, 8.0, 25.0),
2826            ),
2827        ])
2828        .unwrap();
2829        let params = OwnedEvaluateParams {
2830            iou_thresholds: iou_thresholds().to_vec(),
2831            area_ranges: AreaRange::coco_default().to_vec(),
2832            max_dets_per_image: 100,
2833            use_cats: true,
2834            retain_iou: false,
2835        };
2836        (gt, dts_a, dts_b, params)
2837    }
2838
2839    fn boundary_grid_cells(grid: &EvalGrid) -> Vec<f64> {
2840        grid.eval_imgs
2841            .iter()
2842            .filter_map(|c| c.as_ref())
2843            .flat_map(|c| c.dt_scores.iter().copied())
2844            .collect()
2845    }
2846
2847    #[test]
2848    fn boundary_cached_matches_uncached_bit_exact() {
2849        // Same-GT, same-DT call via the cached entry point must
2850        // produce a grid bit-equal to the uncached entry point — the
2851        // cache is a memoization, never a semantic shift.
2852        let (gt, dts, _, params) = boundary_cache_fixture();
2853        let p = params.borrow();
2854        let baseline = evaluate_boundary(&gt, &dts, p, ParityMode::Strict, 0.02).unwrap();
2855        let cache = BoundaryGtCache::new();
2856        let cached_first =
2857            evaluate_boundary_cached(&gt, &dts, p, ParityMode::Strict, 0.02, &cache).unwrap();
2858        let cached_second =
2859            evaluate_boundary_cached(&gt, &dts, p, ParityMode::Strict, 0.02, &cache).unwrap();
2860
2861        let baseline_scores = boundary_grid_cells(&baseline);
2862        let first_scores = boundary_grid_cells(&cached_first);
2863        let second_scores = boundary_grid_cells(&cached_second);
2864        assert_eq!(baseline_scores.len(), first_scores.len());
2865        for (b, c) in baseline_scores.iter().zip(first_scores.iter()) {
2866            assert_eq!(b.to_bits(), c.to_bits());
2867        }
2868        for (b, c) in baseline_scores.iter().zip(second_scores.iter()) {
2869            assert_eq!(b.to_bits(), c.to_bits());
2870        }
2871    }
2872
2873    #[test]
2874    fn boundary_cache_populates_lazily_per_evaluated_cell() {
2875        // The cache fills as bands are derived, which only happens on
2876        // (image, category) cells that have a non-empty DT side. The
2877        // fixture has 3 GTs but only 2 ever participate under
2878        // `use_cats: true`: GT 11 (cat 2) has no matching DT, so its
2879        // band is never computed. Pinning the count documents the
2880        // lazy-load contract — entries we never need stay out of the
2881        // cache, keeping memory proportional to actual work.
2882        let (gt, dts, _, params) = boundary_cache_fixture();
2883        let cache = BoundaryGtCache::new();
2884        assert!(cache.is_empty());
2885        evaluate_boundary_cached(&gt, &dts, params.borrow(), ParityMode::Strict, 0.02, &cache)
2886            .unwrap();
2887        assert_eq!(cache.len(), 2);
2888    }
2889
2890    #[test]
2891    fn boundary_cache_invalidates_on_ratio_change() {
2892        // Bands depend on dilation_ratio; reusing entries computed at
2893        // ratio R₁ when the call is at ratio R₂ would silently return
2894        // wrong numerics. The cache must drop+repopulate.
2895        let (gt, dts, _, params) = boundary_cache_fixture();
2896        let cache = BoundaryGtCache::new();
2897        evaluate_boundary_cached(&gt, &dts, params.borrow(), ParityMode::Strict, 0.02, &cache)
2898            .unwrap();
2899        let after_first = cache.len();
2900        evaluate_boundary_cached(&gt, &dts, params.borrow(), ParityMode::Strict, 0.05, &cache)
2901            .unwrap();
2902        // Same GT count, but every entry was re-derived at the new
2903        // ratio: parity below proves the entries reflect R=0.05, not
2904        // stale R=0.02 data.
2905        assert_eq!(cache.len(), after_first);
2906        let fresh =
2907            evaluate_boundary(&gt, &dts, params.borrow(), ParityMode::Strict, 0.05).unwrap();
2908        let cached =
2909            evaluate_boundary_cached(&gt, &dts, params.borrow(), ParityMode::Strict, 0.05, &cache)
2910                .unwrap();
2911        let fresh_scores = boundary_grid_cells(&fresh);
2912        let cached_scores = boundary_grid_cells(&cached);
2913        for (f, c) in fresh_scores.iter().zip(cached_scores.iter()) {
2914            assert_eq!(f.to_bits(), c.to_bits());
2915        }
2916    }
2917
2918    #[test]
2919    fn boundary_cache_clear_resets_state() {
2920        let (gt, dts, _, params) = boundary_cache_fixture();
2921        let cache = BoundaryGtCache::new();
2922        evaluate_boundary_cached(&gt, &dts, params.borrow(), ParityMode::Strict, 0.02, &cache)
2923            .unwrap();
2924        assert!(!cache.is_empty());
2925        cache.clear();
2926        assert!(cache.is_empty());
2927        // Post-clear the next call must repopulate from scratch and
2928        // still produce the right answer.
2929        let after =
2930            evaluate_boundary_cached(&gt, &dts, params.borrow(), ParityMode::Strict, 0.02, &cache)
2931                .unwrap();
2932        let baseline =
2933            evaluate_boundary(&gt, &dts, params.borrow(), ParityMode::Strict, 0.02).unwrap();
2934        let after_scores = boundary_grid_cells(&after);
2935        let baseline_scores = boundary_grid_cells(&baseline);
2936        for (a, b) in after_scores.iter().zip(baseline_scores.iter()) {
2937            assert_eq!(a.to_bits(), b.to_bits());
2938        }
2939    }
2940
2941    #[test]
2942    fn boundary_cache_survives_changing_dt() {
2943        // The training-loop pattern: same GT, fresh DT each call.
2944        // Cache size must stay constant across DT swaps (the cache
2945        // only ever holds GT bands), and parity vs uncached must
2946        // hold for both DT sets.
2947        let (gt, dts_a, dts_b, params) = boundary_cache_fixture();
2948        let cache = BoundaryGtCache::new();
2949        let cached_a = evaluate_boundary_cached(
2950            &gt,
2951            &dts_a,
2952            params.borrow(),
2953            ParityMode::Strict,
2954            0.02,
2955            &cache,
2956        )
2957        .unwrap();
2958        let len_after_a = cache.len();
2959        let cached_b = evaluate_boundary_cached(
2960            &gt,
2961            &dts_b,
2962            params.borrow(),
2963            ParityMode::Strict,
2964            0.02,
2965            &cache,
2966        )
2967        .unwrap();
2968        assert_eq!(cache.len(), len_after_a);
2969
2970        let baseline_a =
2971            evaluate_boundary(&gt, &dts_a, params.borrow(), ParityMode::Strict, 0.02).unwrap();
2972        let baseline_b =
2973            evaluate_boundary(&gt, &dts_b, params.borrow(), ParityMode::Strict, 0.02).unwrap();
2974        for (lhs, rhs) in boundary_grid_cells(&cached_a)
2975            .iter()
2976            .zip(boundary_grid_cells(&baseline_a).iter())
2977        {
2978            assert_eq!(lhs.to_bits(), rhs.to_bits());
2979        }
2980        for (lhs, rhs) in boundary_grid_cells(&cached_b)
2981            .iter()
2982            .zip(boundary_grid_cells(&baseline_b).iter())
2983        {
2984            assert_eq!(lhs.to_bits(), rhs.to_bits());
2985        }
2986    }
2987
2988    // ---------------------------------------------------------------
2989    // SegmGtCache (mirrors the BoundaryGtCache suite above; the segm
2990    // fixture is built on the same pieces but lives here so the
2991    // boundary tests stay focused on band-specific behaviour).
2992    // ---------------------------------------------------------------
2993
2994    #[test]
2995    fn segm_cached_matches_uncached_bit_exact() {
2996        let (gt, dts, _, params) = boundary_cache_fixture();
2997        let p = params.borrow();
2998        let baseline = evaluate_segm(&gt, &dts, p, ParityMode::Strict).unwrap();
2999        let cache = SegmGtCache::new();
3000        let cached_first = evaluate_segm_cached(&gt, &dts, p, ParityMode::Strict, &cache).unwrap();
3001        let cached_second = evaluate_segm_cached(&gt, &dts, p, ParityMode::Strict, &cache).unwrap();
3002
3003        let baseline_scores = boundary_grid_cells(&baseline);
3004        let first_scores = boundary_grid_cells(&cached_first);
3005        let second_scores = boundary_grid_cells(&cached_second);
3006        assert_eq!(baseline_scores.len(), first_scores.len());
3007        for (b, c) in baseline_scores.iter().zip(first_scores.iter()) {
3008            assert_eq!(b.to_bits(), c.to_bits());
3009        }
3010        for (b, c) in baseline_scores.iter().zip(second_scores.iter()) {
3011            assert_eq!(b.to_bits(), c.to_bits());
3012        }
3013    }
3014
3015    #[test]
3016    fn segm_cache_populates_lazily_per_evaluated_cell() {
3017        // Same lazy-load contract as the boundary cache: only GTs
3018        // that participate in an evaluated `(image, category)` cell
3019        // — i.e. one with at least one DT — get cached. The
3020        // boundary fixture has 3 GTs but only 2 such cells under
3021        // `use_cats: true`.
3022        let (gt, dts, _, params) = boundary_cache_fixture();
3023        let cache = SegmGtCache::new();
3024        assert!(cache.is_empty());
3025        evaluate_segm_cached(&gt, &dts, params.borrow(), ParityMode::Strict, &cache).unwrap();
3026        assert_eq!(cache.len(), 2);
3027    }
3028
3029    #[test]
3030    fn segm_cache_clear_resets_state() {
3031        let (gt, dts, _, params) = boundary_cache_fixture();
3032        let cache = SegmGtCache::new();
3033        evaluate_segm_cached(&gt, &dts, params.borrow(), ParityMode::Strict, &cache).unwrap();
3034        assert!(!cache.is_empty());
3035        cache.clear();
3036        assert!(cache.is_empty());
3037        let after =
3038            evaluate_segm_cached(&gt, &dts, params.borrow(), ParityMode::Strict, &cache).unwrap();
3039        let baseline = evaluate_segm(&gt, &dts, params.borrow(), ParityMode::Strict).unwrap();
3040        for (a, b) in boundary_grid_cells(&after)
3041            .iter()
3042            .zip(boundary_grid_cells(&baseline).iter())
3043        {
3044            assert_eq!(a.to_bits(), b.to_bits());
3045        }
3046    }
3047
3048    #[test]
3049    fn segm_cache_survives_changing_dt() {
3050        // Training-loop pattern: same GT, fresh DT each call. Cache
3051        // size must stay constant across DT swaps (the cache only
3052        // holds GT entries) and parity vs uncached must hold for
3053        // both DT sets.
3054        let (gt, dts_a, dts_b, params) = boundary_cache_fixture();
3055        let cache = SegmGtCache::new();
3056        let cached_a =
3057            evaluate_segm_cached(&gt, &dts_a, params.borrow(), ParityMode::Strict, &cache).unwrap();
3058        let len_after_a = cache.len();
3059        let cached_b =
3060            evaluate_segm_cached(&gt, &dts_b, params.borrow(), ParityMode::Strict, &cache).unwrap();
3061        assert_eq!(cache.len(), len_after_a);
3062
3063        let baseline_a = evaluate_segm(&gt, &dts_a, params.borrow(), ParityMode::Strict).unwrap();
3064        let baseline_b = evaluate_segm(&gt, &dts_b, params.borrow(), ParityMode::Strict).unwrap();
3065        for (lhs, rhs) in boundary_grid_cells(&cached_a)
3066            .iter()
3067            .zip(boundary_grid_cells(&baseline_a).iter())
3068        {
3069            assert_eq!(lhs.to_bits(), rhs.to_bits());
3070        }
3071        for (lhs, rhs) in boundary_grid_cells(&cached_b)
3072            .iter()
3073            .zip(boundary_grid_cells(&baseline_b).iter())
3074        {
3075            assert_eq!(lhs.to_bits(), rhs.to_bits());
3076        }
3077    }
3078
3079    // ---------------------------------------------------------------
3080    // Phase 3: keypoints (OKS) eval pipeline (ADR-0012).
3081    // ---------------------------------------------------------------
3082
3083    /// Builds a flat `[x, y, v, ...]` keypoint vector at a single point.
3084    /// `len` controls the per-category sigma length the kernel expects
3085    /// (17 for COCO-person).
3086    fn const_kps_vec(x: f64, y: f64, v: u32, len: usize) -> Vec<f64> {
3087        let mut out = Vec::with_capacity(3 * len);
3088        for _ in 0..len {
3089            out.push(x);
3090            out.push(y);
3091            out.push(f64::from(v));
3092        }
3093        out
3094    }
3095
3096    fn ann_with_kps(
3097        id: i64,
3098        image: i64,
3099        cat: i64,
3100        bbox: (f64, f64, f64, f64),
3101        keypoints: Vec<f64>,
3102        num_keypoints: Option<u32>,
3103    ) -> CocoAnnotation {
3104        CocoAnnotation {
3105            id: AnnId(id),
3106            image_id: ImageId(image),
3107            category_id: CategoryId(cat),
3108            area: bbox.2 * bbox.3,
3109            is_crowd: false,
3110            ignore_flag: None,
3111            bbox: Bbox {
3112                x: bbox.0,
3113                y: bbox.1,
3114                w: bbox.2,
3115                h: bbox.3,
3116            },
3117            segmentation: None,
3118            keypoints: Some(keypoints),
3119            num_keypoints,
3120        }
3121    }
3122
3123    fn dt_input_with_kps(
3124        image: i64,
3125        cat: i64,
3126        score: f64,
3127        bbox: (f64, f64, f64, f64),
3128        keypoints: Vec<f64>,
3129    ) -> DetectionInput {
3130        DetectionInput {
3131            id: None,
3132            image_id: ImageId(image),
3133            category_id: CategoryId(cat),
3134            score,
3135            bbox: Bbox {
3136                x: bbox.0,
3137                y: bbox.1,
3138                w: bbox.2,
3139                h: bbox.3,
3140            },
3141            segmentation: None,
3142            keypoints: Some(keypoints),
3143            num_keypoints: None,
3144        }
3145    }
3146
3147    #[test]
3148    fn test_evaluate_keypoints_perfect_match() {
3149        // 1 image, 1 GT person, 1 DT person matching exactly. Every
3150        // keypoint aligns → OKS = 1.0 → matched at every threshold,
3151        // and the meta gt_matches matrix carries the matched DT id.
3152        let images = vec![img(1, 100, 100)];
3153        let cats = vec![cat(1, "person")];
3154        let kps = const_kps_vec(50.0, 50.0, 2, 17);
3155        let anns = vec![ann_with_kps(
3156            1,
3157            1,
3158            1,
3159            (40.0, 40.0, 20.0, 20.0),
3160            kps.clone(),
3161            None,
3162        )];
3163        let gt = CocoDataset::from_parts(images, anns, cats).unwrap();
3164        let dts = CocoDetections::from_inputs(vec![dt_input_with_kps(
3165            1,
3166            1,
3167            0.9,
3168            (40.0, 40.0, 20.0, 20.0),
3169            kps,
3170        )])
3171        .unwrap();
3172        let area = AreaRange::coco_default();
3173        let params = EvaluateParams {
3174            iou_thresholds: iou_thresholds(),
3175            area_ranges: &area,
3176            max_dets_per_image: 100,
3177            use_cats: true,
3178            retain_iou: false,
3179        };
3180        let grid =
3181            evaluate_keypoints(&gt, &dts, params, ParityMode::Strict, HashMap::new()).unwrap();
3182        let cell = grid.cell(0, 0, 0).unwrap();
3183        // gt_ignore is false (visible keypoints), so the GT is in play.
3184        assert_eq!(cell.gt_ignore, vec![false]);
3185        // Every threshold matches the DT at score 0.9.
3186        assert!(cell.dt_matched.iter().all(|&m| m));
3187        // Meta carries the matched DT id at every threshold for this GT.
3188        let meta = grid.cell_meta(0, 0, 0).unwrap();
3189        assert!(
3190            meta.gt_matches.iter().all(|&id| id > 0),
3191            "every threshold should match the DT id (>0)",
3192        );
3193    }
3194
3195    #[test]
3196    fn test_evaluate_keypoints_zero_overlap() {
3197        // 1 GT and 1 DT keypoints far apart (separated by ~1000 px on
3198        // a 10×10 bbox). OKS drops well below 0.5 → no match at any
3199        // threshold ≥ 0.5.
3200        let images = vec![img(1, 2000, 2000)];
3201        let cats = vec![cat(1, "person")];
3202        let gt_kps = const_kps_vec(50.0, 50.0, 2, 17);
3203        let dt_kps = const_kps_vec(1500.0, 1500.0, 2, 17);
3204        let anns = vec![ann_with_kps(
3205            1,
3206            1,
3207            1,
3208            (40.0, 40.0, 20.0, 20.0),
3209            gt_kps,
3210            None,
3211        )];
3212        let gt = CocoDataset::from_parts(images, anns, cats).unwrap();
3213        let dts = CocoDetections::from_inputs(vec![dt_input_with_kps(
3214            1,
3215            1,
3216            0.9,
3217            (1490.0, 1490.0, 20.0, 20.0),
3218            dt_kps,
3219        )])
3220        .unwrap();
3221        let area = AreaRange::coco_default();
3222        let params = EvaluateParams {
3223            iou_thresholds: iou_thresholds(),
3224            area_ranges: &area,
3225            max_dets_per_image: 100,
3226            use_cats: true,
3227            retain_iou: false,
3228        };
3229        let grid =
3230            evaluate_keypoints(&gt, &dts, params, ParityMode::Strict, HashMap::new()).unwrap();
3231        let cell = grid.cell(0, 0, 0).unwrap();
3232        assert!(
3233            cell.dt_matched.iter().all(|&m| !m),
3234            "DTs far from GT should not match at any IoU threshold",
3235        );
3236    }
3237
3238    #[test]
3239    fn test_evaluate_keypoints_d2_implicit_ignore() {
3240        // D2 (`strict`): GT with `num_keypoints == 0` is treated as an
3241        // implicit ignore region, OR-ed with the existing ignore. This
3242        // GT carries v=0 on every triplet (so num_keypoints derives to
3243        // 0 even without the precomputed field) and is not is_crowd.
3244        let images = vec![img(1, 100, 100)];
3245        let cats = vec![cat(1, "person")];
3246        let gt_kps = const_kps_vec(50.0, 50.0, 0, 17);
3247        let dt_kps = const_kps_vec(50.0, 50.0, 2, 17);
3248        let anns = vec![ann_with_kps(
3249            1,
3250            1,
3251            1,
3252            (40.0, 40.0, 20.0, 20.0),
3253            gt_kps,
3254            // Explicit Some(0) covers the precomputed-num_keypoints
3255            // path; the kernel treats it identically to deriving from
3256            // visibility flags.
3257            Some(0),
3258        )];
3259        let gt = CocoDataset::from_parts(images, anns, cats).unwrap();
3260        let dts = CocoDetections::from_inputs(vec![dt_input_with_kps(
3261            1,
3262            1,
3263            0.9,
3264            (40.0, 40.0, 20.0, 20.0),
3265            dt_kps,
3266        )])
3267        .unwrap();
3268        let area = AreaRange::coco_default();
3269        let params = EvaluateParams {
3270            iou_thresholds: iou_thresholds(),
3271            area_ranges: &area,
3272            max_dets_per_image: 100,
3273            use_cats: true,
3274            retain_iou: false,
3275        };
3276        let grid =
3277            evaluate_keypoints(&gt, &dts, params, ParityMode::Strict, HashMap::new()).unwrap();
3278        let cell = grid.cell(0, 0, 0).unwrap();
3279        assert_eq!(
3280            cell.gt_ignore,
3281            vec![true],
3282            "D2: zero-visible-keypoints GT must be ignored",
3283        );
3284    }
3285
3286    #[test]
3287    fn test_evaluate_keypoints_per_category_sigmas() {
3288        // Two GTs in different categories; sigmas provided per category.
3289        // Each row of the OKS matrix uses the right sigma vector — we
3290        // verify by asserting the cell evaluates without error and that
3291        // both DTs match their same-category GT with the override-tuned
3292        // sigmas. We pick large sigmas (0.5) so a 1-pixel offset still
3293        // OKS≈1, ensuring matches at every threshold.
3294        let images = vec![img(1, 200, 200)];
3295        let cats = vec![cat(1, "person"), cat(2, "dog")];
3296        let gt_kps = const_kps_vec(50.0, 50.0, 2, 17);
3297        let anns = vec![
3298            ann_with_kps(1, 1, 1, (40.0, 40.0, 20.0, 20.0), gt_kps, None),
3299            ann_with_kps(
3300                2,
3301                1,
3302                2,
3303                (140.0, 140.0, 20.0, 20.0),
3304                const_kps_vec(150.0, 150.0, 2, 17),
3305                None,
3306            ),
3307        ];
3308        let gt = CocoDataset::from_parts(images, anns, cats).unwrap();
3309        // DT[0] near GT[0] (cat 1), DT[1] near GT[1] (cat 2). Both off
3310        // by 1 pixel.
3311        let dts = CocoDetections::from_inputs(vec![
3312            dt_input_with_kps(
3313                1,
3314                1,
3315                0.9,
3316                (40.0, 40.0, 20.0, 20.0),
3317                const_kps_vec(51.0, 50.0, 2, 17),
3318            ),
3319            dt_input_with_kps(
3320                1,
3321                2,
3322                0.8,
3323                (140.0, 140.0, 20.0, 20.0),
3324                const_kps_vec(151.0, 150.0, 2, 17),
3325            ),
3326        ])
3327        .unwrap();
3328        let mut sigmas: HashMap<i64, Vec<f64>> = HashMap::new();
3329        sigmas.insert(1, vec![0.5_f64; 17]);
3330        sigmas.insert(2, vec![0.5_f64; 17]);
3331        let area = AreaRange::coco_default();
3332        let params = EvaluateParams {
3333            iou_thresholds: iou_thresholds(),
3334            area_ranges: &area,
3335            max_dets_per_image: 100,
3336            use_cats: true,
3337            retain_iou: false,
3338        };
3339        let grid = evaluate_keypoints(&gt, &dts, params, ParityMode::Strict, sigmas).unwrap();
3340        // K-axis is [cat 1, cat 2]; each cell sees one GT and one DT.
3341        let cell_cat1 = grid.cell(0, 0, 0).unwrap();
3342        let cell_cat2 = grid.cell(1, 0, 0).unwrap();
3343        assert!(
3344            cell_cat1.dt_matched.iter().all(|&m| m),
3345            "cat-1 DT should match cat-1 GT under override sigmas",
3346        );
3347        assert!(
3348            cell_cat2.dt_matched.iter().all(|&m| m),
3349            "cat-2 DT should match cat-2 GT under override sigmas",
3350        );
3351    }
3352
3353    #[test]
3354    fn test_evaluate_keypoints_missing_dt_kps_rejected() {
3355        // DT entry without `keypoints` field → the kernel build path
3356        // surfaces InvalidAnnotation. There is no parity-mode J2 analog
3357        // for keypoints (no bbox-synthesis fallback).
3358        let images = vec![img(1, 100, 100)];
3359        let cats = vec![cat(1, "person")];
3360        let gt_kps = const_kps_vec(50.0, 50.0, 2, 17);
3361        let anns = vec![ann_with_kps(
3362            1,
3363            1,
3364            1,
3365            (40.0, 40.0, 20.0, 20.0),
3366            gt_kps,
3367            None,
3368        )];
3369        let gt = CocoDataset::from_parts(images, anns, cats).unwrap();
3370        // DT has bbox + score but no keypoints — uses the existing
3371        // bbox-only `dt_input` helper.
3372        let dts = CocoDetections::from_inputs(vec![dt_input(1, 1, 0.9, (40.0, 40.0, 20.0, 20.0))])
3373            .unwrap();
3374        let area = AreaRange::coco_default();
3375        let params = EvaluateParams {
3376            iou_thresholds: iou_thresholds(),
3377            area_ranges: &area,
3378            max_dets_per_image: 100,
3379            use_cats: true,
3380            retain_iou: false,
3381        };
3382        let err =
3383            evaluate_keypoints(&gt, &dts, params, ParityMode::Strict, HashMap::new()).unwrap_err();
3384        match err {
3385            EvalError::InvalidAnnotation { detail } => {
3386                assert!(detail.contains("DT"), "expected DT in msg: {detail}");
3387                assert!(
3388                    detail.contains("keypoints"),
3389                    "expected keypoints in msg: {detail}",
3390                );
3391            }
3392            other => panic!("expected InvalidAnnotation, got {other:?}"),
3393        }
3394    }
3395
3396    #[test]
3397    fn test_keypoints_default_ignore_for_other_kernels() {
3398        // The D2 implicit-ignore clause must not bleed across kernels.
3399        // BboxIou::extra_gt_ignore (default impl) returns false even for
3400        // an annotation with num_keypoints=0; only OksSimilarity
3401        // overrides it.
3402        let ann_zero_kps = ann_with_kps(
3403            1,
3404            1,
3405            1,
3406            (0.0, 0.0, 10.0, 10.0),
3407            const_kps_vec(0.0, 0.0, 0, 17),
3408            Some(0),
3409        );
3410        assert!(
3411            !BboxIou.extra_gt_ignore(&ann_zero_kps),
3412            "BboxIou must keep the default `false` ignore",
3413        );
3414        assert!(
3415            !SegmIou.extra_gt_ignore(&ann_zero_kps),
3416            "SegmIou must keep the default `false` ignore",
3417        );
3418        assert!(
3419            !BoundaryIou {
3420                dilation_ratio: 0.02,
3421            }
3422            .extra_gt_ignore(&ann_zero_kps),
3423            "BoundaryIou must keep the default `false` ignore",
3424        );
3425        // And the OKS kernel does flip it on the same annotation.
3426        assert!(
3427            OksSimilarity::default().extra_gt_ignore(&ann_zero_kps),
3428            "OksSimilarity must flip D2 to true on zero-visible-keypoints GT",
3429        );
3430    }
3431
3432    #[test]
3433    fn boundary_missing_gt_segmentation_surfaces_typed_error() {
3434        // Boundary reuses the segm GT-build path, so missing GT segm
3435        // surfaces the same typed error. Pinned here so a future
3436        // refactor that splits the build paths can't silently regress.
3437        let images = vec![img(1, 100, 100)];
3438        let cats = vec![cat(1, "thing")];
3439        let anns = vec![ann(7, 1, 1, (0.0, 0.0, 10.0, 10.0))];
3440        let gt = CocoDataset::from_parts(images, anns, cats).unwrap();
3441        let dts = CocoDetections::from_inputs(vec![dt_input_with_segm(
3442            1,
3443            1,
3444            0.9,
3445            (0.0, 0.0, 10.0, 10.0),
3446            square_polygon(0.0, 0.0, 10.0),
3447        )])
3448        .unwrap();
3449        let area = AreaRange::coco_default();
3450        let params = EvaluateParams {
3451            iou_thresholds: iou_thresholds(),
3452            area_ranges: &area,
3453            max_dets_per_image: 100,
3454            use_cats: true,
3455            retain_iou: false,
3456        };
3457        let err = evaluate_boundary(&gt, &dts, params, ParityMode::Strict, 0.02).unwrap_err();
3458        match err {
3459            EvalError::InvalidAnnotation { detail } => {
3460                assert!(detail.contains("GT id=7"), "msg: {detail}");
3461            }
3462            other => panic!("expected InvalidAnnotation, got {other:?}"),
3463        }
3464    }
3465
3466    // -- ADR-0026: federated cell-skip and dt_ignore extension ---------------
3467
3468    /// Build an LVIS-style GT dataset directly: a `CocoDataset` whose
3469    /// federated metadata sets are populated. Mirrors what
3470    /// `from_lvis_json_bytes` produces, but lets tests pin the maps
3471    /// without round-tripping through JSON.
3472    fn lvis_dataset(
3473        images: &[ImageMeta],
3474        annotations: &[CocoAnnotation],
3475        categories: &[CategoryMeta],
3476        neg: &[(i64, Vec<i64>)],
3477        nel: &[(i64, Vec<i64>)],
3478        freq: &[(i64, crate::dataset::Frequency)],
3479    ) -> CocoDataset {
3480        // Build LVIS JSON bytes through the public loader so the
3481        // resulting dataset uses the same code path the FFI exercises.
3482        // (Constructing through `from_parts` would leave the federated
3483        // fields `None`.)
3484        let images_json: Vec<serde_json::Value> = images
3485            .iter()
3486            .map(|im| {
3487                let neg_for: Vec<i64> = neg
3488                    .iter()
3489                    .find(|(id, _)| *id == im.id.0)
3490                    .map(|(_, v)| v.clone())
3491                    .unwrap_or_default();
3492                let nel_for: Vec<i64> = nel
3493                    .iter()
3494                    .find(|(id, _)| *id == im.id.0)
3495                    .map(|(_, v)| v.clone())
3496                    .unwrap_or_default();
3497                serde_json::json!({
3498                    "id": im.id.0,
3499                    "width": im.width,
3500                    "height": im.height,
3501                    "neg_category_ids": neg_for,
3502                    "not_exhaustive_category_ids": nel_for,
3503                })
3504            })
3505            .collect();
3506        let cats_json: Vec<serde_json::Value> = categories
3507            .iter()
3508            .map(|c| {
3509                let f = freq
3510                    .iter()
3511                    .find(|(id, _)| *id == c.id.0)
3512                    .map(|(_, f)| match f {
3513                        crate::dataset::Frequency::Rare => "r",
3514                        crate::dataset::Frequency::Common => "c",
3515                        crate::dataset::Frequency::Frequent => "f",
3516                    })
3517                    .expect("test fixture must include frequency for every category");
3518                serde_json::json!({
3519                    "id": c.id.0,
3520                    "name": c.name,
3521                    "frequency": f,
3522                })
3523            })
3524            .collect();
3525        let anns_json = serde_json::to_value(annotations).unwrap();
3526        let payload = serde_json::json!({
3527            "images": images_json,
3528            "annotations": anns_json,
3529            "categories": cats_json,
3530        });
3531        let bytes = serde_json::to_vec(&payload).unwrap();
3532        CocoDataset::from_lvis_json_bytes(&bytes).unwrap()
3533    }
3534
3535    #[test]
3536    fn aa4_skips_cells_outside_pos_union_neg() {
3537        // Two images, two categories. Image 1 has GTs of cat 1 only;
3538        // image 2 has GTs of cat 2 only. Neither image lists anything
3539        // in `neg`. The DT set predicts cat 2 on image 1 (a category
3540        // for which image 1 has no GT and no neg listing) — the
3541        // federated cell-skip MUST drop the resulting (image 1,
3542        // cat 2) cell entirely. Without AA4 the DT counts as a FP and
3543        // tanks AP.
3544        let images = vec![img(1, 100, 100), img(2, 100, 100)];
3545        let cats = vec![cat(1, "a"), cat(2, "b")];
3546        let anns = vec![
3547            ann(1, 1, 1, (0.0, 0.0, 10.0, 10.0)),
3548            ann(2, 2, 2, (0.0, 0.0, 10.0, 10.0)),
3549        ];
3550        let gt_lvis = lvis_dataset(
3551            &images,
3552            &anns,
3553            &cats,
3554            &[(1, vec![]), (2, vec![])],
3555            &[(1, vec![]), (2, vec![])],
3556            &[
3557                (1, crate::dataset::Frequency::Frequent),
3558                (2, crate::dataset::Frequency::Frequent),
3559            ],
3560        );
3561        let gt_coco = CocoDataset::from_parts(images, anns, cats).unwrap();
3562        // DT: a "stray" cat 2 prediction on image 1 — federated wants
3563        // it dropped, COCO will score it as a FP.
3564        let dts = CocoDetections::from_inputs(vec![
3565            dt_input(1, 1, 0.9, (0.0, 0.0, 10.0, 10.0)),
3566            dt_input(1, 2, 0.7, (50.0, 50.0, 10.0, 10.0)),
3567            dt_input(2, 2, 0.9, (0.0, 0.0, 10.0, 10.0)),
3568        ])
3569        .unwrap();
3570        let area = AreaRange::coco_default();
3571        let params = EvaluateParams {
3572            iou_thresholds: iou_thresholds(),
3573            area_ranges: &area,
3574            max_dets_per_image: 100,
3575            use_cats: true,
3576            retain_iou: false,
3577        };
3578        let grid_lvis = evaluate_bbox(&gt_lvis, &dts, params, ParityMode::Strict).unwrap();
3579        let grid_coco = evaluate_bbox(&gt_coco, &dts, params, ParityMode::Strict).unwrap();
3580
3581        // Cell layout: K=[cat 1, cat 2], A=[all], I=[image 1, image 2].
3582        // (image 1, cat 2) sits at k=1, a=0, i=0 — federated dataset
3583        // skips it (None), COCO dataset evaluates it (Some).
3584        let lvis_cell = grid_lvis.cell(1, 0, 0);
3585        let coco_cell = grid_coco.cell(1, 0, 0);
3586        assert!(lvis_cell.is_none(), "AA4: federated cell must be skipped");
3587        assert!(
3588            coco_cell.is_some(),
3589            "control: COCO dataset must evaluate the same cell"
3590        );
3591        // The (image 1, cat 1) cell is unaffected — federated and
3592        // COCO must agree there because cat 1 ∈ pos[1].
3593        assert_eq!(
3594            grid_lvis.cell(0, 0, 0).map(|c| c.dt_scores.len()),
3595            grid_coco.cell(0, 0, 0).map(|c| c.dt_scores.len()),
3596        );
3597    }
3598
3599    #[test]
3600    fn aa4_keeps_neg_cells_with_no_gts() {
3601        // Same shape as the previous test, but image 1 lists cat 2 in
3602        // its `neg` set: the cell now stays (so we score recall on a
3603        // verified-absent category) and unmatched DTs become FPs.
3604        let images = vec![img(1, 100, 100)];
3605        let cats = vec![cat(1, "a"), cat(2, "b")];
3606        let anns = vec![ann(1, 1, 1, (0.0, 0.0, 10.0, 10.0))];
3607        let gt = lvis_dataset(
3608            &images,
3609            &anns,
3610            &cats,
3611            &[(1, vec![2])], // cat 2 ∈ neg[1]
3612            &[(1, vec![])],
3613            &[
3614                (1, crate::dataset::Frequency::Frequent),
3615                (2, crate::dataset::Frequency::Frequent),
3616            ],
3617        );
3618        let dts = CocoDetections::from_inputs(vec![
3619            dt_input(1, 1, 0.9, (0.0, 0.0, 10.0, 10.0)),
3620            dt_input(1, 2, 0.7, (50.0, 50.0, 10.0, 10.0)),
3621        ])
3622        .unwrap();
3623        let area = AreaRange::coco_default();
3624        let params = EvaluateParams {
3625            iou_thresholds: iou_thresholds(),
3626            area_ranges: &area,
3627            max_dets_per_image: 100,
3628            use_cats: true,
3629            retain_iou: false,
3630        };
3631        let grid = evaluate_bbox(&gt, &dts, params, ParityMode::Strict).unwrap();
3632        let cell = grid
3633            .cell(1, 0, 0)
3634            .expect("cat 2 ∈ neg[1] must produce an evaluated cell");
3635        // The cell has no GTs and one DT; the DT is an unmatched FP
3636        // (not ignored, because the cell is not in `not_exhaustive`).
3637        assert_eq!(cell.dt_scores.len(), 1);
3638        assert!(cell.dt_ignore.iter().all(|&ig| !ig));
3639    }
3640
3641    #[test]
3642    fn aa3_dt_ignore_extension_in_not_exhaustive_cell() {
3643        // Image 1 has GTs of cat 1 and lists cat 1 in its
3644        // `not_exhaustive` set. The DT set has two predictions for
3645        // cat 1: one matches the GT (TP), the other is unmatched.
3646        // Quirk **AA3** says the unmatched DT must have
3647        // `dt_ignore = true`; the matched DT keeps `dt_ignore =
3648        // false`.
3649        let images = vec![img(1, 100, 100)];
3650        let cats = vec![cat(1, "a")];
3651        let anns = vec![ann(1, 1, 1, (0.0, 0.0, 10.0, 10.0))];
3652        let gt = lvis_dataset(
3653            &images,
3654            &anns,
3655            &cats,
3656            &[(1, vec![])],
3657            &[(1, vec![1])], // cat 1 ∈ not_exhaustive[1]
3658            &[(1, crate::dataset::Frequency::Frequent)],
3659        );
3660        let dts = CocoDetections::from_inputs(vec![
3661            dt_input(1, 1, 0.9, (0.0, 0.0, 10.0, 10.0)),   // TP
3662            dt_input(1, 1, 0.7, (50.0, 50.0, 10.0, 10.0)), // unmatched FP candidate
3663        ])
3664        .unwrap();
3665        let area = AreaRange::coco_default();
3666        let params = EvaluateParams {
3667            iou_thresholds: iou_thresholds(),
3668            area_ranges: &area,
3669            max_dets_per_image: 100,
3670            use_cats: true,
3671            retain_iou: false,
3672        };
3673        let grid = evaluate_bbox(&gt, &dts, params, ParityMode::Strict).unwrap();
3674        let cell = grid.cell(0, 0, 0).expect("cell must evaluate");
3675        let n_t = cell.dt_ignore.shape()[0];
3676        // sorted-DT order is descending by score: [TP, FP]. TP must
3677        // never be `dt_ignore = true` (B6 only flips ignore on
3678        // *unmatched* DTs); FP must be `true` for every IoU
3679        // threshold.
3680        for t in 0..n_t {
3681            assert!(!cell.dt_ignore[(t, 0)], "TP should not be dt_ignore");
3682            assert!(
3683                cell.dt_ignore[(t, 1)],
3684                "AA3: unmatched DT in not_exhaustive cell must be dt_ignore"
3685            );
3686        }
3687    }
3688
3689    #[test]
3690    fn aa3_dt_ignore_only_unmatched() {
3691        // Mirror of the previous test but with `not_exhaustive` empty:
3692        // the same DT pair must produce `dt_ignore = false` on both
3693        // entries (the unmatched DT is now a real FP).
3694        let images = vec![img(1, 100, 100)];
3695        let cats = vec![cat(1, "a")];
3696        let anns = vec![ann(1, 1, 1, (0.0, 0.0, 10.0, 10.0))];
3697        let gt = lvis_dataset(
3698            &images,
3699            &anns,
3700            &cats,
3701            &[(1, vec![])],
3702            &[(1, vec![])],
3703            &[(1, crate::dataset::Frequency::Frequent)],
3704        );
3705        let dts = CocoDetections::from_inputs(vec![
3706            dt_input(1, 1, 0.9, (0.0, 0.0, 10.0, 10.0)),
3707            dt_input(1, 1, 0.7, (50.0, 50.0, 10.0, 10.0)),
3708        ])
3709        .unwrap();
3710        let area = AreaRange::coco_default();
3711        let params = EvaluateParams {
3712            iou_thresholds: iou_thresholds(),
3713            area_ranges: &area,
3714            max_dets_per_image: 100,
3715            use_cats: true,
3716            retain_iou: false,
3717        };
3718        let grid = evaluate_bbox(&gt, &dts, params, ParityMode::Strict).unwrap();
3719        let cell = grid.cell(0, 0, 0).expect("cell must evaluate");
3720        assert!(cell.dt_ignore.iter().all(|&ig| !ig));
3721    }
3722
3723    #[test]
3724    fn federated_dataset_with_use_cats_false_falls_back_to_coco() {
3725        // Federated logic requires `use_cats=true`. With `use_cats=false`
3726        // the L4 collapse merges every category into one bucket; we
3727        // explicitly skip the federated checks so a misconfigured
3728        // caller still sees deterministic COCO-grade output.
3729        let images = vec![img(1, 100, 100), img(2, 100, 100)];
3730        let cats = vec![cat(1, "a"), cat(2, "b")];
3731        let anns = vec![
3732            ann(1, 1, 1, (0.0, 0.0, 10.0, 10.0)),
3733            ann(2, 2, 2, (0.0, 0.0, 10.0, 10.0)),
3734        ];
3735        let gt = lvis_dataset(
3736            &images,
3737            &anns,
3738            &cats,
3739            &[(1, vec![]), (2, vec![])],
3740            &[(1, vec![]), (2, vec![])],
3741            &[
3742                (1, crate::dataset::Frequency::Frequent),
3743                (2, crate::dataset::Frequency::Frequent),
3744            ],
3745        );
3746        let dts = CocoDetections::from_inputs(vec![
3747            dt_input(1, 1, 0.9, (0.0, 0.0, 10.0, 10.0)),
3748            dt_input(1, 2, 0.7, (50.0, 50.0, 10.0, 10.0)),
3749        ])
3750        .unwrap();
3751        let area = AreaRange::coco_default();
3752        let params = EvaluateParams {
3753            iou_thresholds: iou_thresholds(),
3754            area_ranges: &area,
3755            max_dets_per_image: 100,
3756            use_cats: false,
3757            retain_iou: false,
3758        };
3759        // No panic, no skipped cell — the K-axis is collapsed to one
3760        // sentinel category so AA4 cannot apply.
3761        let grid = evaluate_bbox(&gt, &dts, params, ParityMode::Strict).unwrap();
3762        assert_eq!(grid.n_categories, 1);
3763        // (k=0, a=0, i=0) is the only image-1 cell; it must contain
3764        // both DTs (cat 1 and cat 2 collapsed onto k=0).
3765        let cell = grid.cell(0, 0, 0).expect("collapsed cell must evaluate");
3766        assert_eq!(cell.dt_scores.len(), 2);
3767    }
3768
3769    #[test]
3770    fn coco_dataset_unaffected_by_federated_machinery() {
3771        // The federated branches must be no-ops when
3772        // `is_federated()` is false. Pin this with a regression check
3773        // against the perfect_match_grid fixture: the cell shape
3774        // must be byte-identical to what the function returned
3775        // before the AA3/AA4 patch.
3776        let g = perfect_match_grid();
3777        // 1 category, 4 area ranges, 1 image. (k=0, a=0, i=0) holds
3778        // the all-area cell with both DTs matched.
3779        let cell = g.cell(0, 0, 0).expect("perfect_match cell must exist");
3780        assert_eq!(cell.dt_scores.len(), 2);
3781        assert!(cell.dt_ignore.iter().all(|&ig| !ig));
3782    }
3783
3784    // -- Quirk AG6: strict-mode `area > 0` GT filter (ADR-0026) --------------
3785
3786    /// Build a GT annotation with an explicitly-pinned `area`. The
3787    /// general-purpose `ann()` derives area from the bbox (`w * h`),
3788    /// which can't synthesize the "bbox has positive extent but `area`
3789    /// field is 0" case the oracle filters on.
3790    fn ann_with_area(
3791        id: i64,
3792        image: i64,
3793        cat: i64,
3794        bbox: (f64, f64, f64, f64),
3795        area: f64,
3796    ) -> CocoAnnotation {
3797        let mut a = ann(id, image, cat, bbox);
3798        a.area = area;
3799        a
3800    }
3801
3802    #[test]
3803    fn ag6_mixed_cell_drops_zero_area_gt_in_strict_mode() {
3804        // Mixed cell: one area>0 GT and one area==0 GT (both with
3805        // positive-extent bboxes — mirrors the LVIS val data where
3806        // ann_id=31604 has `bbox=[132.86, 347.1, 0.07, 0.08]` and
3807        // `area=0.0` because the segm-derived area is zero). Perfect-DTs
3808        // for both. Strict mode mirrors the oracle: the zero-area GT
3809        // is dropped, leaving its DT as an unmatched FP.
3810        let images = vec![img(1, 100, 100)];
3811        let cats = vec![cat(1, "a")];
3812        let anns = vec![
3813            ann(1, 1, 1, (10.0, 10.0, 20.0, 20.0)),
3814            ann_with_area(2, 1, 1, (50.0, 50.0, 0.1, 0.1), 0.0),
3815        ];
3816        let gt = lvis_dataset(
3817            &images,
3818            &anns,
3819            &cats,
3820            &[(1, vec![])],
3821            &[(1, vec![])],
3822            &[(1, crate::dataset::Frequency::Frequent)],
3823        );
3824        let dts = CocoDetections::from_inputs(vec![
3825            dt_input(1, 1, 0.9, (10.0, 10.0, 20.0, 20.0)),
3826            dt_input(1, 1, 0.8, (50.0, 50.0, 0.1, 0.1)),
3827        ])
3828        .unwrap();
3829        let area = AreaRange::coco_default();
3830        let params = EvaluateParams {
3831            iou_thresholds: iou_thresholds(),
3832            area_ranges: &area,
3833            max_dets_per_image: 100,
3834            use_cats: true,
3835            retain_iou: false,
3836        };
3837
3838        let strict = evaluate_bbox(&gt, &dts, params, ParityMode::Strict).unwrap();
3839        let cell = strict
3840            .cell(0, 0, 0)
3841            .expect("mixed cell must still evaluate in strict mode");
3842        assert_eq!(cell.dt_scores.len(), 2);
3843        // dt_scores sorted desc → [0.9, 0.8]. At t=0 (iou=0.5):
3844        // DT_real matches GT_real; DT_zero finds no GT (filtered out)
3845        // so dt_matches[0,1] == 0.
3846        let strict_meta = strict.cell_meta(0, 0, 0).unwrap();
3847        assert_eq!(strict_meta.dt_matches[(0, 0)], 1, "DT_real → GT id=1");
3848        assert_eq!(
3849            strict_meta.dt_matches[(0, 1)],
3850            0,
3851            "DT_zero must be unmatched after strict filter drops GT id=2"
3852        );
3853
3854        let corrected = evaluate_bbox(&gt, &dts, params, ParityMode::Corrected).unwrap();
3855        let cor_meta = corrected.cell_meta(0, 0, 0).unwrap();
3856        assert_eq!(cor_meta.dt_matches[(0, 0)], 1, "DT_real → GT id=1");
3857        assert_eq!(
3858            cor_meta.dt_matches[(0, 1)],
3859            2,
3860            "Corrected mode keeps the zero-area GT and matches DT_zero → GT id=2"
3861        );
3862    }
3863
3864    #[test]
3865    fn ag6_all_zero_area_cell_skipped_via_aa4_in_strict_mode() {
3866        // Only GT for (image 1, cat 1) is zero-area. Post-filter
3867        // gt_indices is empty; cat 1 is not in neg[1] either, so the
3868        // AA4 cell-skip path fires and the DT is silently dropped —
3869        // mirroring the oracle's behavior on the (image 492990,
3870        // cat 982) cell in LVIS val (the only all-zero-area cell on
3871        // that dataset).
3872        let images = vec![img(1, 100, 100)];
3873        let cats = vec![cat(1, "a")];
3874        let anns = vec![ann_with_area(1, 1, 1, (50.0, 50.0, 0.1, 0.1), 0.0)];
3875        let gt = lvis_dataset(
3876            &images,
3877            &anns,
3878            &cats,
3879            &[(1, vec![])],
3880            &[(1, vec![])],
3881            &[(1, crate::dataset::Frequency::Frequent)],
3882        );
3883        let dts =
3884            CocoDetections::from_inputs(vec![dt_input(1, 1, 0.9, (50.0, 50.0, 0.1, 0.1))]).unwrap();
3885        let area = AreaRange::coco_default();
3886        let params = EvaluateParams {
3887            iou_thresholds: iou_thresholds(),
3888            area_ranges: &area,
3889            max_dets_per_image: 100,
3890            use_cats: true,
3891            retain_iou: false,
3892        };
3893
3894        let strict = evaluate_bbox(&gt, &dts, params, ParityMode::Strict).unwrap();
3895        assert!(
3896            strict.cell(0, 0, 0).is_none(),
3897            "AG6: all-zero-area cell must be skipped via AA4 in strict mode"
3898        );
3899
3900        let corrected = evaluate_bbox(&gt, &dts, params, ParityMode::Corrected).unwrap();
3901        let cell = corrected
3902            .cell(0, 0, 0)
3903            .expect("Corrected mode must keep the zero-area GT");
3904        assert_eq!(cell.dt_scores.len(), 1);
3905    }
3906
3907    #[test]
3908    fn ag6_strict_filter_is_noop_on_coco_dataset() {
3909        // Same input as the mixed-cell test but constructed via
3910        // `from_parts` so `federated()` is `None`. The strict filter
3911        // must NOT apply — COCO eval keeps zero-area GTs (the
3912        // pycocotools oracle doesn't filter at load).
3913        let images = vec![img(1, 100, 100)];
3914        let cats = vec![cat(1, "a")];
3915        let anns = vec![
3916            ann(1, 1, 1, (10.0, 10.0, 20.0, 20.0)),
3917            ann_with_area(2, 1, 1, (50.0, 50.0, 0.1, 0.1), 0.0),
3918        ];
3919        let gt = CocoDataset::from_parts(images, anns, cats).unwrap();
3920        let dts = CocoDetections::from_inputs(vec![
3921            dt_input(1, 1, 0.9, (10.0, 10.0, 20.0, 20.0)),
3922            dt_input(1, 1, 0.8, (50.0, 50.0, 0.1, 0.1)),
3923        ])
3924        .unwrap();
3925        let area = AreaRange::coco_default();
3926        let params = EvaluateParams {
3927            iou_thresholds: iou_thresholds(),
3928            area_ranges: &area,
3929            max_dets_per_image: 100,
3930            use_cats: true,
3931            retain_iou: false,
3932        };
3933        let grid = evaluate_bbox(&gt, &dts, params, ParityMode::Strict).unwrap();
3934        let meta = grid.cell_meta(0, 0, 0).unwrap();
3935        assert_eq!(meta.dt_matches[(0, 0)], 1);
3936        assert_eq!(
3937            meta.dt_matches[(0, 1)],
3938            2,
3939            "COCO strict mode must NOT drop the zero-area GT — AG6 is LVIS-only"
3940        );
3941    }
3942}