Skip to main content

vernier_core/
dataset.rs

1//! Dataset abstraction and the COCO ground-truth implementation.
2//!
3//! Per ADR-0005, the matching engine and accumulator are written once
4//! and never edited; they are generic over a dataset trait, never over
5//! a concrete dataset type. Future datasets (custom corpora, Phase 3
6//! keypoint datasets such as CrowdPose) add new `EvalDataset` impls
7//! without touching anything in `matching.rs` or `accumulate.rs`.
8//!
9//! The trait is shaped around two access patterns the matching loop
10//! drives:
11//!
12//! - "Give me the GTs for image `i`." — driven by the per-image
13//!   evaluation outer loop.
14//! - "Give me the GTs for category `k` across all images." — driven
15//!   by the per-category accumulation that happens after matching.
16//!
17//! Both go through index slices (`&[usize]`) into a single flat
18//! storage. The convenience method `ann_iter_for_image` builds an
19//! iterator on top of the slice; callers that want raw indices (e.g.,
20//! to interleave bbox / segm / keypoint lookups) use the slice form.
21//!
22//! ## Quirk dispositions
23//!
24//! The COCO loader honors the dataset-level dispositions ratified in
25//! ADR-0002:
26//!
27//! - **D1** (`corrected`): we store both the JSON `iscrowd` flag and
28//!   the optional `ignore` flag verbatim. The eval-time
29//!   [`CocoAnnotation::effective_ignore`] computes the flag per
30//!   parity mode, instead of overwriting one with the other at load
31//!   time the way pycocotools does.
32//! - **D3** (`aligned`): annotations are not mutated mid-evaluation;
33//!   the per-call `_ignore` (which combines the dataset flag with the
34//!   current area range) is computed at eval time.
35//! - **J3** (`strict`): detection-side area is derived at construction
36//!   from the bbox (`bbox.w * bbox.h`) and never read from JSON.
37//! - **J1** (`aligned`): user-supplied DT ids are preserved verbatim;
38//!   absent ids are auto-assigned sequentially during construction.
39//! - **E2 / J4** (`strict`): detections never carry an `iscrowd` flag
40//!   — the type does not have the field. JSON inputs that include
41//!   `iscrowd=1` are silently dropped, matching pycocotools' overwrite.
42
43use std::collections::{HashMap, HashSet};
44use std::sync::{Arc, OnceLock};
45
46use serde::{Deserialize, Serialize};
47
48use crate::error::EvalError;
49use crate::parity::ParityMode;
50use crate::segmentation::{Segmentation, SegmentationRleCounts};
51
52/// Parse vs `from_parts` split for COCO GT / DT loaders, gated on `bench-timings`.
53#[cfg(feature = "bench-timings")]
54pub(crate) mod dataset_timings {
55    use crate::bench_counters::BenchCounterSet;
56
57    pub(super) const GT_PARSE_NS: usize = 0;
58    pub(super) const GT_FROM_PARTS_NS: usize = 1;
59    pub(super) const DT_PARSE_NS: usize = 2;
60    pub(super) const DT_FROM_INPUTS_NS: usize = 3;
61
62    pub(super) static COUNTERS: BenchCounterSet<4> = BenchCounterSet::new();
63
64    pub(crate) fn read_and_reset() -> (u64, u64, u64, u64) {
65        let [a, b, c, d] = COUNTERS.read_and_reset();
66        (a, b, c, d)
67    }
68}
69
70/// Newtype for image ids. Sourced from the JSON `id` field; preserved
71/// verbatim. Crowd_region's image with `id = 1` becomes
72/// `ImageId(1)`.
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
74#[serde(transparent)]
75pub struct ImageId(pub i64);
76
77/// Newtype for category ids.
78#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
79#[serde(transparent)]
80pub struct CategoryId(pub i64);
81
82/// Newtype for annotation ids.
83#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
84#[serde(transparent)]
85pub struct AnnId(pub i64);
86
87/// Per-image metadata. We keep only what the eval algorithm reads;
88/// fields like `coco_url`, `flickr_url`, `date_captured` are dropped on
89/// load (round-trip is via the typed COCO data, not raw JSON).
90#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
91pub struct ImageMeta {
92    /// Image id.
93    pub id: ImageId,
94    /// Image width in pixels.
95    pub width: u32,
96    /// Image height in pixels.
97    pub height: u32,
98    /// File name as recorded in the dataset JSON; useful for tracing
99    /// fixtures back to source images.
100    #[serde(default, skip_serializing_if = "Option::is_none")]
101    pub file_name: Option<String>,
102}
103
104/// Per-category metadata.
105#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
106pub struct CategoryMeta {
107    /// Category id.
108    pub id: CategoryId,
109    /// Human-readable category name (e.g., `"person"`).
110    pub name: String,
111    /// Optional supercategory grouping (e.g., `"animal"`).
112    #[serde(default, skip_serializing_if = "Option::is_none")]
113    pub supercategory: Option<String>,
114}
115
116/// Axis-aligned bounding box in COCO format `(x, y, w, h)`, where
117/// `(x, y)` is the top-left corner in pixels (typically with sub-pixel
118/// floats) and `(w, h)` are the width and height.
119#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
120#[serde(from = "[f64; 4]", into = "[f64; 4]")]
121pub struct Bbox {
122    /// Top-left x (pixels).
123    pub x: f64,
124    /// Top-left y (pixels).
125    pub y: f64,
126    /// Width (pixels).
127    pub w: f64,
128    /// Height (pixels).
129    pub h: f64,
130}
131
132impl From<[f64; 4]> for Bbox {
133    fn from([x, y, w, h]: [f64; 4]) -> Self {
134        Self { x, y, w, h }
135    }
136}
137
138impl From<Bbox> for [f64; 4] {
139    fn from(b: Bbox) -> Self {
140        [b.x, b.y, b.w, b.h]
141    }
142}
143
144/// A COCO annotation as stored on the dataset side (ground truth).
145///
146/// Detection annotations follow a separate path — see the future
147/// `loadRes`-equivalent — because their `iscrowd` is always 0 (quirk
148/// **E2**) and their `area` is auto-derived (quirk **J3**). Conflating
149/// the two would let a DT bug silently corrupt GT semantics.
150#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
151pub struct CocoAnnotation {
152    /// Annotation id (preserved verbatim from JSON).
153    pub id: AnnId,
154    /// Image this annotation belongs to.
155    pub image_id: ImageId,
156    /// Category this annotation belongs to.
157    pub category_id: CategoryId,
158    /// Pixel area as recorded in JSON. For GT, COCO stores this
159    /// directly; we trust the field.
160    pub area: f64,
161    /// Crowd flag (the COCO `iscrowd` field). pycocotools coerces this
162    /// to bool via truthiness, so 0/1 ints round-trip identically.
163    #[serde(rename = "iscrowd", default, deserialize_with = "deserialize_bool_int")]
164    pub is_crowd: bool,
165    /// Optional explicit `ignore` flag.
166    ///
167    /// `None` means the JSON had no `ignore` field. pycocotools (quirk
168    /// **D1**) silently overwrites whatever was here with `is_crowd`;
169    /// vernier preserves it and lets [`Self::effective_ignore`] resolve
170    /// the strict vs corrected disposition at eval time.
171    #[serde(
172        rename = "ignore",
173        default,
174        deserialize_with = "deserialize_opt_bool_int"
175    )]
176    pub ignore_flag: Option<bool>,
177    /// Bounding box. Required for every COCO ground-truth annotation
178    /// (even keypoint-only annotations carry a bbox; the bbox is what
179    /// `J3` derives DT-area from). Phase 3 adds `keypoints` as an
180    /// additional optional field.
181    pub bbox: Bbox,
182    /// COCO `segmentation` field, in any of the three shapes
183    /// pycocotools accepts (multi-polygon, uncompressed RLE,
184    /// compressed RLE). `None` for keypoint-only annotations or
185    /// fixtures that omit it. The matching engine normalizes via
186    /// [`Segmentation::to_rle`] at eval time.
187    #[serde(default, skip_serializing_if = "Option::is_none")]
188    pub segmentation: Option<Segmentation>,
189    /// Flat keypoint triplets `[x_1, y_1, v_1, x_2, y_2, v_2, ...]`
190    /// (per ADR-0012). `None` for non-keypoint annotations; the eval
191    /// pipeline raises [`EvalError::InvalidAnnotation`] when a GT is
192    /// missing keypoints under `iouType="keypoints"`.
193    #[serde(default, skip_serializing_if = "Option::is_none")]
194    pub keypoints: Option<Vec<f64>>,
195    /// COCO `num_keypoints` count of *visible* keypoints (`v > 0`),
196    /// per ADR-0012. pycocotools precomputes this on GT (driving the
197    /// quirk **D2** implicit-ignore branch); on DT it is not required
198    /// and is derived from `keypoints` when needed.
199    #[serde(default, skip_serializing_if = "Option::is_none")]
200    pub num_keypoints: Option<u32>,
201}
202
203impl CocoAnnotation {
204    /// Resolves the effective ignore flag for this annotation under a
205    /// given parity mode (per ADR-0002 / quirk **D1**).
206    ///
207    /// - `Strict` reproduces pycocotools: the user's `ignore` field is
208    ///   discarded, and `ignore` is set to `is_crowd`.
209    /// - `Corrected` honors the user's explicit `ignore` field when
210    ///   present; falls back to `is_crowd` when absent.
211    pub fn effective_ignore(&self, mode: ParityMode) -> bool {
212        match mode {
213            ParityMode::Strict => self.is_crowd,
214            ParityMode::Corrected => self.ignore_flag.unwrap_or(self.is_crowd),
215        }
216    }
217}
218
219/// Common interface every annotation type on every dataset implements.
220///
221/// The matching engine (per ADR-0005) reads only this trait — it does
222/// not see [`CocoAnnotation`] or any future per-dataset annotation type
223/// directly.
224pub trait Annotation {
225    /// Image this annotation belongs to.
226    fn image_id(&self) -> ImageId;
227    /// Category this annotation belongs to.
228    fn category_id(&self) -> CategoryId;
229    /// Pixel area.
230    fn area(&self) -> f64;
231    /// Crowd flag (raw, before parity resolution).
232    fn is_crowd(&self) -> bool;
233    /// Effective ignore flag under the given parity mode.
234    fn effective_ignore(&self, mode: ParityMode) -> bool;
235}
236
237impl Annotation for CocoAnnotation {
238    fn image_id(&self) -> ImageId {
239        self.image_id
240    }
241    fn category_id(&self) -> CategoryId {
242        self.category_id
243    }
244    fn area(&self) -> f64 {
245        self.area
246    }
247    fn is_crowd(&self) -> bool {
248        self.is_crowd
249    }
250    fn effective_ignore(&self, mode: ParityMode) -> bool {
251        Self::effective_ignore(self, mode)
252    }
253}
254
255/// Trait every dataset (COCO, CrowdPose, custom) implements.
256///
257/// `Send + Sync` is required by the future `BackgroundEvaluator`
258/// (separate ADR) so the dataset can be shared across worker threads
259/// without copying.
260pub trait EvalDataset: Send + Sync {
261    /// Concrete annotation type. For [`CocoDataset`] this is
262    /// [`CocoAnnotation`]; future datasets may use their own type with
263    /// extra metadata.
264    type Annotation: Annotation;
265
266    /// All images in the dataset, in input order.
267    fn images(&self) -> &[ImageMeta];
268
269    /// All categories in the dataset, in input order.
270    fn categories(&self) -> &[CategoryMeta];
271
272    /// Flat slice of every annotation in the dataset, in input order.
273    fn annotations(&self) -> &[Self::Annotation];
274
275    /// Indices into [`Self::annotations`] for a given image.
276    /// Returns an empty slice when the image is unknown.
277    fn ann_indices_for_image(&self, image_id: ImageId) -> &[usize];
278
279    /// Indices into [`Self::annotations`] for a given category.
280    /// Returns an empty slice when the category is unknown.
281    fn ann_indices_for_category(&self, cat_id: CategoryId) -> &[usize];
282
283    /// Convenience iterator over annotations for a given image.
284    fn ann_iter_for_image(&self, image_id: ImageId) -> AnnotationIter<'_, Self::Annotation> {
285        AnnotationIter {
286            anns: self.annotations(),
287            indices: self.ann_indices_for_image(image_id).iter(),
288        }
289    }
290
291    /// Convenience iterator over annotations for a given category.
292    fn ann_iter_for_category(&self, cat_id: CategoryId) -> AnnotationIter<'_, Self::Annotation> {
293        AnnotationIter {
294            anns: self.annotations(),
295            indices: self.ann_indices_for_category(cat_id).iter(),
296        }
297    }
298}
299
300/// Iterator that walks a slice of annotation indices and yields
301/// references into the flat annotation storage. Returned by the
302/// `*_iter_for_*` methods on [`EvalDataset`].
303pub struct AnnotationIter<'a, A> {
304    anns: &'a [A],
305    indices: std::slice::Iter<'a, usize>,
306}
307
308impl<'a, A> Iterator for AnnotationIter<'a, A> {
309    type Item = &'a A;
310
311    fn next(&mut self) -> Option<Self::Item> {
312        let idx = *self.indices.next()?;
313        self.anns.get(idx)
314    }
315
316    fn size_hint(&self) -> (usize, Option<usize>) {
317        self.indices.size_hint()
318    }
319}
320
321impl<'a, A> ExactSizeIterator for AnnotationIter<'a, A> {}
322
323/// On-disk shape of a COCO ground-truth JSON file.
324///
325/// Only the fields vernier reads are typed; unknown top-level fields
326/// (`info`, `licenses`, …) are dropped on load. Round-tripping in tests
327/// uses the same struct; user JSON that round-trips through vernier
328/// will lose those fields. We document this loudly because pycocotools
329/// 2.0.11 added a single line preserving the `info` field on `loadRes`.
330#[derive(Debug, Clone, Serialize, Deserialize)]
331pub struct CocoJson {
332    /// All images.
333    pub images: Vec<ImageMeta>,
334    /// All annotations.
335    pub annotations: Vec<CocoAnnotation>,
336    /// All categories.
337    pub categories: Vec<CategoryMeta>,
338}
339
340/// LVIS category-frequency tier (quirk **AB1** of ADR-0026).
341///
342/// Each LVIS category is tagged at dataset publication with one of
343/// three buckets, keyed by how many *training* images contain at least
344/// one annotation of that category:
345///
346/// - [`Frequency::Rare`]: `< 10` train images
347/// - [`Frequency::Common`]: `[10, 100)` train images
348/// - [`Frequency::Frequent`]: `≥ 100` train images
349///
350/// The boundaries are pinned by the upstream eval code at
351/// `lvis/eval.py:537-541`; the LVIS paper's prose ("1-10 / 11-100 /
352/// `>100`") is loose — a 10-image category is `Common`, not `Rare`.
353/// The `frequency` field is precomputed at dataset publication;
354/// vernier reads it as-is and never derives it from `image_count`
355/// (quirk **AB2**).
356///
357/// Serializes to/from the single-letter form (`"r"` / `"c"` / `"f"`)
358/// the LVIS JSON schema uses.
359#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
360pub enum Frequency {
361    /// `< 10` train images.
362    #[serde(rename = "r")]
363    Rare,
364    /// `[10, 100)` train images.
365    #[serde(rename = "c")]
366    Common,
367    /// `≥ 100` train images.
368    #[serde(rename = "f")]
369    Frequent,
370}
371
372impl Frequency {
373    /// LVIS single-letter form (`"r"` / `"c"` / `"f"`). Mirrors the
374    /// `serde(rename = ...)` tags on the variants — same canonical
375    /// form the JSON schema uses, available without going through
376    /// serde for places (FFI, log lines) that just need the string.
377    pub const fn as_letter(self) -> &'static str {
378        match self {
379            Self::Rare => "r",
380            Self::Common => "c",
381            Self::Frequent => "f",
382        }
383    }
384}
385
386/// On-disk LVIS image record. Carries the COCO image fields plus the
387/// LVIS-specific federated lists. The `pos_category_ids` set is
388/// **derived** from GT annotations at load (quirk **AA1**) and is not
389/// a JSON field — only `neg` and `not_exhaustive` are explicit.
390#[derive(Debug, Clone, Deserialize)]
391struct LvisImageRaw {
392    id: ImageId,
393    width: u32,
394    height: u32,
395    #[serde(default)]
396    file_name: Option<String>,
397    /// LVIS-only: categories verified absent from this image. `None`
398    /// in the wild means a malformed LVIS JSON; v1 spec requires the
399    /// field on every image (possibly empty).
400    #[serde(default)]
401    neg_category_ids: Option<Vec<CategoryId>>,
402    /// LVIS-only: categories whose annotations on this image are not
403    /// guaranteed exhaustive. Subset of `pos` by spec; consumed by
404    /// quirk **AA3** to extend `dt_ignore` on unmatched DTs in the
405    /// cell.
406    #[serde(default)]
407    not_exhaustive_category_ids: Option<Vec<CategoryId>>,
408}
409
410/// On-disk LVIS category record. Carries the COCO category fields
411/// plus the `frequency` tag (quirk **AB1**). `image_count` and
412/// `instance_count` are stored on the upstream JSON but **not read**
413/// by the eval code (quirk **AB2**); we drop them on load.
414#[derive(Debug, Clone, Deserialize)]
415struct LvisCategoryRaw {
416    id: CategoryId,
417    name: String,
418    #[serde(default)]
419    supercategory: Option<String>,
420    /// Required field on every LVIS v1 category. `None` here means the
421    /// JSON entry omitted it; collected and surfaced via
422    /// [`EvalError::MissingFrequency`] (quirk **AB6** corrected).
423    #[serde(default)]
424    frequency: Option<Frequency>,
425}
426
427/// On-disk shape of an LVIS v1 ground-truth JSON file. Structurally
428/// COCO JSON (quirk **AG1**) plus the federated extras on per-image
429/// and per-category records. Annotations are byte-identical between
430/// COCO and LVIS schemas, so [`CocoAnnotation`] is reused.
431#[derive(Debug, Clone, Deserialize)]
432struct LvisJson {
433    images: Vec<LvisImageRaw>,
434    annotations: Vec<CocoAnnotation>,
435    categories: Vec<LvisCategoryRaw>,
436}
437
438/// LVIS federated metadata bundle (ADR-0026). Carried as a single
439/// `Option` on [`CocoDataset`] because the four fields are all
440/// populated together by [`CocoDataset::from_lvis_json_bytes`] and
441/// all `None` after the COCO loader path. Storing one optional
442/// struct (rather than four separate `Option<...>` fields) reflects
443/// the all-or-none semantics and lets the orchestrator gate
444/// federated branches on a single `is_some()` check.
445#[derive(Debug, Clone)]
446pub struct FederatedMetadata {
447    /// Per-image positive-category set, derived from GT annotations
448    /// at load (quirk **AA1**, not a JSON field).
449    pub pos_category_ids: HashMap<ImageId, HashSet<CategoryId>>,
450    /// Per-image negative-category set, read verbatim from the JSON
451    /// (quirk **AA2**).
452    pub neg_category_ids: HashMap<ImageId, HashSet<CategoryId>>,
453    /// Per-image not-exhaustive-category set, read verbatim from the
454    /// JSON (quirk **AA3**).
455    pub not_exhaustive_category_ids: HashMap<ImageId, HashSet<CategoryId>>,
456    /// Per-category frequency tag (quirk **AB1**). Required on every
457    /// category by `from_lvis_json_bytes`; missing entries raise
458    /// [`EvalError::MissingFrequency`] at load (quirk **AB6**
459    /// corrected).
460    pub category_frequency: HashMap<CategoryId, Frequency>,
461}
462
463/// COCO ground-truth dataset.
464///
465/// Storage is a single `Arc<Vec<CocoAnnotation>>` plus per-image and
466/// per-category index vectors. The `Arc` makes the dataset cheaply
467/// shareable across worker threads (the `BackgroundEvaluator` from a
468/// future ADR depends on this); the index vectors are owned by the
469/// `CocoDataset` because they're cheap to rebuild and rebuild needs
470/// to happen exactly when the annotation set changes.
471///
472/// ## LVIS federated metadata (ADR-0026)
473///
474/// `federated` is `Some` exactly when the dataset was loaded via
475/// [`CocoDataset::from_lvis_json_bytes`]. The orchestrator's
476/// federated branches gate on `federated.is_some()`; absence is the
477/// COCO default, where the matching engine runs unchanged.
478#[derive(Debug, Clone)]
479pub struct CocoDataset {
480    images: Arc<Vec<ImageMeta>>,
481    categories: Arc<Vec<CategoryMeta>>,
482    annotations: Arc<Vec<CocoAnnotation>>,
483    by_image: HashMap<ImageId, Vec<usize>>,
484    by_category: HashMap<CategoryId, Vec<usize>>,
485    by_image_cat: HashMap<(ImageId, CategoryId), Vec<usize>>,
486    federated: Option<FederatedMetadata>,
487    /// 32-byte BLAKE3 fingerprint of the dataset's canonical form.
488    /// Cached lazily on first call to [`Self::dataset_hash`]; carried
489    /// in distributed-eval partial headers (ADR-0031). Wrapped in
490    /// `Arc<OnceLock>` so cheap clones share the same cache, matching
491    /// the existing Arc-shared layout for `images` / `categories` /
492    /// `annotations`.
493    cached_hash: Arc<OnceLock<[u8; 32]>>,
494}
495
496impl CocoDataset {
497    /// Loads a dataset from a JSON byte slice.
498    ///
499    /// Validates that every annotation references a known image and a
500    /// known category; missing references raise [`EvalError::InvalidAnnotation`]
501    /// rather than producing a silently-empty dataset.
502    pub fn from_json_bytes(bytes: &[u8]) -> Result<Self, EvalError> {
503        #[cfg(feature = "bench-timings")]
504        let t0 = std::time::Instant::now();
505        let raw: CocoJson = serde_json::from_slice(bytes)?;
506        #[cfg(feature = "bench-timings")]
507        let parse_ns = u64::try_from(t0.elapsed().as_nanos()).unwrap_or(u64::MAX);
508        #[cfg(feature = "bench-timings")]
509        let t1 = std::time::Instant::now();
510        let result = Self::from_parts(raw.images, raw.annotations, raw.categories);
511        #[cfg(feature = "bench-timings")]
512        {
513            let from_parts_ns = u64::try_from(t1.elapsed().as_nanos()).unwrap_or(u64::MAX);
514            dataset_timings::COUNTERS.add(dataset_timings::GT_PARSE_NS, parse_ns);
515            dataset_timings::COUNTERS.add(dataset_timings::GT_FROM_PARTS_NS, from_parts_ns);
516        }
517        result
518    }
519
520    /// Loads a dataset from already-typed parts.
521    pub fn from_parts(
522        images: Vec<ImageMeta>,
523        annotations: Vec<CocoAnnotation>,
524        categories: Vec<CategoryMeta>,
525    ) -> Result<Self, EvalError> {
526        let known_images: HashSet<ImageId> = images.iter().map(|i| i.id).collect();
527        let known_categories: HashSet<CategoryId> = categories.iter().map(|c| c.id).collect();
528
529        let mut by_image: HashMap<ImageId, Vec<usize>> = HashMap::with_capacity(images.len());
530        let mut by_category: HashMap<CategoryId, Vec<usize>> =
531            HashMap::with_capacity(categories.len());
532        let mut by_image_cat: HashMap<(ImageId, CategoryId), Vec<usize>> = HashMap::new();
533
534        for (idx, ann) in annotations.iter().enumerate() {
535            if !known_images.contains(&ann.image_id) {
536                return Err(EvalError::InvalidAnnotation {
537                    detail: format!(
538                        "annotation id={} references unknown image_id={}",
539                        ann.id.0, ann.image_id.0
540                    ),
541                });
542            }
543            if !known_categories.contains(&ann.category_id) {
544                return Err(EvalError::InvalidAnnotation {
545                    detail: format!(
546                        "annotation id={} references unknown category_id={}",
547                        ann.id.0, ann.category_id.0
548                    ),
549                });
550            }
551            by_image.entry(ann.image_id).or_default().push(idx);
552            by_category.entry(ann.category_id).or_default().push(idx);
553            by_image_cat
554                .entry((ann.image_id, ann.category_id))
555                .or_default()
556                .push(idx);
557        }
558
559        Ok(Self {
560            images: Arc::new(images),
561            categories: Arc::new(categories),
562            annotations: Arc::new(annotations),
563            by_image,
564            by_category,
565            by_image_cat,
566            federated: None,
567            cached_hash: Arc::new(OnceLock::new()),
568        })
569    }
570
571    /// Loads an LVIS v1 ground-truth dataset from a JSON byte slice.
572    ///
573    /// LVIS JSON is structurally COCO JSON plus per-image
574    /// `neg_category_ids` / `not_exhaustive_category_ids` and
575    /// per-category `frequency` (quirk **AG1**). This loader reads the
576    /// extras into the federated metadata fields on the returned
577    /// dataset; the underlying `images` / `annotations` / `categories`
578    /// projections match what [`Self::from_json_bytes`] would produce
579    /// on the same JSON.
580    ///
581    /// ## Validation
582    ///
583    /// - **AA1.** `pos_category_ids[I]` is **derived** from GT
584    ///   annotations: `pos[I] = {ann.category_id for ann in
585    ///   annotations[I]}`. Not a JSON field. A category with zero
586    ///   annotations on `I` is *not* in `pos[I]`.
587    /// - **AA7 (corrected).** Disjointness invariants are enforced at
588    ///   load:
589    ///     - `pos[I] ∩ neg[I] = ∅` — a category with GT on an image
590    ///       cannot also be in `neg[I]`.
591    ///     - `not_exhaustive[I] ⊆ pos[I]` — by spec, not_exhaustive
592    ///       is a subset of pos.
593    ///     - `not_exhaustive[I] ∩ neg[I] = ∅` — equivalent restatement
594    ///       given the prior two.
595    ///
596    ///   The first violation surfaces as
597    ///   [`EvalError::LvisFederatedConflict`] with the offending
598    ///   `(image_id, category_id)`.
599    /// - **AB6 (corrected).** Every category must carry a `frequency`
600    ///   tag. Missing tags are collected across the full categories
601    ///   list and surfaced once via [`EvalError::MissingFrequency`]
602    ///   with a sorted id list — more debuggable than lvis-api's
603    ///   mid-eval `KeyError` on the first miss.
604    ///
605    /// Per-image `neg_category_ids` and `not_exhaustive_category_ids`
606    /// are optional in the JSON: an absent field is treated as an
607    /// empty set, which matches the LVIS v1 semantic ("no negatives /
608    /// nothing flagged non-exhaustive on this image").
609    pub fn from_lvis_json_bytes(bytes: &[u8]) -> Result<Self, EvalError> {
610        let raw: LvisJson = serde_json::from_slice(bytes)?;
611
612        let images: Vec<ImageMeta> = raw
613            .images
614            .iter()
615            .map(|im| ImageMeta {
616                id: im.id,
617                width: im.width,
618                height: im.height,
619                file_name: im.file_name.clone(),
620            })
621            .collect();
622        let categories: Vec<CategoryMeta> = raw
623            .categories
624            .iter()
625            .map(|c| CategoryMeta {
626                id: c.id,
627                name: c.name.clone(),
628                supercategory: c.supercategory.clone(),
629            })
630            .collect();
631
632        // AB6 (corrected): collect all categories missing `frequency`
633        // and raise once with the full list. Sorted ascending for
634        // stable error messages.
635        let mut missing_freq: Vec<i64> = raw
636            .categories
637            .iter()
638            .filter(|c| c.frequency.is_none())
639            .map(|c| c.id.0)
640            .collect();
641        if !missing_freq.is_empty() {
642            missing_freq.sort_unstable();
643            return Err(EvalError::MissingFrequency {
644                category_ids: missing_freq,
645            });
646        }
647        let category_frequency: HashMap<CategoryId, Frequency> = raw
648            .categories
649            .iter()
650            .filter_map(|c| c.frequency.map(|f| (c.id, f)))
651            .collect();
652
653        // Build the dataset spine via the existing constructor — that
654        // gives us the ref-integrity validation (J5 / AG1) for free.
655        let mut dataset = Self::from_parts(images, raw.annotations, categories)?;
656
657        // AA1: derive pos[I] from GTs. Defaults each image to an empty
658        // set so callers can ask without special-casing.
659        let mut pos: HashMap<ImageId, HashSet<CategoryId>> =
660            HashMap::with_capacity(raw.images.len());
661        for im in &raw.images {
662            pos.entry(im.id).or_default();
663        }
664        for ann in dataset.annotations.iter() {
665            pos.entry(ann.image_id).or_default().insert(ann.category_id);
666        }
667
668        // Project explicit `neg` / `not_exhaustive` fields onto sets;
669        // treat absent / empty as the empty set.
670        let mut neg: HashMap<ImageId, HashSet<CategoryId>> =
671            HashMap::with_capacity(raw.images.len());
672        let mut nel: HashMap<ImageId, HashSet<CategoryId>> =
673            HashMap::with_capacity(raw.images.len());
674        for im in &raw.images {
675            let neg_set: HashSet<CategoryId> = im
676                .neg_category_ids
677                .as_deref()
678                .unwrap_or(&[])
679                .iter()
680                .copied()
681                .collect();
682            let nel_set: HashSet<CategoryId> = im
683                .not_exhaustive_category_ids
684                .as_deref()
685                .unwrap_or(&[])
686                .iter()
687                .copied()
688                .collect();
689            neg.insert(im.id, neg_set);
690            nel.insert(im.id, nel_set);
691        }
692
693        // AA7 (corrected): disjointness validation.
694        for im in &raw.images {
695            let image_id = im.id;
696            let pos_i = pos.get(&image_id).map_or_else(HashSet::new, Clone::clone);
697            let neg_i = &neg[&image_id];
698            let nel_i = &nel[&image_id];
699
700            // pos ∩ neg: a category with GT on this image cannot also
701            // be in neg.
702            if let Some(c) = pos_i.intersection(neg_i).next().copied() {
703                return Err(EvalError::LvisFederatedConflict {
704                    image_id: image_id.0,
705                    category_id: c.0,
706                    detail: "category has GT on image but is also in neg_category_ids",
707                });
708            }
709            // not_exhaustive ⊆ pos: by spec.
710            if let Some(c) = nel_i.difference(&pos_i).next().copied() {
711                return Err(EvalError::LvisFederatedConflict {
712                    image_id: image_id.0,
713                    category_id: c.0,
714                    detail:
715                        "category in not_exhaustive_category_ids but not in pos (no GT on image)",
716                });
717            }
718            // not_exhaustive ∩ neg: implied by the first two but
719            // checked explicitly so a malformed JSON gets the most
720            // direct error.
721            if let Some(c) = nel_i.intersection(neg_i).next().copied() {
722                return Err(EvalError::LvisFederatedConflict {
723                    image_id: image_id.0,
724                    category_id: c.0,
725                    detail: "category in both not_exhaustive_category_ids and neg_category_ids",
726                });
727            }
728        }
729
730        dataset.federated = Some(FederatedMetadata {
731            pos_category_ids: pos,
732            neg_category_ids: neg,
733            not_exhaustive_category_ids: nel,
734            category_frequency,
735        });
736        Ok(dataset)
737    }
738
739    /// LVIS federated metadata bundle. `Some` only when the dataset
740    /// was built by [`Self::from_lvis_json_bytes`]; the orchestrator's
741    /// AA3/AA4 branches gate on this.
742    pub fn federated(&self) -> Option<&FederatedMetadata> {
743        self.federated.as_ref()
744    }
745
746    /// Per-image positive-category set, derived from GTs at load time
747    /// (quirk **AA1**). `Some` only when the dataset is federated.
748    pub fn pos_category_ids(&self) -> Option<&HashMap<ImageId, HashSet<CategoryId>>> {
749        self.federated.as_ref().map(|f| &f.pos_category_ids)
750    }
751
752    /// Per-image negative-category set, read verbatim from the LVIS
753    /// JSON (quirk **AA2**). `Some` only when the dataset is federated.
754    pub fn neg_category_ids(&self) -> Option<&HashMap<ImageId, HashSet<CategoryId>>> {
755        self.federated.as_ref().map(|f| &f.neg_category_ids)
756    }
757
758    /// Per-image not-exhaustive-category set, read verbatim from the
759    /// LVIS JSON (quirk **AA3**). `Some` only when the dataset is
760    /// federated.
761    pub fn not_exhaustive_category_ids(&self) -> Option<&HashMap<ImageId, HashSet<CategoryId>>> {
762        self.federated
763            .as_ref()
764            .map(|f| &f.not_exhaustive_category_ids)
765    }
766
767    /// Per-category frequency tag, read verbatim from the LVIS JSON
768    /// (quirk **AB1**). `Some` only when the dataset is federated;
769    /// missing-on-some-categories inputs are rejected at load
770    /// (quirk **AB6**).
771    pub fn category_frequency(&self) -> Option<&HashMap<CategoryId, Frequency>> {
772        self.federated.as_ref().map(|f| &f.category_frequency)
773    }
774
775    /// `true` when the dataset carries LVIS federated metadata.
776    /// Cheap shortcut for orchestration code that gates behaviour on
777    /// the federated flag.
778    pub fn is_federated(&self) -> bool {
779        self.federated.is_some()
780    }
781
782    /// Round-trips the dataset to the on-disk JSON shape, preserving
783    /// every field vernier carries. Useful for fixture authoring and
784    /// for debugging serde mismatches.
785    ///
786    /// LVIS federated metadata is **not** included in the output —
787    /// the round trip targets the COCO schema only. Callers needing
788    /// to round-trip LVIS JSON must use the source bytes directly.
789    pub fn to_json_value(&self) -> CocoJson {
790        CocoJson {
791            images: (*self.images).clone(),
792            annotations: (*self.annotations).clone(),
793            categories: (*self.categories).clone(),
794        }
795    }
796}
797
798impl EvalDataset for CocoDataset {
799    type Annotation = CocoAnnotation;
800
801    fn images(&self) -> &[ImageMeta] {
802        &self.images
803    }
804
805    fn categories(&self) -> &[CategoryMeta] {
806        &self.categories
807    }
808
809    fn annotations(&self) -> &[CocoAnnotation] {
810        &self.annotations
811    }
812
813    fn ann_indices_for_image(&self, image_id: ImageId) -> &[usize] {
814        self.by_image.get(&image_id).map_or(&[][..], Vec::as_slice)
815    }
816
817    fn ann_indices_for_category(&self, cat_id: CategoryId) -> &[usize] {
818        self.by_category.get(&cat_id).map_or(&[][..], Vec::as_slice)
819    }
820}
821
822impl CocoDataset {
823    /// Indices into [`Self::annotations`] for a given `(image, category)`
824    /// cell. Empty when no GT of that category exists on that image.
825    pub fn ann_indices_for(&self, image: ImageId, cat: CategoryId) -> &[usize] {
826        self.by_image_cat
827            .get(&(image, cat))
828            .map_or(&[][..], Vec::as_slice)
829    }
830}
831
832// ---------------------------------------------------------------------------
833// dataset_hash — canonical-form fingerprint for ADR-0031 partials.
834//
835// The hash is the BLAKE3 digest of a deterministic byte stream built
836// from the dataset's images + categories + annotations + federated
837// metadata. Independent of input order: each section is sorted by id
838// before hashing. The canonical form is the load-bearing wire-format
839// invariant that makes "this partial was computed against the same GT
840// I have" a strict, refusable check; format_version bumps when the
841// canonical form changes (per ADR-0031 §"Wire format" backward-compat
842// rules).
843//
844// Domain separators (4-byte ASCII tags) precede each section so a
845// rearranged stream cannot collide with the canonical one.
846// ---------------------------------------------------------------------------
847
848/// Domain-separated section tag for the canonical-form stream.
849const HASH_TAG_DATASET: &[u8; 4] = b"DSET";
850const HASH_TAG_IMAGES: &[u8; 4] = b"IMGS";
851const HASH_TAG_CATEGORIES: &[u8; 4] = b"CATS";
852const HASH_TAG_ANNOTATIONS: &[u8; 4] = b"ANNS";
853const HASH_TAG_FEDERATED: &[u8; 4] = b"FEDM";
854
855/// Bumped when the canonical-form layout changes. Read into the
856/// stream once, before any section, so a v1 hash can never collide
857/// with a v2 hash even on identical underlying data.
858const HASH_CANONICAL_VERSION: u8 = 1;
859
860#[inline]
861fn hash_u8(h: &mut blake3::Hasher, v: u8) {
862    h.update(&[v]);
863}
864#[inline]
865fn hash_u32(h: &mut blake3::Hasher, v: u32) {
866    h.update(&v.to_le_bytes());
867}
868#[inline]
869fn hash_i64(h: &mut blake3::Hasher, v: i64) {
870    h.update(&v.to_le_bytes());
871}
872#[inline]
873fn hash_u64(h: &mut blake3::Hasher, v: u64) {
874    h.update(&v.to_le_bytes());
875}
876#[inline]
877fn hash_f64(h: &mut blake3::Hasher, v: f64) {
878    // Bit-exact representation; canonical for finite values. NaN
879    // payloads matter (two NaNs with different bits hash differently);
880    // the dataset loader rejects non-finite area / bbox / keypoints
881    // upstream so the surface here is f64s the user actually trusts.
882    h.update(&v.to_bits().to_le_bytes());
883}
884#[inline]
885fn hash_bool(h: &mut blake3::Hasher, v: bool) {
886    hash_u8(h, u8::from(v));
887}
888#[inline]
889fn hash_bytes(h: &mut blake3::Hasher, bytes: &[u8]) {
890    hash_u64(h, bytes.len() as u64);
891    h.update(bytes);
892}
893#[inline]
894fn hash_string(h: &mut blake3::Hasher, s: &str) {
895    hash_bytes(h, s.as_bytes());
896}
897#[inline]
898fn hash_option<T>(
899    h: &mut blake3::Hasher,
900    opt: Option<T>,
901    write: impl FnOnce(&mut blake3::Hasher, T),
902) {
903    match opt {
904        None => hash_u8(h, 0),
905        Some(v) => {
906            hash_u8(h, 1);
907            write(h, v);
908        }
909    }
910}
911
912fn hash_bbox(h: &mut blake3::Hasher, b: &Bbox) {
913    hash_f64(h, b.x);
914    hash_f64(h, b.y);
915    hash_f64(h, b.w);
916    hash_f64(h, b.h);
917}
918
919fn hash_segmentation(h: &mut blake3::Hasher, seg: Option<&Segmentation>) {
920    match seg {
921        None => hash_u8(h, 0),
922        Some(Segmentation::Polygons(polys)) => {
923            hash_u8(h, 1);
924            hash_u64(h, polys.len() as u64);
925            for poly in polys {
926                hash_u64(h, poly.len() as u64);
927                for &v in poly {
928                    hash_f64(h, v);
929                }
930            }
931        }
932        Some(Segmentation::Rle(rle)) => {
933            let [rh, rw] = rle.size;
934            match &rle.counts {
935                SegmentationRleCounts::Compressed(s) => {
936                    hash_u8(h, 2);
937                    hash_u32(h, rh);
938                    hash_u32(h, rw);
939                    hash_string(h, s);
940                }
941                SegmentationRleCounts::Uncompressed(counts) => {
942                    hash_u8(h, 3);
943                    hash_u32(h, rh);
944                    hash_u32(h, rw);
945                    hash_u64(h, counts.len() as u64);
946                    for &c in counts.iter() {
947                        hash_u32(h, c);
948                    }
949                }
950            }
951        }
952    }
953}
954
955/// Walk a slice in id-sorted order, prefixed by a domain tag and the
956/// element count, hashing each element via `write`. The id projection
957/// (`key`) returns the i64 id; sort is by that key, unstable (ids are
958/// unique by construction). Avoids materializing a fresh sorted Vec
959/// of items by sorting an index permutation instead.
960fn hash_id_sorted<T>(
961    h: &mut blake3::Hasher,
962    tag: &[u8; 4],
963    items: &[T],
964    key: impl Fn(&T) -> i64,
965    write: impl Fn(&mut blake3::Hasher, &T),
966) {
967    h.update(tag);
968    let mut order: Vec<usize> = (0..items.len()).collect();
969    order.sort_unstable_by_key(|&i| key(&items[i]));
970    hash_u64(h, order.len() as u64);
971    for &i in &order {
972        write(h, &items[i]);
973    }
974}
975
976fn hash_image_meta(h: &mut blake3::Hasher, im: &ImageMeta) {
977    let ImageMeta {
978        id,
979        width,
980        height,
981        file_name,
982    } = im;
983    hash_i64(h, id.0);
984    hash_u32(h, *width);
985    hash_u32(h, *height);
986    hash_option(h, file_name.as_deref(), hash_string);
987}
988
989fn hash_category_meta(h: &mut blake3::Hasher, c: &CategoryMeta) {
990    let CategoryMeta {
991        id,
992        name,
993        supercategory,
994    } = c;
995    hash_i64(h, id.0);
996    hash_string(h, name);
997    hash_option(h, supercategory.as_deref(), hash_string);
998}
999
1000fn hash_coco_annotation(h: &mut blake3::Hasher, a: &CocoAnnotation) {
1001    // Exhaustive destructure: adding a field to CocoAnnotation is a
1002    // compile error here, forcing the canonical form to stay in sync.
1003    let CocoAnnotation {
1004        id,
1005        image_id,
1006        category_id,
1007        area,
1008        is_crowd,
1009        ignore_flag,
1010        bbox,
1011        segmentation,
1012        keypoints,
1013        num_keypoints,
1014    } = a;
1015    hash_i64(h, id.0);
1016    hash_i64(h, image_id.0);
1017    hash_i64(h, category_id.0);
1018    hash_f64(h, *area);
1019    hash_bool(h, *is_crowd);
1020    hash_option(h, *ignore_flag, hash_bool);
1021    hash_bbox(h, bbox);
1022    hash_segmentation(h, segmentation.as_ref());
1023    hash_option(h, keypoints.as_deref(), |h, kps| {
1024        hash_u64(h, kps.len() as u64);
1025        for &v in kps {
1026            hash_f64(h, v);
1027        }
1028    });
1029    hash_option(h, *num_keypoints, hash_u32);
1030}
1031
1032fn hash_federated(h: &mut blake3::Hasher, fed: &FederatedMetadata) {
1033    h.update(HASH_TAG_FEDERATED);
1034
1035    // category_frequency: sort by category id, write (id, letter byte).
1036    let mut freq_pairs: Vec<(i64, &Frequency)> = fed
1037        .category_frequency
1038        .iter()
1039        .map(|(k, v)| (k.0, v))
1040        .collect();
1041    freq_pairs.sort_unstable_by_key(|(k, _)| *k);
1042    hash_u64(h, freq_pairs.len() as u64);
1043    for (cid, freq) in freq_pairs {
1044        hash_i64(h, cid);
1045        // `as_letter` is a single ASCII char; one byte is enough.
1046        hash_u8(h, freq.as_letter().as_bytes()[0]);
1047    }
1048
1049    // pos / neg / not_exhaustive: each is HashMap<ImageId, HashSet<CategoryId>>.
1050    // Hash all three sections via the same canonical form: sort by image id,
1051    // then for each image sort the category ids ascending and write count + ids.
1052    type FedSection<'a> = (&'a [u8; 3], &'a HashMap<ImageId, HashSet<CategoryId>>);
1053    let sections: [FedSection<'_>; 3] = [
1054        (b"POS", &fed.pos_category_ids),
1055        (b"NEG", &fed.neg_category_ids),
1056        (b"NEX", &fed.not_exhaustive_category_ids),
1057    ];
1058    for (tag, map) in sections {
1059        h.update(tag);
1060        let mut entries: Vec<(i64, Vec<i64>)> = map
1061            .iter()
1062            .map(|(image_id, cats)| {
1063                let mut cat_ids: Vec<i64> = cats.iter().map(|c| c.0).collect();
1064                cat_ids.sort_unstable();
1065                (image_id.0, cat_ids)
1066            })
1067            .collect();
1068        entries.sort_unstable_by_key(|(image_id, _)| *image_id);
1069        hash_u64(h, entries.len() as u64);
1070        for (image_id, cat_ids) in entries {
1071            hash_i64(h, image_id);
1072            hash_u64(h, cat_ids.len() as u64);
1073            for cid in cat_ids {
1074                hash_i64(h, cid);
1075            }
1076        }
1077    }
1078}
1079
1080impl CocoDataset {
1081    /// 32-byte BLAKE3 fingerprint of this dataset's canonical form.
1082    /// Stable across input orderings: images, categories, annotations
1083    /// are sorted by id before hashing. Lazily cached on first call;
1084    /// shared across [`Clone`]s via the underlying `Arc<OnceLock>`.
1085    ///
1086    /// Carried in distributed-eval partial headers (ADR-0031); a
1087    /// receiving rank refuses to merge partials whose `dataset_hash`
1088    /// disagrees with its live dataset's.
1089    pub fn dataset_hash(&self) -> [u8; 32] {
1090        *self.cached_hash.get_or_init(|| self.compute_dataset_hash())
1091    }
1092
1093    fn compute_dataset_hash(&self) -> [u8; 32] {
1094        let mut h = blake3::Hasher::new();
1095        h.update(HASH_TAG_DATASET);
1096        hash_u8(&mut h, HASH_CANONICAL_VERSION);
1097
1098        hash_id_sorted(
1099            &mut h,
1100            HASH_TAG_IMAGES,
1101            &self.images,
1102            |im| im.id.0,
1103            hash_image_meta,
1104        );
1105        hash_id_sorted(
1106            &mut h,
1107            HASH_TAG_CATEGORIES,
1108            &self.categories,
1109            |c| c.id.0,
1110            hash_category_meta,
1111        );
1112        hash_id_sorted(
1113            &mut h,
1114            HASH_TAG_ANNOTATIONS,
1115            &self.annotations,
1116            |a| a.id.0,
1117            hash_coco_annotation,
1118        );
1119
1120        // Federated metadata, when present (LVIS path).
1121        match self.federated.as_ref() {
1122            None => hash_u8(&mut h, 0),
1123            Some(fed) => {
1124                hash_u8(&mut h, 1);
1125                hash_federated(&mut h, fed);
1126            }
1127        }
1128
1129        *h.finalize().as_bytes()
1130    }
1131}
1132
1133// ---------------------------------------------------------------------------
1134// detections (DT side)
1135// ---------------------------------------------------------------------------
1136
1137/// One COCO detection record (the DT side, what `loadRes` consumes).
1138///
1139/// Per the dispositions in this module's header:
1140///
1141/// - `is_crowd` does not exist as a field — quirks **E2 / J4**.
1142/// - `area` is derived from `bbox` at construction (`bbox.w * bbox.h`) —
1143///   quirk **J3**.
1144/// - `id` is honored when the user supplies one and auto-assigned
1145///   otherwise — quirk **J1** (`aligned`, an opinionated improvement
1146///   over pycocotools' silent overwrite).
1147#[derive(Debug, Clone, PartialEq)]
1148pub struct CocoDetection {
1149    /// Detection id. Either user-supplied (J1) or auto-assigned by
1150    /// [`CocoDetections::from_inputs`].
1151    pub id: AnnId,
1152    /// Image this detection is on.
1153    pub image_id: ImageId,
1154    /// Category this detection predicts.
1155    pub category_id: CategoryId,
1156    /// Confidence score. Sort key for the matching engine.
1157    pub score: f64,
1158    /// Bounding box (`(x, y, w, h)`).
1159    pub bbox: Bbox,
1160    /// Pixel area, derived from `bbox` per quirk **J3**.
1161    pub area: f64,
1162    /// Segmentation prediction, when the detector emits one. `None`
1163    /// for bbox-only detectors. Parity dispositions match
1164    /// [`CocoAnnotation::segmentation`].
1165    pub segmentation: Option<Segmentation>,
1166    /// Flat keypoint triplets `[x_1, y_1, v_1, x_2, y_2, v_2, ...]`
1167    /// (per ADR-0012). `None` for bbox-/segm-only detectors; the eval
1168    /// pipeline raises [`EvalError::InvalidAnnotation`] when a DT is
1169    /// missing keypoints under `iouType="keypoints"`.
1170    pub keypoints: Option<Vec<f64>>,
1171    /// COCO `num_keypoints` count of *visible* keypoints. On DT this
1172    /// field is not required (pycocotools never reads it); the OKS
1173    /// pipeline derives it from `keypoints` when needed. Tracked here
1174    /// for shape-parity with [`CocoAnnotation::num_keypoints`].
1175    pub num_keypoints: Option<u32>,
1176}
1177
1178impl Annotation for CocoDetection {
1179    fn image_id(&self) -> ImageId {
1180        self.image_id
1181    }
1182    fn category_id(&self) -> CategoryId {
1183        self.category_id
1184    }
1185    fn area(&self) -> f64 {
1186        self.area
1187    }
1188    fn is_crowd(&self) -> bool {
1189        false
1190    }
1191    fn effective_ignore(&self, _: ParityMode) -> bool {
1192        false
1193    }
1194}
1195
1196/// Caller-side input for one detection. Mirrors the shape of a single
1197/// entry of a COCO results JSON array but uses typed ids.
1198#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1199pub struct DetectionInput {
1200    /// Optional user-supplied id (quirk **J1**). Absent → auto-assigned.
1201    #[serde(default)]
1202    pub id: Option<AnnId>,
1203    /// Image id.
1204    pub image_id: ImageId,
1205    /// Category id.
1206    pub category_id: CategoryId,
1207    /// Confidence score.
1208    pub score: f64,
1209    /// Bounding box.
1210    pub bbox: Bbox,
1211    /// Optional segmentation prediction. `None` for bbox-only
1212    /// detectors. Stored verbatim and normalized via
1213    /// [`Segmentation::to_rle`] at eval time.
1214    #[serde(default, skip_serializing_if = "Option::is_none")]
1215    pub segmentation: Option<Segmentation>,
1216    /// Optional keypoint prediction (flat `[x, y, v, ...]` triplets,
1217    /// per ADR-0012). `None` for non-keypoint detectors.
1218    #[serde(default, skip_serializing_if = "Option::is_none")]
1219    pub keypoints: Option<Vec<f64>>,
1220    /// Optional `num_keypoints` count. The OKS path derives this from
1221    /// `keypoints` when absent (DT side does not require it).
1222    #[serde(default, skip_serializing_if = "Option::is_none")]
1223    pub num_keypoints: Option<u32>,
1224}
1225
1226/// COCO detections collection — flat storage plus `(image, category)`-
1227/// and per-image indices for the per-cell gather.
1228#[derive(Debug, Clone)]
1229pub struct CocoDetections {
1230    detections: Arc<Vec<CocoDetection>>,
1231    by_image_cat: HashMap<(ImageId, CategoryId), Vec<usize>>,
1232    by_image: HashMap<ImageId, Vec<usize>>,
1233}
1234
1235impl CocoDetections {
1236    /// Loads detections from the JSON array shape pycocotools'
1237    /// `loadRes` consumes (a list of objects with `image_id`,
1238    /// `category_id`, `bbox`, `score`, optional `id`).
1239    ///
1240    /// `iscrowd` and `area` fields, if present, are silently dropped:
1241    /// quirks **E2/J4** force `is_crowd=0` and quirk **J3** derives
1242    /// `area` from `bbox`.
1243    pub fn from_json_bytes(bytes: &[u8]) -> Result<Self, EvalError> {
1244        #[cfg(feature = "bench-timings")]
1245        let t0 = std::time::Instant::now();
1246        let raw: Vec<DetectionInput> = serde_json::from_slice(bytes)?;
1247        #[cfg(feature = "bench-timings")]
1248        let parse_ns = u64::try_from(t0.elapsed().as_nanos()).unwrap_or(u64::MAX);
1249        #[cfg(feature = "bench-timings")]
1250        let t1 = std::time::Instant::now();
1251        let result = Self::from_inputs(raw);
1252        #[cfg(feature = "bench-timings")]
1253        {
1254            let from_inputs_ns = u64::try_from(t1.elapsed().as_nanos()).unwrap_or(u64::MAX);
1255            dataset_timings::COUNTERS.add(dataset_timings::DT_PARSE_NS, parse_ns);
1256            dataset_timings::COUNTERS.add(dataset_timings::DT_FROM_INPUTS_NS, from_inputs_ns);
1257        }
1258        result
1259    }
1260
1261    /// Builds a [`CocoDetections`] from typed inputs. Auto-assigns ids
1262    /// (quirk **J1**) for inputs that did not supply one, validates
1263    /// finite scores, and derives areas (quirk **J3**).
1264    pub fn from_inputs(inputs: Vec<DetectionInput>) -> Result<Self, EvalError> {
1265        let mut detections = Vec::with_capacity(inputs.len());
1266        let mut next_auto = 1i64;
1267        for input in inputs {
1268            if !input.score.is_finite() {
1269                return Err(EvalError::NonFinite {
1270                    context: "detection score",
1271                });
1272            }
1273            let id = match input.id {
1274                Some(id) => id,
1275                None => {
1276                    let id = AnnId(next_auto);
1277                    next_auto += 1;
1278                    id
1279                }
1280            };
1281            detections.push(CocoDetection {
1282                id,
1283                image_id: input.image_id,
1284                category_id: input.category_id,
1285                score: input.score,
1286                bbox: input.bbox,
1287                area: input.bbox.w * input.bbox.h,
1288                segmentation: input.segmentation,
1289                keypoints: input.keypoints,
1290                num_keypoints: input.num_keypoints,
1291            });
1292        }
1293
1294        let mut by_image_cat: HashMap<(ImageId, CategoryId), Vec<usize>> = HashMap::new();
1295        let mut by_image: HashMap<ImageId, Vec<usize>> = HashMap::new();
1296        for (idx, dt) in detections.iter().enumerate() {
1297            by_image_cat
1298                .entry((dt.image_id, dt.category_id))
1299                .or_default()
1300                .push(idx);
1301            by_image.entry(dt.image_id).or_default().push(idx);
1302        }
1303
1304        Ok(Self {
1305            detections: Arc::new(detections),
1306            by_image_cat,
1307            by_image,
1308        })
1309    }
1310
1311    /// Build from already-resolved records, preserving their ids and
1312    /// fields verbatim. Used by the streaming evaluator to assemble a
1313    /// `CocoDetections` view across batches at finalize/snapshot time
1314    /// without re-running the auto-id and area-derivation logic in
1315    /// [`Self::from_inputs`].
1316    pub fn from_records(records: Vec<CocoDetection>) -> Self {
1317        let mut by_image_cat: HashMap<(ImageId, CategoryId), Vec<usize>> = HashMap::new();
1318        let mut by_image: HashMap<ImageId, Vec<usize>> = HashMap::new();
1319        for (idx, dt) in records.iter().enumerate() {
1320            by_image_cat
1321                .entry((dt.image_id, dt.category_id))
1322                .or_default()
1323                .push(idx);
1324            by_image.entry(dt.image_id).or_default().push(idx);
1325        }
1326        Self {
1327            detections: Arc::new(records),
1328            by_image_cat,
1329            by_image,
1330        }
1331    }
1332
1333    /// Flat slice of every detection.
1334    pub fn detections(&self) -> &[CocoDetection] {
1335        &self.detections
1336    }
1337
1338    /// Indices into [`Self::detections`] for one `(image, category)`
1339    /// cell. Empty slice when the cell is empty (no detections of that
1340    /// category on that image).
1341    pub fn indices_for(&self, image: ImageId, cat: CategoryId) -> &[usize] {
1342        self.by_image_cat
1343            .get(&(image, cat))
1344            .map_or(&[][..], Vec::as_slice)
1345    }
1346
1347    /// Indices into [`Self::detections`] for every detection on an
1348    /// image, regardless of category. Path used when `useCats=false`
1349    /// (quirk **L4**).
1350    pub fn indices_for_image(&self, image: ImageId) -> &[usize] {
1351        self.by_image.get(&image).map_or(&[][..], Vec::as_slice)
1352    }
1353
1354    /// LVIS per-image top-`max_dets` trim (quirk **AC2** of ADR-0026).
1355    ///
1356    /// Mirrors `LVISResults.limit_dets_per_image` at
1357    /// `lvis/results.py:73-84`: groups detections by `image_id`,
1358    /// sorts each group by score descending (stable — quirk
1359    /// **AC4**), and keeps the top `max_dets` across **all
1360    /// categories combined**. The cross-class consequence (quirk
1361    /// **AC3**): 250 cat-A + 350 cat-B detections on one image trim
1362    /// to **300 total**, not 250 + min(350, 300).
1363    ///
1364    /// `max_dets < 0` (or `i64::MIN`) disables the trim entirely
1365    /// (quirk **AC5**, mirroring the `if max_dets >= 0` guard at
1366    /// `results.py:39-40`). `max_dets == 0` keeps zero detections —
1367    /// edge case the upstream allows but isn't useful in practice.
1368    ///
1369    /// The output preserves DT ids and per-detection fields verbatim;
1370    /// only the membership of the flat detections vector and the
1371    /// per-cell index maps change. The original [`CocoDetections`] is
1372    /// untouched (the inner `Arc<Vec<CocoDetection>>` is *not* shared
1373    /// with the result — the trim copies the surviving entries into a
1374    /// fresh allocation).
1375    ///
1376    /// Within each image's group, ties on `score` resolve in input
1377    /// order: Rust's `slice::sort_by` is stable, matching Python's
1378    /// `sorted(_, reverse=True)` Timsort behavior. The fact that the
1379    /// matching path's `argsort_score_desc` is *also* stable
1380    /// (`np.argsort(-scores, kind="mergesort")`, AC4) is a separate
1381    /// invariant — vernier's parity claim covers both sites.
1382    pub fn lvis_trim(&self, max_dets: i64) -> CocoDetections {
1383        if max_dets < 0 {
1384            // AC5: negative cap disables the trim. Cheap clone — the
1385            // detections `Arc` is shared, only the index maps allocate.
1386            return self.clone();
1387        }
1388        let cap = max_dets as usize;
1389        let mut by_image_groups: HashMap<ImageId, Vec<usize>> = HashMap::new();
1390        for (idx, dt) in self.detections.iter().enumerate() {
1391            by_image_groups.entry(dt.image_id).or_default().push(idx);
1392        }
1393        // Iterate images in id-ascending order so the output's flat
1394        // detections vector is deterministic — the LVIS oracle's
1395        // `LVISResults.dataset['annotations']` is a dict-iteration
1396        // order (image insertion order), which Python's `dict` keeps
1397        // stable since 3.7. Rebuilding the order from id-ascending
1398        // here matches the shape vernier's later FFI consumers
1399        // expect; the per-image trim itself is order-invariant.
1400        let mut image_ids: Vec<ImageId> = by_image_groups.keys().copied().collect();
1401        image_ids.sort_unstable_by_key(|i| i.0);
1402
1403        // Tight upper bound on the post-trim count: input length is
1404        // always an upper bound on the result, and `cap * n_images`
1405        // only beats it when the input is dense enough to hit the
1406        // cap on every image. Take the smaller of the two so we
1407        // never over-allocate by a factor of 5x on typical evals
1408        // (most images carry far fewer than `max_dets` detections).
1409        let upper_bound = self
1410            .detections
1411            .len()
1412            .min(cap.saturating_mul(image_ids.len()));
1413        let mut out: Vec<CocoDetection> = Vec::with_capacity(upper_bound);
1414        for image_id in image_ids {
1415            let mut group = by_image_groups.remove(&image_id).unwrap_or_default();
1416            // Stable sort by score descending. `partial_cmp` returns
1417            // `None` only on NaN; `from_inputs` rejects NaN scores
1418            // upstream (quirk **AD3** corrected), so `Equal` is the
1419            // only fallback we need to consider.
1420            group.sort_by(|&a, &b| {
1421                self.detections[b]
1422                    .score
1423                    .partial_cmp(&self.detections[a].score)
1424                    .unwrap_or(std::cmp::Ordering::Equal)
1425            });
1426            for &idx in group.iter().take(cap) {
1427                out.push(self.detections[idx].clone());
1428            }
1429        }
1430        CocoDetections::from_records(out)
1431    }
1432}
1433
1434// ---------------------------------------------------------------------------
1435// serde glue
1436// ---------------------------------------------------------------------------
1437
1438/// COCO JSON uses `0`/`1` ints for `iscrowd` / `ignore`, but a
1439/// permissive reader also accepts bool literals. Shared between the
1440/// required and optional flag deserializers below.
1441#[derive(Deserialize)]
1442#[serde(untagged)]
1443enum BoolOrInt {
1444    Bool(bool),
1445    Int(i64),
1446}
1447
1448impl BoolOrInt {
1449    fn into_bool<E: serde::de::Error>(self) -> Result<bool, E> {
1450        match self {
1451            Self::Bool(b) => Ok(b),
1452            Self::Int(0) => Ok(false),
1453            Self::Int(1) => Ok(true),
1454            Self::Int(other) => Err(E::custom(format!(
1455                "expected 0 or 1 for COCO bool field, got {other}"
1456            ))),
1457        }
1458    }
1459}
1460
1461fn deserialize_bool_int<'de, D>(de: D) -> Result<bool, D::Error>
1462where
1463    D: serde::Deserializer<'de>,
1464{
1465    BoolOrInt::deserialize(de)?.into_bool()
1466}
1467
1468fn deserialize_opt_bool_int<'de, D>(de: D) -> Result<Option<bool>, D::Error>
1469where
1470    D: serde::Deserializer<'de>,
1471{
1472    Option::<BoolOrInt>::deserialize(de)?
1473        .map(BoolOrInt::into_bool)
1474        .transpose()
1475}
1476
1477#[cfg(test)]
1478mod tests {
1479    use super::*;
1480    use proptest::prelude::*;
1481
1482    const CROWD_REGION_GT: &str = r#"{
1483        "images": [
1484            {"id": 1, "width": 200, "height": 200, "file_name": "img1.png"}
1485        ],
1486        "annotations": [
1487            {"id": 1, "image_id": 1, "category_id": 1,
1488             "bbox": [100, 100, 50, 50], "area": 2500, "iscrowd": 0},
1489            {"id": 2, "image_id": 1, "category_id": 1,
1490             "bbox": [0, 0, 200, 200], "area": 40000, "iscrowd": 1}
1491        ],
1492        "categories": [
1493            {"id": 1, "name": "widget", "supercategory": "thing"}
1494        ]
1495    }"#;
1496
1497    fn load_crowd_region() -> CocoDataset {
1498        CocoDataset::from_json_bytes(CROWD_REGION_GT.as_bytes()).unwrap()
1499    }
1500
1501    #[test]
1502    fn loads_crowd_region_fixture() {
1503        let ds = load_crowd_region();
1504        assert_eq!(ds.images().len(), 1);
1505        assert_eq!(ds.categories().len(), 1);
1506        assert_eq!(ds.annotations().len(), 2);
1507        assert_eq!(ds.images()[0].file_name.as_deref(), Some("img1.png"));
1508        assert_eq!(ds.categories()[0].name, "widget");
1509    }
1510
1511    #[test]
1512    fn by_image_index_returns_both_anns() {
1513        let ds = load_crowd_region();
1514        let idxs = ds.ann_indices_for_image(ImageId(1));
1515        assert_eq!(idxs.len(), 2);
1516        let anns: Vec<_> = ds.ann_iter_for_image(ImageId(1)).collect();
1517        assert_eq!(anns.len(), 2);
1518        assert_eq!(anns[0].id, AnnId(1));
1519        assert_eq!(anns[1].id, AnnId(2));
1520    }
1521
1522    #[test]
1523    fn by_category_index_returns_both_anns() {
1524        let ds = load_crowd_region();
1525        let idxs = ds.ann_indices_for_category(CategoryId(1));
1526        assert_eq!(idxs.len(), 2);
1527    }
1528
1529    #[test]
1530    fn unknown_image_returns_empty_slice() {
1531        let ds = load_crowd_region();
1532        assert!(ds.ann_indices_for_image(ImageId(999)).is_empty());
1533        assert!(ds.ann_indices_for_category(CategoryId(999)).is_empty());
1534    }
1535
1536    #[test]
1537    fn empty_image_or_category_returns_empty_slice_not_missing() {
1538        // A dataset with an image that has no annotations: the index
1539        // must be present (empty), so the matching loop can ask
1540        // without special-casing.
1541        const ONLY_EMPTY_IMG: &str = r#"{
1542            "images": [{"id": 7, "width": 1, "height": 1}],
1543            "annotations": [],
1544            "categories": [{"id": 3, "name": "thing"}]
1545        }"#;
1546        let ds = CocoDataset::from_json_bytes(ONLY_EMPTY_IMG.as_bytes()).unwrap();
1547        assert!(ds.ann_indices_for_image(ImageId(7)).is_empty());
1548        assert!(ds.ann_indices_for_category(CategoryId(3)).is_empty());
1549    }
1550
1551    #[test]
1552    fn rejects_annotation_referencing_unknown_image() {
1553        const BAD: &str = r#"{
1554            "images": [{"id": 1, "width": 10, "height": 10}],
1555            "annotations": [
1556                {"id": 1, "image_id": 99, "category_id": 1,
1557                 "bbox": [0, 0, 1, 1], "area": 1, "iscrowd": 0}
1558            ],
1559            "categories": [{"id": 1, "name": "thing"}]
1560        }"#;
1561        let err = CocoDataset::from_json_bytes(BAD.as_bytes()).unwrap_err();
1562        match err {
1563            EvalError::InvalidAnnotation { detail } => {
1564                assert!(detail.contains("image_id=99"), "msg: {detail}");
1565            }
1566            other => panic!("expected InvalidAnnotation, got {other:?}"),
1567        }
1568    }
1569
1570    #[test]
1571    fn rejects_annotation_referencing_unknown_category() {
1572        const BAD: &str = r#"{
1573            "images": [{"id": 1, "width": 10, "height": 10}],
1574            "annotations": [
1575                {"id": 1, "image_id": 1, "category_id": 42,
1576                 "bbox": [0, 0, 1, 1], "area": 1, "iscrowd": 0}
1577            ],
1578            "categories": [{"id": 1, "name": "thing"}]
1579        }"#;
1580        let err = CocoDataset::from_json_bytes(BAD.as_bytes()).unwrap_err();
1581        match err {
1582            EvalError::InvalidAnnotation { detail } => {
1583                assert!(detail.contains("category_id=42"), "msg: {detail}");
1584            }
1585            other => panic!("expected InvalidAnnotation, got {other:?}"),
1586        }
1587    }
1588
1589    #[test]
1590    fn round_trips_through_json() {
1591        let ds = load_crowd_region();
1592        let json = serde_json::to_string(&ds.to_json_value()).unwrap();
1593        let again = CocoDataset::from_json_bytes(json.as_bytes()).unwrap();
1594        assert_eq!(ds.images(), again.images());
1595        assert_eq!(ds.categories(), again.categories());
1596        assert_eq!(ds.annotations(), again.annotations());
1597    }
1598
1599    // -- Quirk D1: effective_ignore differs by parity mode ----------------
1600
1601    #[test]
1602    fn d1_strict_mode_drops_explicit_ignore_field() {
1603        // Annotation with iscrowd=0 and explicit ignore=1.
1604        // Strict (pycocotools): ignore := iscrowd → false.
1605        // Corrected: respects user's ignore=1 → true.
1606        const ANN_JSON: &str = r#"{
1607            "images": [{"id": 1, "width": 10, "height": 10}],
1608            "annotations": [
1609                {"id": 1, "image_id": 1, "category_id": 1,
1610                 "bbox": [0, 0, 1, 1], "area": 1,
1611                 "iscrowd": 0, "ignore": 1}
1612            ],
1613            "categories": [{"id": 1, "name": "thing"}]
1614        }"#;
1615        let ds = CocoDataset::from_json_bytes(ANN_JSON.as_bytes()).unwrap();
1616        let ann = &ds.annotations()[0];
1617        assert!(!ann.effective_ignore(ParityMode::Strict));
1618        assert!(ann.effective_ignore(ParityMode::Corrected));
1619    }
1620
1621    #[test]
1622    fn d1_strict_mode_uses_iscrowd_when_ignore_absent() {
1623        // Annotation with iscrowd=1 and no ignore field.
1624        // Both modes: ignore = is_crowd = true.
1625        const ANN_JSON: &str = r#"{
1626            "images": [{"id": 1, "width": 10, "height": 10}],
1627            "annotations": [
1628                {"id": 1, "image_id": 1, "category_id": 1,
1629                 "bbox": [0, 0, 1, 1], "area": 1, "iscrowd": 1}
1630            ],
1631            "categories": [{"id": 1, "name": "thing"}]
1632        }"#;
1633        let ds = CocoDataset::from_json_bytes(ANN_JSON.as_bytes()).unwrap();
1634        let ann = &ds.annotations()[0];
1635        assert!(ann.effective_ignore(ParityMode::Strict));
1636        assert!(ann.effective_ignore(ParityMode::Corrected));
1637    }
1638
1639    // -- Per-cell index ((image, category)) -------------------------------
1640
1641    #[test]
1642    fn ann_indices_for_image_cat_returns_correct_subset() {
1643        const TWO_CATS: &str = r#"{
1644            "images": [{"id": 1, "width": 10, "height": 10}],
1645            "annotations": [
1646                {"id": 1, "image_id": 1, "category_id": 1,
1647                 "bbox": [0, 0, 1, 1], "area": 1, "iscrowd": 0},
1648                {"id": 2, "image_id": 1, "category_id": 2,
1649                 "bbox": [0, 0, 1, 1], "area": 1, "iscrowd": 0},
1650                {"id": 3, "image_id": 1, "category_id": 1,
1651                 "bbox": [0, 0, 1, 1], "area": 1, "iscrowd": 0}
1652            ],
1653            "categories": [
1654                {"id": 1, "name": "a"}, {"id": 2, "name": "b"}
1655            ]
1656        }"#;
1657        let ds = CocoDataset::from_json_bytes(TWO_CATS.as_bytes()).unwrap();
1658        let cat1: Vec<AnnId> = ds
1659            .ann_indices_for(ImageId(1), CategoryId(1))
1660            .iter()
1661            .map(|&i| ds.annotations()[i].id)
1662            .collect();
1663        assert_eq!(cat1, vec![AnnId(1), AnnId(3)]);
1664        let cat2: Vec<AnnId> = ds
1665            .ann_indices_for(ImageId(1), CategoryId(2))
1666            .iter()
1667            .map(|&i| ds.annotations()[i].id)
1668            .collect();
1669        assert_eq!(cat2, vec![AnnId(2)]);
1670        assert!(ds.ann_indices_for(ImageId(1), CategoryId(99)).is_empty());
1671        assert!(ds.ann_indices_for(ImageId(99), CategoryId(1)).is_empty());
1672    }
1673
1674    // -- CocoDetections: J1 (auto-id), J3 (area from bbox), validation ----
1675
1676    fn dt_input(image: i64, cat: i64, score: f64, bbox: (f64, f64, f64, f64)) -> DetectionInput {
1677        DetectionInput {
1678            id: None,
1679            image_id: ImageId(image),
1680            category_id: CategoryId(cat),
1681            score,
1682            bbox: Bbox {
1683                x: bbox.0,
1684                y: bbox.1,
1685                w: bbox.2,
1686                h: bbox.3,
1687            },
1688            segmentation: None,
1689            keypoints: None,
1690            num_keypoints: None,
1691        }
1692    }
1693
1694    #[test]
1695    fn j1_auto_assigns_ids_when_absent() {
1696        let dts = CocoDetections::from_inputs(vec![
1697            dt_input(1, 1, 0.9, (0.0, 0.0, 1.0, 1.0)),
1698            dt_input(1, 1, 0.8, (0.0, 0.0, 1.0, 1.0)),
1699        ])
1700        .unwrap();
1701        let ids: Vec<AnnId> = dts.detections().iter().map(|d| d.id).collect();
1702        assert_eq!(ids, vec![AnnId(1), AnnId(2)]);
1703    }
1704
1705    #[test]
1706    fn j1_preserves_user_supplied_ids() {
1707        let mut a = dt_input(1, 1, 0.9, (0.0, 0.0, 1.0, 1.0));
1708        a.id = Some(AnnId(42));
1709        let mut b = dt_input(1, 1, 0.8, (0.0, 0.0, 1.0, 1.0));
1710        b.id = Some(AnnId(7));
1711        let dts = CocoDetections::from_inputs(vec![a, b]).unwrap();
1712        let ids: Vec<AnnId> = dts.detections().iter().map(|d| d.id).collect();
1713        assert_eq!(ids, vec![AnnId(42), AnnId(7)]);
1714    }
1715
1716    #[test]
1717    fn j3_derives_area_from_bbox() {
1718        let dts =
1719            CocoDetections::from_inputs(vec![dt_input(1, 1, 0.5, (10.0, 10.0, 4.0, 5.0))]).unwrap();
1720        assert_eq!(dts.detections()[0].area, 20.0);
1721    }
1722
1723    #[test]
1724    fn rejects_non_finite_score() {
1725        let err = CocoDetections::from_inputs(vec![dt_input(1, 1, f64::NAN, (0.0, 0.0, 1.0, 1.0))])
1726            .unwrap_err();
1727        assert!(matches!(
1728            err,
1729            EvalError::NonFinite {
1730                context: "detection score"
1731            }
1732        ));
1733    }
1734
1735    #[test]
1736    fn detections_indices_per_image_cat() {
1737        let dts = CocoDetections::from_inputs(vec![
1738            dt_input(1, 1, 0.9, (0.0, 0.0, 1.0, 1.0)),
1739            dt_input(1, 2, 0.8, (0.0, 0.0, 1.0, 1.0)),
1740            dt_input(2, 1, 0.7, (0.0, 0.0, 1.0, 1.0)),
1741        ])
1742        .unwrap();
1743        assert_eq!(dts.indices_for(ImageId(1), CategoryId(1)), &[0]);
1744        assert_eq!(dts.indices_for(ImageId(1), CategoryId(2)), &[1]);
1745        assert_eq!(dts.indices_for(ImageId(2), CategoryId(1)), &[2]);
1746        assert!(dts.indices_for(ImageId(99), CategoryId(1)).is_empty());
1747        // Quirk L4 path: indices_for_image returns every category.
1748        let img1: Vec<usize> = dts.indices_for_image(ImageId(1)).to_vec();
1749        assert_eq!(img1, vec![0, 1]);
1750    }
1751
1752    #[test]
1753    fn loads_detections_from_json_array() {
1754        const JSON: &str = r#"[
1755            {"image_id": 1, "category_id": 1, "score": 0.9,
1756             "bbox": [0, 0, 2, 3]},
1757            {"id": 7, "image_id": 1, "category_id": 1, "score": 0.5,
1758             "bbox": [1, 1, 1, 1]}
1759        ]"#;
1760        let dts = CocoDetections::from_json_bytes(JSON.as_bytes()).unwrap();
1761        let ds = dts.detections();
1762        assert_eq!(ds[0].id, AnnId(1)); // auto-assigned
1763        assert_eq!(ds[0].area, 6.0); // J3
1764        assert_eq!(ds[1].id, AnnId(7)); // user-supplied (J1)
1765        assert!(!ds[0].is_crowd()); // E2/J4
1766        assert!(ds[0].segmentation.is_none());
1767    }
1768
1769    // -- Phase 2: segmentation field on GT and DT -----------------------------
1770
1771    #[test]
1772    fn gt_loads_polygon_segmentation() {
1773        const JSON: &str = r#"{
1774            "images": [{"id": 1, "width": 10, "height": 10}],
1775            "annotations": [
1776                {"id": 1, "image_id": 1, "category_id": 1,
1777                 "bbox": [0, 0, 4, 4], "area": 16, "iscrowd": 0,
1778                 "segmentation": [[0, 0, 4, 0, 4, 4, 0, 4]]}
1779            ],
1780            "categories": [{"id": 1, "name": "thing"}]
1781        }"#;
1782        let ds = CocoDataset::from_json_bytes(JSON.as_bytes()).unwrap();
1783        let seg = ds.annotations()[0].segmentation.as_ref().unwrap();
1784        let rle = seg.to_rle(10, 10).unwrap();
1785        assert_eq!(rle.area(), 16);
1786    }
1787
1788    #[test]
1789    fn gt_loads_compressed_rle_segmentation() {
1790        let counts_str = String::from_utf8(vernier_mask::encode_counts(&[0, 16])).unwrap();
1791        let json = format!(
1792            r#"{{
1793            "images": [{{"id": 1, "width": 4, "height": 4}}],
1794            "annotations": [
1795                {{"id": 1, "image_id": 1, "category_id": 1,
1796                 "bbox": [0, 0, 4, 4], "area": 16, "iscrowd": 1,
1797                 "segmentation": {{"size": [4, 4], "counts": "{counts_str}"}}}}
1798            ],
1799            "categories": [{{"id": 1, "name": "thing"}}]
1800        }}"#
1801        );
1802        let ds = CocoDataset::from_json_bytes(json.as_bytes()).unwrap();
1803        let seg = ds.annotations()[0].segmentation.as_ref().unwrap();
1804        let rle = seg.to_rle(4, 4).unwrap();
1805        assert_eq!((rle.h, rle.w), (4, 4));
1806        assert_eq!(rle.area(), 16);
1807    }
1808
1809    #[test]
1810    fn gt_segmentation_round_trips_through_to_json_value() {
1811        const JSON: &str = r#"{
1812            "images": [{"id": 1, "width": 10, "height": 10}],
1813            "annotations": [
1814                {"id": 1, "image_id": 1, "category_id": 1,
1815                 "bbox": [0, 0, 4, 4], "area": 16, "iscrowd": 0,
1816                 "segmentation": [[0, 0, 4, 0, 4, 4, 0, 4]]}
1817            ],
1818            "categories": [{"id": 1, "name": "thing"}]
1819        }"#;
1820        let ds = CocoDataset::from_json_bytes(JSON.as_bytes()).unwrap();
1821        let serialized = serde_json::to_string(&ds.to_json_value()).unwrap();
1822        let again = CocoDataset::from_json_bytes(serialized.as_bytes()).unwrap();
1823        assert_eq!(ds.annotations(), again.annotations());
1824    }
1825
1826    #[test]
1827    fn gt_without_segmentation_field_loads_as_none() {
1828        let ds = load_crowd_region();
1829        assert!(ds.annotations().iter().all(|a| a.segmentation.is_none()));
1830    }
1831
1832    #[test]
1833    fn dt_loads_compressed_rle_segmentation() {
1834        const JSON: &str = r#"[
1835            {"image_id": 1, "category_id": 1, "score": 0.9,
1836             "bbox": [0, 0, 4, 4],
1837             "segmentation": {"size": [4, 4], "counts": "04L4"}}
1838        ]"#;
1839        let dts = CocoDetections::from_json_bytes(JSON.as_bytes()).unwrap();
1840        assert!(dts.detections()[0].segmentation.is_some());
1841    }
1842
1843    #[test]
1844    fn dt_without_segmentation_loads_as_none() {
1845        const JSON: &str = r#"[
1846            {"image_id": 1, "category_id": 1, "score": 0.9, "bbox": [0, 0, 1, 1]}
1847        ]"#;
1848        let dts = CocoDetections::from_json_bytes(JSON.as_bytes()).unwrap();
1849        assert!(dts.detections()[0].segmentation.is_none());
1850    }
1851
1852    // -- Property: index invariants hold across arbitrary datasets --------
1853
1854    fn arb_image() -> impl Strategy<Value = ImageMeta> {
1855        (1i64..1000, 1u32..2048, 1u32..2048).prop_map(|(id, w, h)| ImageMeta {
1856            id: ImageId(id),
1857            width: w,
1858            height: h,
1859            file_name: None,
1860        })
1861    }
1862
1863    fn arb_category() -> impl Strategy<Value = CategoryMeta> {
1864        (1i64..100, "[a-z]{1,8}").prop_map(|(id, name)| CategoryMeta {
1865            id: CategoryId(id),
1866            name,
1867            supercategory: None,
1868        })
1869    }
1870
1871    /// Minimal `CocoAnnotation` with the required ids set and every
1872    /// optional field defaulted. Tests that only care about identity /
1873    /// canonical-form invariance use this to skip the 10-field literal.
1874    fn make_min_annotation(
1875        id: AnnId,
1876        image_id: ImageId,
1877        category_id: CategoryId,
1878    ) -> CocoAnnotation {
1879        CocoAnnotation {
1880            id,
1881            image_id,
1882            category_id,
1883            area: 25.0,
1884            is_crowd: false,
1885            ignore_flag: None,
1886            bbox: Bbox {
1887                x: 0.0,
1888                y: 0.0,
1889                w: 5.0,
1890                h: 5.0,
1891            },
1892            segmentation: None,
1893            keypoints: None,
1894            num_keypoints: None,
1895        }
1896    }
1897
1898    proptest! {
1899        #![proptest_config(ProptestConfig::with_cases(64))]
1900
1901        #[test]
1902        fn index_invariants_hold(
1903            // Generate a small image set, a small category set, and a
1904            // bag of annotations whose (image_id, category_id) pick
1905            // from those sets uniformly. The invariant we check: every
1906            // annotation appears in exactly one by_image bucket and
1907            // exactly one by_category bucket, and no bucket contains a
1908            // stray index.
1909            images in proptest::collection::vec(arb_image(), 1..6),
1910            categories in proptest::collection::vec(arb_category(), 1..6),
1911            n_anns in 0usize..40,
1912            ann_seed in any::<u64>(),
1913        ) {
1914            // De-duplicate ids; HashMaps in `from_parts` collapse them
1915            // anyway and tests should not depend on prop generators
1916            // accidentally minting collisions.
1917            let mut images = images;
1918            images.sort_by_key(|i| i.id);
1919            images.dedup_by_key(|i| i.id);
1920            let mut categories = categories;
1921            categories.sort_by_key(|c| c.id);
1922            categories.dedup_by_key(|c| c.id);
1923
1924            // Cheap deterministic PRNG from ann_seed; avoids pulling
1925            // in `rand` for a single proptest helper.
1926            let mut state = ann_seed.wrapping_add(1);
1927            let mut next = || {
1928                state = state.wrapping_mul(6364136223846793005)
1929                             .wrapping_add(1442695040888963407);
1930                state
1931            };
1932
1933            let mut annotations = Vec::with_capacity(n_anns);
1934            for ann_idx in 0..n_anns {
1935                let img = &images[(next() as usize) % images.len()];
1936                let cat = &categories[(next() as usize) % categories.len()];
1937                annotations.push(CocoAnnotation {
1938                    id: AnnId(ann_idx as i64 + 1),
1939                    image_id: img.id,
1940                    category_id: cat.id,
1941                    area: 1.0,
1942                    is_crowd: false,
1943                    ignore_flag: None,
1944                    bbox: Bbox { x: 0.0, y: 0.0, w: 1.0, h: 1.0 },
1945                    segmentation: None,
1946                    keypoints: None,
1947                    num_keypoints: None,
1948                });
1949            }
1950
1951            let ds = CocoDataset::from_parts(
1952                images.clone(), annotations.clone(), categories.clone()
1953            ).unwrap();
1954
1955            // Every annotation index appears exactly once across all
1956            // by_image buckets and exactly once across all by_category
1957            // buckets.
1958            let mut seen_img: Vec<usize> = images.iter()
1959                .flat_map(|i| ds.ann_indices_for_image(i.id).iter().copied())
1960                .collect();
1961            seen_img.sort_unstable();
1962            let expected: Vec<usize> = (0..annotations.len()).collect();
1963            prop_assert_eq!(&seen_img, &expected);
1964
1965            let mut seen_cat: Vec<usize> = categories.iter()
1966                .flat_map(|c| ds.ann_indices_for_category(c.id).iter().copied())
1967                .collect();
1968            seen_cat.sort_unstable();
1969            prop_assert_eq!(&seen_cat, &expected);
1970
1971            // Cross-check: every index in by_image[i] has image_id == i.
1972            for img in &images {
1973                for &idx in ds.ann_indices_for_image(img.id) {
1974                    prop_assert_eq!(ds.annotations()[idx].image_id, img.id);
1975                }
1976            }
1977            for cat in &categories {
1978                for &idx in ds.ann_indices_for_category(cat.id) {
1979                    prop_assert_eq!(ds.annotations()[idx].category_id, cat.id);
1980                }
1981            }
1982        }
1983    }
1984
1985    // -- ADR-0026: LVIS federated metadata loader -----------------------------
1986
1987    /// Minimal valid LVIS GT: 2 images, 2 categories with frequencies,
1988    /// 1 GT on image 1 (cat 1) and 1 GT on image 2 (cat 2). Image 1
1989    /// has cat 2 in `neg`, image 2 has cat 1 flagged not-exhaustive.
1990    /// Used as the base fixture for the AA1 / AA7 / AB6 tests; the
1991    /// negative tests mutate it to violate one constraint at a time.
1992    const LVIS_MIN_VALID: &str = r#"{
1993        "images": [
1994            {"id": 1, "width": 100, "height": 100,
1995             "neg_category_ids": [2], "not_exhaustive_category_ids": []},
1996            {"id": 2, "width": 100, "height": 100,
1997             "neg_category_ids": [], "not_exhaustive_category_ids": [2]}
1998        ],
1999        "annotations": [
2000            {"id": 1, "image_id": 1, "category_id": 1,
2001             "bbox": [0, 0, 10, 10], "area": 100, "iscrowd": 0},
2002            {"id": 2, "image_id": 2, "category_id": 2,
2003             "bbox": [0, 0, 20, 20], "area": 400, "iscrowd": 0}
2004        ],
2005        "categories": [
2006            {"id": 1, "name": "a", "frequency": "f"},
2007            {"id": 2, "name": "b", "frequency": "r"}
2008        ]
2009    }"#;
2010
2011    #[test]
2012    fn lvis_loads_minimal_valid_dataset() {
2013        let ds = CocoDataset::from_lvis_json_bytes(LVIS_MIN_VALID.as_bytes()).unwrap();
2014        // Spine identical to a COCO load.
2015        assert_eq!(ds.images().len(), 2);
2016        assert_eq!(ds.categories().len(), 2);
2017        assert_eq!(ds.annotations().len(), 2);
2018        // Federated metadata populated.
2019        assert!(ds.is_federated());
2020        let pos = ds.pos_category_ids().unwrap();
2021        let neg = ds.neg_category_ids().unwrap();
2022        let nel = ds.not_exhaustive_category_ids().unwrap();
2023        let freq = ds.category_frequency().unwrap();
2024        // AA1: pos derived from GTs.
2025        assert_eq!(pos[&ImageId(1)], HashSet::from([CategoryId(1)]));
2026        assert_eq!(pos[&ImageId(2)], HashSet::from([CategoryId(2)]));
2027        // AA2: neg read verbatim.
2028        assert_eq!(neg[&ImageId(1)], HashSet::from([CategoryId(2)]));
2029        assert_eq!(neg[&ImageId(2)], HashSet::new());
2030        // AA3: not_exhaustive read verbatim.
2031        assert_eq!(nel[&ImageId(1)], HashSet::new());
2032        assert_eq!(nel[&ImageId(2)], HashSet::from([CategoryId(2)]));
2033        // AB1: frequency tags.
2034        assert_eq!(freq[&CategoryId(1)], Frequency::Frequent);
2035        assert_eq!(freq[&CategoryId(2)], Frequency::Rare);
2036    }
2037
2038    #[test]
2039    fn aa1_pos_derived_from_gts_does_not_include_zero_ann_categories() {
2040        // Cat 2 has a GT only on image 2; pos[image 1] must NOT
2041        // contain cat 2 (it's only in neg there).
2042        let ds = CocoDataset::from_lvis_json_bytes(LVIS_MIN_VALID.as_bytes()).unwrap();
2043        let pos = ds.pos_category_ids().unwrap();
2044        assert!(!pos[&ImageId(1)].contains(&CategoryId(2)));
2045        assert!(!pos[&ImageId(2)].contains(&CategoryId(1)));
2046    }
2047
2048    #[test]
2049    fn from_json_bytes_leaves_federated_metadata_none() {
2050        // The COCO loader on the same JSON shape ignores the LVIS
2051        // extras and leaves federated metadata empty (the orchestrator
2052        // then runs COCO semantics on the cells).
2053        let ds = CocoDataset::from_json_bytes(LVIS_MIN_VALID.as_bytes()).unwrap();
2054        assert!(!ds.is_federated());
2055        assert!(ds.pos_category_ids().is_none());
2056        assert!(ds.neg_category_ids().is_none());
2057        assert!(ds.not_exhaustive_category_ids().is_none());
2058        assert!(ds.category_frequency().is_none());
2059    }
2060
2061    #[test]
2062    fn aa7_pos_intersect_neg_rejected() {
2063        // Cat 1 has a GT on image 1 → it's in pos[1]; the JSON also
2064        // lists cat 1 in image 1's neg → conflict.
2065        const BAD: &str = r#"{
2066            "images": [
2067                {"id": 1, "width": 10, "height": 10,
2068                 "neg_category_ids": [1], "not_exhaustive_category_ids": []}
2069            ],
2070            "annotations": [
2071                {"id": 1, "image_id": 1, "category_id": 1,
2072                 "bbox": [0, 0, 5, 5], "area": 25, "iscrowd": 0}
2073            ],
2074            "categories": [{"id": 1, "name": "a", "frequency": "f"}]
2075        }"#;
2076        let err = CocoDataset::from_lvis_json_bytes(BAD.as_bytes()).unwrap_err();
2077        match err {
2078            EvalError::LvisFederatedConflict {
2079                image_id,
2080                category_id,
2081                detail,
2082            } => {
2083                assert_eq!(image_id, 1);
2084                assert_eq!(category_id, 1);
2085                assert!(detail.contains("GT"));
2086            }
2087            other => panic!("expected LvisFederatedConflict, got {other:?}"),
2088        }
2089    }
2090
2091    #[test]
2092    fn aa7_not_exhaustive_outside_pos_rejected() {
2093        // Image 1 lists cat 2 in not_exhaustive but has no GT of cat 2
2094        // → not_exhaustive ⊄ pos.
2095        const BAD: &str = r#"{
2096            "images": [
2097                {"id": 1, "width": 10, "height": 10,
2098                 "neg_category_ids": [], "not_exhaustive_category_ids": [2]}
2099            ],
2100            "annotations": [
2101                {"id": 1, "image_id": 1, "category_id": 1,
2102                 "bbox": [0, 0, 5, 5], "area": 25, "iscrowd": 0}
2103            ],
2104            "categories": [
2105                {"id": 1, "name": "a", "frequency": "f"},
2106                {"id": 2, "name": "b", "frequency": "r"}
2107            ]
2108        }"#;
2109        let err = CocoDataset::from_lvis_json_bytes(BAD.as_bytes()).unwrap_err();
2110        match err {
2111            EvalError::LvisFederatedConflict {
2112                image_id,
2113                category_id,
2114                detail,
2115            } => {
2116                assert_eq!(image_id, 1);
2117                assert_eq!(category_id, 2);
2118                assert!(detail.contains("not_exhaustive"));
2119            }
2120            other => panic!("expected LvisFederatedConflict, got {other:?}"),
2121        }
2122    }
2123
2124    #[test]
2125    fn ab6_missing_frequency_collects_all_offenders() {
2126        // Two categories, neither has a frequency. The error must
2127        // surface both ids in sorted order, not just the first miss.
2128        const BAD: &str = r#"{
2129            "images": [
2130                {"id": 1, "width": 10, "height": 10,
2131                 "neg_category_ids": [], "not_exhaustive_category_ids": []}
2132            ],
2133            "annotations": [],
2134            "categories": [
2135                {"id": 7, "name": "g"},
2136                {"id": 3, "name": "c"}
2137            ]
2138        }"#;
2139        let err = CocoDataset::from_lvis_json_bytes(BAD.as_bytes()).unwrap_err();
2140        match err {
2141            EvalError::MissingFrequency { category_ids } => {
2142                assert_eq!(category_ids, vec![3, 7]);
2143            }
2144            other => panic!("expected MissingFrequency, got {other:?}"),
2145        }
2146    }
2147
2148    #[test]
2149    fn lvis_loader_treats_absent_neg_field_as_empty() {
2150        // LVIS schema requires neg/not_exhaustive on every image, but a
2151        // tolerant loader treats absence as empty (matches the LVIS v1
2152        // semantic where a missing field → no negatives).
2153        const TOLERANT: &str = r#"{
2154            "images": [{"id": 1, "width": 10, "height": 10}],
2155            "annotations": [],
2156            "categories": [{"id": 1, "name": "a", "frequency": "c"}]
2157        }"#;
2158        let ds = CocoDataset::from_lvis_json_bytes(TOLERANT.as_bytes()).unwrap();
2159        let neg = ds.neg_category_ids().unwrap();
2160        let nel = ds.not_exhaustive_category_ids().unwrap();
2161        assert!(neg[&ImageId(1)].is_empty());
2162        assert!(nel[&ImageId(1)].is_empty());
2163    }
2164
2165    #[test]
2166    fn frequency_round_trips_serde() {
2167        for f in [Frequency::Rare, Frequency::Common, Frequency::Frequent] {
2168            let s = serde_json::to_string(&f).unwrap();
2169            let back: Frequency = serde_json::from_str(&s).unwrap();
2170            assert_eq!(f, back);
2171        }
2172        // Confirm the serde rename targets the LVIS single-letter form.
2173        assert_eq!(serde_json::to_string(&Frequency::Rare).unwrap(), "\"r\"");
2174        assert_eq!(serde_json::to_string(&Frequency::Common).unwrap(), "\"c\"");
2175        assert_eq!(
2176            serde_json::to_string(&Frequency::Frequent).unwrap(),
2177            "\"f\""
2178        );
2179    }
2180
2181    // -- AC2/AC3/AC4/AC5: lvis_trim per-image top-K ---------------------------
2182
2183    #[test]
2184    fn ac2_q1_trims_500_single_category_to_300() {
2185        // ADR-0026 appendix Q1: 500 single-category detections on one
2186        // image must trim to exactly 300, dropping the lowest-score
2187        // 200. Score-descending order is preserved.
2188        let dts = CocoDetections::from_inputs(
2189            (0..500)
2190                .map(|i| {
2191                    let score = 1.0 - (i as f64) / 1000.0; // 1.0, 0.999, …, 0.501
2192                    dt_input(1, 1, score, (0.0, 0.0, 1.0, 1.0))
2193                })
2194                .collect(),
2195        )
2196        .unwrap();
2197        let trimmed = dts.lvis_trim(300);
2198        assert_eq!(trimmed.detections().len(), 300);
2199        // Scores must be descending and start at 1.0.
2200        let scores: Vec<f64> = trimmed.detections().iter().map(|d| d.score).collect();
2201        for w in scores.windows(2) {
2202            assert!(
2203                w[0] >= w[1],
2204                "lvis_trim must preserve score-descending order"
2205            );
2206        }
2207        assert!((scores[0] - 1.0).abs() < 1e-12);
2208        // The lowest score in the trimmed set is the 300th input
2209        // (1.0 - 299/1000 = 0.701).
2210        assert!((scores[299] - 0.701).abs() < 1e-12);
2211    }
2212
2213    #[test]
2214    fn ac3_q2_cross_class_crowding_keeps_300_total_across_classes() {
2215        // ADR-0026 appendix Q2: 250 cat-A + 350 cat-B detections on
2216        // one image trim to **300 total** (top-300 across both
2217        // classes by score combined), not 250 + min(350, 300) = 550.
2218        // Score layouts are interleaved so the trim has to actually
2219        // sort across classes — a per-class trim would leave cat-A
2220        // intact and only trim cat-B.
2221        let mut inputs = Vec::with_capacity(600);
2222        for i in 0..250 {
2223            // cat 1 scores: 0.5, 0.498, …, 0.002 (250 values)
2224            let score = 0.5 - (i as f64) * 0.002;
2225            inputs.push(dt_input(1, 1, score, (0.0, 0.0, 1.0, 1.0)));
2226        }
2227        for i in 0..350 {
2228            // cat 2 scores: 1.0, 0.998, …, 0.302 (350 values).
2229            // The top 300 across both classes are all cat-2 (every
2230            // cat-2 score >= 0.302 > every cat-1 score 0.5 only at
2231            // its top, so cross-class trim keeps cat-2 dominant).
2232            // Actually score 0.302 < 0.5 so cat-1 top entries
2233            // survive — see assertion below.
2234            let score = 1.0 - (i as f64) * 0.002;
2235            inputs.push(dt_input(1, 2, score, (0.0, 0.0, 1.0, 1.0)));
2236        }
2237        let dts = CocoDetections::from_inputs(inputs).unwrap();
2238        let trimmed = dts.lvis_trim(300);
2239        // AC3: top-300 total — not per-class.
2240        assert_eq!(trimmed.detections().len(), 300);
2241        // Counts per category in the trim — cat-2 has higher overall
2242        // scores so most of the trim is cat-2; cat-1's top entries
2243        // (score 0.5 ≥ 0.302) also make the cut.
2244        let n_cat1 = trimmed
2245            .detections()
2246            .iter()
2247            .filter(|d| d.category_id == CategoryId(1))
2248            .count();
2249        let n_cat2 = trimmed
2250            .detections()
2251            .iter()
2252            .filter(|d| d.category_id == CategoryId(2))
2253            .count();
2254        // cat-2 scores >= 0.5 are i in 0..=250; cat-1 scores >= 0.302
2255        // are i in 0..=99. The exact mix is determined by the sort
2256        // of all 600 scores; what we assert is the cross-class total.
2257        assert_eq!(n_cat1 + n_cat2, 300);
2258        // Sanity: neither class is fully empty (otherwise the trim
2259        // would have collapsed to per-class).
2260        assert!(n_cat1 > 0, "cat 1 must keep at least its top-score entries");
2261        assert!(n_cat2 > 0, "cat 2 must keep its high-score entries");
2262    }
2263
2264    #[test]
2265    fn ac5_negative_max_dets_disables_trim() {
2266        // `max_dets < 0` is the upstream `if max_dets >= 0` guard
2267        // disabled. `lvis_trim(-1)` must return every input
2268        // detection unchanged.
2269        let dts = CocoDetections::from_inputs(
2270            (0..50)
2271                .map(|i| dt_input(1, 1, i as f64 / 100.0, (0.0, 0.0, 1.0, 1.0)))
2272                .collect(),
2273        )
2274        .unwrap();
2275        let trimmed = dts.lvis_trim(-1);
2276        assert_eq!(trimmed.detections().len(), 50);
2277        // No reordering — the AC5 path doesn't even sort.
2278        for (i, dt) in trimmed.detections().iter().enumerate() {
2279            assert!((dt.score - (i as f64 / 100.0)).abs() < 1e-12);
2280        }
2281    }
2282
2283    #[test]
2284    fn ac5_max_dets_at_capacity_is_no_op() {
2285        // `max_dets >= n_dts` keeps every detection — but resorts
2286        // them score-descending. (We don't assert order preservation
2287        // because the trim is allowed to reorder; the contract is
2288        // count + membership.)
2289        let dts = CocoDetections::from_inputs(
2290            (0..10)
2291                .map(|i| dt_input(1, 1, i as f64 / 10.0, (0.0, 0.0, 1.0, 1.0)))
2292                .collect(),
2293        )
2294        .unwrap();
2295        let trimmed = dts.lvis_trim(100);
2296        assert_eq!(trimmed.detections().len(), 10);
2297    }
2298
2299    #[test]
2300    fn ac4_stable_sort_preserves_input_order_for_score_ties() {
2301        // Two detections with the exact same score — the trim must
2302        // keep them in input order. Python's `sorted(_,
2303        // reverse=True)` uses Timsort (stable); Rust's `slice::sort_by`
2304        // is also stable. This test pins the cross-language
2305        // invariant.
2306        let mut a = dt_input(1, 1, 0.5, (0.0, 0.0, 1.0, 1.0));
2307        a.id = Some(AnnId(100));
2308        let mut b = dt_input(1, 1, 0.5, (1.0, 0.0, 1.0, 1.0));
2309        b.id = Some(AnnId(200));
2310        let dts = CocoDetections::from_inputs(vec![a, b]).unwrap();
2311        let trimmed = dts.lvis_trim(2);
2312        let ids: Vec<AnnId> = trimmed.detections().iter().map(|d| d.id).collect();
2313        assert_eq!(
2314            ids,
2315            vec![AnnId(100), AnnId(200)],
2316            "AC4: stable sort must preserve input order on score ties"
2317        );
2318    }
2319
2320    #[test]
2321    fn lvis_trim_groups_by_image_id() {
2322        // 3 images, each with 5 detections; trim to 2 per image.
2323        // Verify the group boundaries are honored: image 1 gets its
2324        // top-2 cat-1 entries, image 2 gets its top-2 cat-2 entries,
2325        // etc.
2326        let mut inputs = Vec::with_capacity(15);
2327        for img in 1..=3i64 {
2328            for i in 0..5 {
2329                let score = 1.0 - (img as f64) * 0.01 - (i as f64) * 0.001;
2330                inputs.push(dt_input(img, img, score, (0.0, 0.0, 1.0, 1.0)));
2331            }
2332        }
2333        let dts = CocoDetections::from_inputs(inputs).unwrap();
2334        let trimmed = dts.lvis_trim(2);
2335        assert_eq!(trimmed.detections().len(), 6);
2336        // 2 per image:
2337        for img in 1..=3i64 {
2338            let n = trimmed
2339                .detections()
2340                .iter()
2341                .filter(|d| d.image_id == ImageId(img))
2342                .count();
2343            assert_eq!(n, 2, "image {img} must trim to 2");
2344        }
2345    }
2346
2347    #[test]
2348    fn lvis_trim_zero_max_dets_keeps_nothing() {
2349        let dts = CocoDetections::from_inputs(vec![
2350            dt_input(1, 1, 0.9, (0.0, 0.0, 1.0, 1.0)),
2351            dt_input(1, 1, 0.5, (0.0, 0.0, 1.0, 1.0)),
2352        ])
2353        .unwrap();
2354        let trimmed = dts.lvis_trim(0);
2355        assert!(trimmed.detections().is_empty());
2356    }
2357
2358    #[test]
2359    fn lvis_loader_inherits_invalid_annotation_validation() {
2360        // Annotation references unknown image — the spine validation
2361        // (J5 / AG1) must fire before AA7.
2362        const BAD: &str = r#"{
2363            "images": [
2364                {"id": 1, "width": 10, "height": 10,
2365                 "neg_category_ids": [], "not_exhaustive_category_ids": []}
2366            ],
2367            "annotations": [
2368                {"id": 1, "image_id": 99, "category_id": 1,
2369                 "bbox": [0, 0, 1, 1], "area": 1, "iscrowd": 0}
2370            ],
2371            "categories": [{"id": 1, "name": "a", "frequency": "f"}]
2372        }"#;
2373        let err = CocoDataset::from_lvis_json_bytes(BAD.as_bytes()).unwrap_err();
2374        assert!(matches!(err, EvalError::InvalidAnnotation { .. }));
2375    }
2376
2377    // -----------------------------------------------------------------
2378    // dataset_hash stability tests (ADR-0031)
2379    // -----------------------------------------------------------------
2380
2381    #[test]
2382    fn dataset_hash_is_stable_for_equal_inputs() {
2383        let a = load_crowd_region();
2384        let b = load_crowd_region();
2385        assert_eq!(a.dataset_hash(), b.dataset_hash());
2386    }
2387
2388    #[test]
2389    fn dataset_hash_caches_via_arc_clone() {
2390        // The cache is `Arc<OnceLock>` so a clone shares the slot. The
2391        // first call on either side populates it; the second call on
2392        // the clone should observe the cached value (i.e., equal).
2393        let a = load_crowd_region();
2394        let b = a.clone();
2395        let h1 = a.dataset_hash();
2396        let h2 = b.dataset_hash();
2397        assert_eq!(h1, h2);
2398    }
2399
2400    #[test]
2401    fn dataset_hash_invariant_to_image_order() {
2402        // Two datasets that differ only in image declaration order
2403        // must hash identically.
2404        let order_a = r#"{
2405            "images": [
2406                {"id": 1, "width": 10, "height": 10},
2407                {"id": 2, "width": 20, "height": 20}
2408            ],
2409            "annotations": [
2410                {"id": 1, "image_id": 1, "category_id": 1,
2411                 "bbox": [0, 0, 5, 5], "area": 25, "iscrowd": 0}
2412            ],
2413            "categories": [{"id": 1, "name": "x"}]
2414        }"#;
2415        let order_b = r#"{
2416            "images": [
2417                {"id": 2, "width": 20, "height": 20},
2418                {"id": 1, "width": 10, "height": 10}
2419            ],
2420            "annotations": [
2421                {"id": 1, "image_id": 1, "category_id": 1,
2422                 "bbox": [0, 0, 5, 5], "area": 25, "iscrowd": 0}
2423            ],
2424            "categories": [{"id": 1, "name": "x"}]
2425        }"#;
2426        let a = CocoDataset::from_json_bytes(order_a.as_bytes()).unwrap();
2427        let b = CocoDataset::from_json_bytes(order_b.as_bytes()).unwrap();
2428        assert_eq!(a.dataset_hash(), b.dataset_hash());
2429    }
2430
2431    #[test]
2432    fn dataset_hash_invariant_to_annotation_order() {
2433        let order_a = r#"{
2434            "images": [{"id": 1, "width": 200, "height": 200}],
2435            "annotations": [
2436                {"id": 1, "image_id": 1, "category_id": 1,
2437                 "bbox": [0, 0, 5, 5], "area": 25, "iscrowd": 0},
2438                {"id": 2, "image_id": 1, "category_id": 1,
2439                 "bbox": [10, 10, 5, 5], "area": 25, "iscrowd": 0}
2440            ],
2441            "categories": [{"id": 1, "name": "x"}]
2442        }"#;
2443        let order_b = r#"{
2444            "images": [{"id": 1, "width": 200, "height": 200}],
2445            "annotations": [
2446                {"id": 2, "image_id": 1, "category_id": 1,
2447                 "bbox": [10, 10, 5, 5], "area": 25, "iscrowd": 0},
2448                {"id": 1, "image_id": 1, "category_id": 1,
2449                 "bbox": [0, 0, 5, 5], "area": 25, "iscrowd": 0}
2450            ],
2451            "categories": [{"id": 1, "name": "x"}]
2452        }"#;
2453        let a = CocoDataset::from_json_bytes(order_a.as_bytes()).unwrap();
2454        let b = CocoDataset::from_json_bytes(order_b.as_bytes()).unwrap();
2455        assert_eq!(a.dataset_hash(), b.dataset_hash());
2456    }
2457
2458    #[test]
2459    fn dataset_hash_changes_when_bbox_changes_by_one_pixel() {
2460        let base = r#"{
2461            "images": [{"id": 1, "width": 200, "height": 200}],
2462            "annotations": [
2463                {"id": 1, "image_id": 1, "category_id": 1,
2464                 "bbox": [10, 10, 5, 5], "area": 25, "iscrowd": 0}
2465            ],
2466            "categories": [{"id": 1, "name": "x"}]
2467        }"#;
2468        let shifted = r#"{
2469            "images": [{"id": 1, "width": 200, "height": 200}],
2470            "annotations": [
2471                {"id": 1, "image_id": 1, "category_id": 1,
2472                 "bbox": [11, 10, 5, 5], "area": 25, "iscrowd": 0}
2473            ],
2474            "categories": [{"id": 1, "name": "x"}]
2475        }"#;
2476        let a = CocoDataset::from_json_bytes(base.as_bytes()).unwrap();
2477        let b = CocoDataset::from_json_bytes(shifted.as_bytes()).unwrap();
2478        assert_ne!(a.dataset_hash(), b.dataset_hash());
2479    }
2480
2481    proptest! {
2482        #[test]
2483        fn dataset_hash_invariant_under_id_shuffle(
2484            mut images in proptest::collection::vec(arb_image(), 1..16),
2485            categories in proptest::collection::vec(arb_category(), 1..4),
2486        ) {
2487            // Dedup images / categories by id — `from_parts` doesn't
2488            // reject duplicates, but the canonical-form hash is only
2489            // well-defined over a unique set.
2490            images.sort_by_key(|im| im.id.0);
2491            images.dedup_by_key(|im| im.id.0);
2492            let mut unique_categories = categories;
2493            unique_categories.sort_by_key(|c| c.id.0);
2494            unique_categories.dedup_by_key(|c| c.id.0);
2495            prop_assume!(!images.is_empty());
2496            prop_assume!(!unique_categories.is_empty());
2497
2498            // One annotation per image, all on the first category — the
2499            // shape doesn't matter, only that two datasets that differ
2500            // solely in declaration order should hash identically.
2501            let cat_id = unique_categories[0].id;
2502            let annotations: Vec<CocoAnnotation> = images
2503                .iter()
2504                .enumerate()
2505                .map(|(i, im)| make_min_annotation(AnnId((i as i64) + 1), im.id, cat_id))
2506                .collect();
2507            let mut shuffled = images.clone();
2508            shuffled.reverse();
2509
2510            let a = CocoDataset::from_parts(
2511                images,
2512                annotations.clone(),
2513                unique_categories.clone(),
2514            ).unwrap();
2515            let b = CocoDataset::from_parts(
2516                shuffled,
2517                annotations,
2518                unique_categories,
2519            ).unwrap();
2520            prop_assert_eq!(a.dataset_hash(), b.dataset_hash());
2521        }
2522    }
2523
2524    // -----------------------------------------------------------------
2525    // params_hash stability tests (ADR-0031)
2526    // -----------------------------------------------------------------
2527
2528    #[test]
2529    fn params_hash_is_stable_for_equal_inputs() {
2530        use crate::evaluate::OwnedEvaluateParams;
2531        let a = OwnedEvaluateParams {
2532            iou_thresholds: vec![0.5, 0.55, 0.6],
2533            area_ranges: vec![],
2534            max_dets_per_image: 100,
2535            use_cats: true,
2536            retain_iou: false,
2537        };
2538        let b = a.clone();
2539        assert_eq!(a.params_hash().unwrap(), b.params_hash().unwrap());
2540    }
2541
2542    #[test]
2543    fn params_hash_changes_when_thresholds_change() {
2544        use crate::evaluate::OwnedEvaluateParams;
2545        let a = OwnedEvaluateParams {
2546            iou_thresholds: vec![0.5, 0.55, 0.6],
2547            area_ranges: vec![],
2548            max_dets_per_image: 100,
2549            use_cats: true,
2550            retain_iou: false,
2551        };
2552        let mut b = a.clone();
2553        b.iou_thresholds.push(0.65);
2554        assert_ne!(a.params_hash().unwrap(), b.params_hash().unwrap());
2555    }
2556
2557    #[test]
2558    fn params_hash_changes_when_use_cats_toggles() {
2559        use crate::evaluate::OwnedEvaluateParams;
2560        let a = OwnedEvaluateParams {
2561            iou_thresholds: vec![0.5],
2562            area_ranges: vec![],
2563            max_dets_per_image: 100,
2564            use_cats: true,
2565            retain_iou: false,
2566        };
2567        let mut b = a.clone();
2568        b.use_cats = false;
2569        assert_ne!(a.params_hash().unwrap(), b.params_hash().unwrap());
2570    }
2571}