Skip to main content

vernier_core/
breakdown.rs

1//! Generalized **Breakdown** axis — value-typed slice descriptor that
2//! replaces the hardcoded COCO area ranges (ADR-0016, *proposed*).
3//!
4//! ## What this module provides
5//!
6//! - [`Bucket`] — one slot on a [`Breakdown`]: a `[lo, hi]` closed
7//!   range over an annotation-derived `f64` key, paired with a
8//!   human-readable label and the `A`-axis position the summarizer
9//!   indexes by. (Closed-on-both-ends, per quirks **D6/D7**: see
10//!   [`Bucket::contains`].)
11//! - [`Breakdown`] — an axis name plus a list of [`Bucket`]s; the
12//!   value-typed configuration the orchestrator and summarizer share.
13//!
14//! ## Why this exists
15//!
16//! Today the per-image orchestrator binds annotations into A-axis
17//! buckets via [`crate::AreaRange`], and the summarizer reads them back
18//! out via [`crate::summarize::AreaRng`]. The two shapes are coupled (`index` on
19//! one matches `index` on the other) but live in different modules and
20//! describe the same conceptual axis with different field sets.
21//!
22//! `Breakdown` unifies them: one value owns the bucket layout, can hand
23//! out [`AreaRange`]s for the evaluator and [`AreaRng`]s for the
24//! summarizer, and carries an *axis name* — the seed CrowdPose's
25//! `crowdIndex` and any future depth/occlusion axes plug into. The
26//! constructors [`Breakdown::coco_area_det`] and
27//! [`Breakdown::coco_area_keypoints`] reproduce the canonical
28//! pycocotools layouts bit-for-bit; everything that runs through them
29//! yields the same numbers as the prior hardcoded path.
30//!
31//! ## Scope
32//!
33//! Internal-only for now. The FFI/Python boundary keeps the existing
34//! `AreaRange` / `AreaRng` shape unchanged so byte-identical parity is
35//! preserved. Custom user-defined Breakdowns are usable from Rust today
36//! (the `coco_area_det` / `coco_area_keypoints` constructors are
37//! examples), but no Python entry point accepts one yet — that is a
38//! follow-up ADR (CrowdPose-driven).
39//!
40//! [`AreaRange`]: crate::AreaRange
41//! [`AreaRng`]: crate::summarize::AreaRng
42
43use std::borrow::Cow;
44use std::collections::BTreeSet;
45
46use crate::evaluate::{AreaRange, AREA_UNBOUNDED};
47use crate::summarize::{AreaRng, MaxDetSelector, Metric, StatRequest};
48
49/// One bucket on a [`Breakdown`].
50///
51/// A bucket carries everything the per-image orchestrator and the
52/// summarizer need to slice a single A-axis cell: the inclusive `[lo,
53/// hi]` range used to test annotation membership, the label rendered in
54/// the summary table, and the `index` position on the
55/// [`crate::Accumulated`] A-axis where the cell's outputs land.
56///
57/// **Inclusivity.** `[lo, hi]` is closed on *both* ends, mirroring
58/// pycocotools' `cocoeval.py:251` predicate `not (area < lo or area >
59/// hi)` (quirk **D6** strict). An annotation whose key sits exactly on
60/// a boundary lands in *both* adjacent buckets. This is *not* the
61/// half-open convention `Range<f64>` would imply — buckets are not a
62/// partition, they are an overlap-tolerant cover.
63#[derive(Debug, Clone, PartialEq)]
64pub struct Bucket {
65    /// `A`-axis position. `0` is conventionally the `all` bucket on
66    /// area-keyed Breakdowns.
67    pub index: usize,
68    /// Label rendered by the summarizer (e.g. `"all"`, `"small"`,
69    /// `"medium"`, `"large"` for area; `"easy"`, `"hard"` for a future
70    /// CrowdPose breakdown).
71    pub label: Cow<'static, str>,
72    /// Lower bound (inclusive — quirks D6/D7).
73    pub lo: f64,
74    /// Upper bound (inclusive — quirks D6/D7). Use [`AREA_UNBOUNDED`]
75    /// (`1e10`) for "no upper bound"; pycocotools uses exactly that
76    /// value for the `all` and `large` buckets.
77    pub hi: f64,
78}
79
80impl Bucket {
81    /// `const`-friendly constructor for compile-time string labels.
82    pub const fn from_static(index: usize, label: &'static str, lo: f64, hi: f64) -> Self {
83        Self {
84            index,
85            label: Cow::Borrowed(label),
86            lo,
87            hi,
88        }
89    }
90
91    /// Constructor for owned-string labels (e.g., labels built at
92    /// runtime from a config file).
93    pub fn new(index: usize, label: impl Into<Cow<'static, str>>, lo: f64, hi: f64) -> Self {
94        Self {
95            index,
96            label: label.into(),
97            lo,
98            hi,
99        }
100    }
101
102    /// `true` when `key` falls inside `[lo, hi]` (closed on both ends —
103    /// quirks D6/D7).
104    pub fn contains(&self, key: f64) -> bool {
105        key >= self.lo && key <= self.hi
106    }
107
108    /// Lift this bucket into the [`AreaRange`] shape the per-image
109    /// orchestrator consumes.
110    pub fn to_area_range(&self) -> AreaRange {
111        AreaRange {
112            index: self.index,
113            lo: self.lo,
114            hi: self.hi,
115        }
116    }
117
118    /// Lift this bucket into the [`AreaRng`] shape the summarizer
119    /// consumes.
120    pub fn to_area_rng(&self) -> AreaRng {
121        AreaRng::new(self.index, self.label.clone())
122    }
123}
124
125/// A value-typed slice axis: a name plus a list of [`Bucket`]s.
126///
127/// `Breakdown` is the kernel summarizer's per-bucket slice descriptor.
128/// The two pre-defined constructors [`Breakdown::coco_area_det`] and
129/// [`Breakdown::coco_area_keypoints`] reproduce pycocotools' default
130/// area-grid layouts; user-defined Breakdowns are constructed via
131/// [`Breakdown::new`].
132///
133/// The `axis` name has no effect on numeric output today — labels on
134/// individual buckets are what reach the summary table — but is kept
135/// alongside the bucket list so a future schema-bump can surface it
136/// (e.g., `"breakdown_axis": "area"` in the CLI's JSON output, ADR-0015).
137///
138/// ## Invariants (debug-checked at construction)
139///
140/// - `buckets` is non-empty.
141/// - Every `Bucket::index` is unique and lies in `0..buckets.len()`.
142///
143/// These invariants let downstream consumers ([`Breakdown::area_ranges`],
144/// [`Breakdown::summary_areas`], the orchestrator) treat the layout as a
145/// dense, gap-free A-axis without re-validating.
146#[derive(Debug, Clone, PartialEq)]
147pub struct Breakdown {
148    axis: Cow<'static, str>,
149    buckets: Vec<Bucket>,
150}
151
152impl Breakdown {
153    /// Construct a breakdown from an axis name and a list of buckets.
154    ///
155    /// # Panics
156    ///
157    /// In debug builds, panics if `buckets` is empty or has duplicate
158    /// or out-of-range indices. Release builds silently accept the
159    /// degenerate input — downstream slicing will produce empty / `-1`
160    /// sentinel cells but cannot violate memory safety
161    /// (`#![forbid(unsafe_code)]`).
162    pub fn new(axis: impl Into<Cow<'static, str>>, buckets: Vec<Bucket>) -> Self {
163        let out = Self {
164            axis: axis.into(),
165            buckets,
166        };
167        debug_assert!(!out.buckets.is_empty(), "Breakdown must have >= 1 bucket");
168        let n = out.buckets.len();
169        debug_assert!(
170            out.buckets.iter().all(|b| b.index < n),
171            "Breakdown bucket index out of range",
172        );
173        let mut seen = vec![false; n];
174        for b in &out.buckets {
175            if b.index < n {
176                debug_assert!(!seen[b.index], "Breakdown has duplicate bucket index");
177                seen[b.index] = true;
178            }
179        }
180        out
181    }
182
183    /// The four-bucket COCO area grid for det-family kernels (bbox /
184    /// segm / boundary): `all`, `small`, `medium`, `large` with
185    /// inclusive `[lo, hi]` ranges `[0, 1e10]`, `[0, 32^2]`, `[32^2,
186    /// 96^2]`, `[96^2, 1e10]`.
187    ///
188    /// Indices line up with the legacy [`AreaRng`] constants: `0 =
189    /// all`, `1 = small`, `2 = medium`, `3 = large`. The literal numbers
190    /// are pinned by quirk **D4** (strict) and reproduce pycocotools'
191    /// `Params.areaRng` bit-for-bit.
192    pub fn coco_area_det() -> Self {
193        // The literal bound values mirror evaluate.rs::AreaRange::coco_default.
194        Self::new(
195            "area",
196            vec![
197                Bucket::from_static(0, "all", 0.0, AREA_UNBOUNDED),
198                Bucket::from_static(1, "small", 0.0, 32.0 * 32.0),
199                Bucket::from_static(2, "medium", 32.0 * 32.0, 96.0 * 96.0),
200                Bucket::from_static(3, "large", 96.0 * 96.0, AREA_UNBOUNDED),
201            ],
202        )
203    }
204
205    /// The three-bucket keypoints area grid: `all`, `medium`, `large`.
206    ///
207    /// Quirk **D5** (strict, ratified by ADR-0012): pycocotools omits
208    /// the `small` bucket for `iouType="keypoints"`. The A-axis is
209    /// compressed to three entries — `0 = all`, `1 = medium`, `2 =
210    /// large` — matching what the kp summarizer's
211    /// [`StatRequest::coco_keypoints_default`] expects.
212    pub fn coco_area_keypoints() -> Self {
213        Self::new(
214            "area",
215            vec![
216                Bucket::from_static(0, "all", 0.0, AREA_UNBOUNDED),
217                Bucket::from_static(1, "medium", 32.0 * 32.0, 96.0 * 96.0),
218                Bucket::from_static(2, "large", 96.0 * 96.0, AREA_UNBOUNDED),
219            ],
220        )
221    }
222
223    /// Axis name (e.g., `"area"`).
224    pub fn axis(&self) -> &str {
225        &self.axis
226    }
227
228    /// All buckets, in construction order.
229    pub fn buckets(&self) -> &[Bucket] {
230        &self.buckets
231    }
232
233    /// Number of buckets — the size of the `A`-axis the orchestrator
234    /// emits and the accumulator slices on.
235    pub fn len(&self) -> usize {
236        self.buckets.len()
237    }
238
239    /// `true` when [`Self::len`] is `0` (degenerate; only reachable in
240    /// release builds with a malformed [`Self::new`] call).
241    pub fn is_empty(&self) -> bool {
242        self.buckets.is_empty()
243    }
244
245    /// Bucket at `A`-axis position `index`, or `None` if absent.
246    pub fn bucket_at(&self, index: usize) -> Option<&Bucket> {
247        self.buckets.iter().find(|b| b.index == index)
248    }
249
250    /// Materialize the [`AreaRange`] slice the per-image orchestrator
251    /// consumes via [`crate::EvaluateParams::area_ranges`].
252    ///
253    /// Cheap (`buckets.len()` allocations) and idempotent — call as
254    /// often as needed; cache the result in a local if profiling shows
255    /// it on a hot path.
256    pub fn area_ranges(&self) -> Vec<AreaRange> {
257        self.buckets.iter().map(Bucket::to_area_range).collect()
258    }
259
260    /// Materialize the [`AreaRng`] slice the summarizer consumes when
261    /// rendering each per-bucket [`crate::summarize::StatLine`]. Useful for callers
262    /// that build custom `StatRequest` plans against this breakdown's
263    /// A-axis layout.
264    pub fn summary_areas(&self) -> Vec<AreaRng> {
265        self.buckets.iter().map(Bucket::to_area_rng).collect()
266    }
267
268    /// Build the canonical 12-row pycocotools detection plan over this
269    /// breakdown.
270    ///
271    /// The breakdown must have the four-bucket layout `(0, 1, 2, 3)`
272    /// matching `(all, small, medium, large)`; otherwise this method
273    /// returns `None` and the caller falls back to
274    /// [`StatRequest::coco_detection_default`] (which assumes the
275    /// canonical layout). For breakdowns with non-canonical bucket
276    /// counts (e.g., a 5-bucket fine-grained area split), callers
277    /// should compose their own plan via
278    /// [`StatRequest::new`] + [`Self::summary_areas`].
279    ///
280    /// Returning `Option` rather than `Result` keeps the surface lean:
281    /// the only failure mode is "this breakdown isn't the canonical
282    /// detection shape", which the caller resolves by composing a
283    /// custom plan, not by surfacing an error.
284    pub fn detection_plan(&self) -> Option<[StatRequest; 12]> {
285        if self.len() != 4 {
286            return None;
287        }
288        let all = self.bucket_at(0)?.to_area_rng();
289        let small = self.bucket_at(1)?.to_area_rng();
290        let medium = self.bucket_at(2)?.to_area_rng();
291        let large = self.bucket_at(3)?.to_area_rng();
292        use MaxDetSelector::{Largest, Value};
293        use Metric::{AveragePrecision, AverageRecall};
294        Some([
295            StatRequest::new(AveragePrecision, None, all.clone(), Largest),
296            StatRequest::new(AveragePrecision, Some(0.5), all.clone(), Largest),
297            StatRequest::new(AveragePrecision, Some(0.75), all.clone(), Largest),
298            StatRequest::new(AveragePrecision, None, small.clone(), Largest),
299            StatRequest::new(AveragePrecision, None, medium.clone(), Largest),
300            StatRequest::new(AveragePrecision, None, large.clone(), Largest),
301            StatRequest::new(AverageRecall, None, all.clone(), Value(1)),
302            StatRequest::new(AverageRecall, None, all.clone(), Value(10)),
303            StatRequest::new(AverageRecall, None, all, Value(100)),
304            StatRequest::new(AverageRecall, None, small, Largest),
305            StatRequest::new(AverageRecall, None, medium, Largest),
306            StatRequest::new(AverageRecall, None, large, Largest),
307        ])
308    }
309
310    /// Build the canonical 10-row pycocotools keypoints plan over this
311    /// breakdown.
312    ///
313    /// Mirrors [`StatRequest::coco_keypoints_default`] but pulls the
314    /// `(all, medium, large)` labels from this breakdown's buckets at
315    /// indices 0/1/2 — useful when a future caller wants the kp plan
316    /// shape but with custom labels (e.g., dataset-specific
317    /// "small-medium / medium / large" wording).
318    ///
319    /// Returns `None` unless the breakdown has exactly three buckets
320    /// at indices 0/1/2.
321    pub fn keypoints_plan(&self) -> Option<[StatRequest; 10]> {
322        if self.len() != 3 {
323            return None;
324        }
325        let all = self.bucket_at(0)?.to_area_rng();
326        let medium = self.bucket_at(1)?.to_area_rng();
327        let large = self.bucket_at(2)?.to_area_rng();
328        use MaxDetSelector::Largest;
329        use Metric::{AveragePrecision, AverageRecall};
330        Some([
331            StatRequest::new(AveragePrecision, None, all.clone(), Largest),
332            StatRequest::new(AveragePrecision, Some(0.5), all.clone(), Largest),
333            StatRequest::new(AveragePrecision, Some(0.75), all.clone(), Largest),
334            StatRequest::new(AveragePrecision, None, medium.clone(), Largest),
335            StatRequest::new(AveragePrecision, None, large.clone(), Largest),
336            StatRequest::new(AverageRecall, None, all.clone(), Largest),
337            StatRequest::new(AverageRecall, Some(0.5), all.clone(), Largest),
338            StatRequest::new(AverageRecall, Some(0.75), all, Largest),
339            StatRequest::new(AverageRecall, None, medium, Largest),
340            StatRequest::new(AverageRecall, None, large, Largest),
341        ])
342    }
343}
344
345/// One group on a [`ClassGroupBreakdown`].
346///
347/// Class-group breakdowns partition the class-id space into named
348/// subsets — e.g., `{"vehicles": [3, 6, 8], "animals": [16, 17, 18]}`
349/// for a semantic-segmentation rollup. Unlike [`Bucket`] (closed
350/// `[lo, hi]` over `f64`), a [`ClassGroup`] is a discrete set of
351/// class ids with strict partition discipline at the breakdown level.
352///
353/// `class_ids` is private behind [`Self::class_ids`] so the
354/// sorted-unique invariant established by [`Self::new`] is preserved
355/// post-construction — it's load-bearing for the binary search in
356/// [`ClassGroupBreakdown::group_of`] and for deterministic hashing in
357/// the future per-paradigm `params_hash`.
358#[derive(Debug, Clone, PartialEq, Eq)]
359pub struct ClassGroup {
360    /// Position on the group axis. Dense `0..groups.len()`.
361    pub index: usize,
362    /// Human-readable label (e.g., `"vehicles"`).
363    pub label: Cow<'static, str>,
364    class_ids: Vec<u32>,
365}
366
367impl ClassGroup {
368    /// Constructor that sorts and dedups `class_ids`. The user-facing
369    /// validators (FFI / Python `__post_init__`) reject duplicates
370    /// up-front; this dedup is defensive belt-and-suspenders for
371    /// internal callers.
372    pub fn new(
373        index: usize,
374        label: impl Into<Cow<'static, str>>,
375        class_ids: impl IntoIterator<Item = u32>,
376    ) -> Self {
377        let mut ids: Vec<u32> = class_ids.into_iter().collect();
378        ids.sort_unstable();
379        ids.dedup();
380        Self {
381            index,
382            label: label.into(),
383            class_ids: ids,
384        }
385    }
386
387    /// Class ids in this group, sorted ascending and deduplicated.
388    pub fn class_ids(&self) -> &[u32] {
389        &self.class_ids
390    }
391}
392
393/// A class-id-keyed slice axis: a name plus a list of [`ClassGroup`]s.
394///
395/// The sibling of [`Breakdown`] for class-id partitions. Used by
396/// semantic (ADR-0041) and panoptic (ADR-0042) `class_grouping`
397/// fields. Carries strict partition discipline — no class id may
398/// appear in two groups.
399///
400/// ## Invariants (debug-checked at construction)
401///
402/// - `groups` is non-empty.
403/// - Every `ClassGroup::index` is unique and lies in
404///   `0..groups.len()`.
405/// - No class id appears in two groups (partition discipline).
406/// - Group labels are unique.
407#[derive(Debug, Clone, PartialEq, Eq)]
408pub struct ClassGroupBreakdown {
409    axis: Cow<'static, str>,
410    groups: Vec<ClassGroup>,
411}
412
413impl ClassGroupBreakdown {
414    /// Construct from an axis name and a list of groups.
415    ///
416    /// # Panics
417    ///
418    /// In debug builds, panics if `groups` is empty, has duplicate /
419    /// out-of-range indices, has duplicate labels, or has a class id
420    /// appearing in two groups. Release builds silently accept the
421    /// degenerate input — no memory safety risk under
422    /// `#![forbid(unsafe_code)]`.
423    pub fn new(axis: impl Into<Cow<'static, str>>, groups: Vec<ClassGroup>) -> Self {
424        let out = Self {
425            axis: axis.into(),
426            groups,
427        };
428        debug_assert!(
429            !out.groups.is_empty(),
430            "ClassGroupBreakdown must have >= 1 group",
431        );
432        let n = out.groups.len();
433        debug_assert!(
434            out.groups.iter().all(|g| g.index < n),
435            "ClassGroupBreakdown group index out of range",
436        );
437        let mut seen_idx = vec![false; n];
438        let mut seen_labels: BTreeSet<&str> = BTreeSet::new();
439        let mut seen_ids: BTreeSet<u32> = BTreeSet::new();
440        for g in &out.groups {
441            if g.index < n {
442                debug_assert!(
443                    !seen_idx[g.index],
444                    "ClassGroupBreakdown duplicate group index",
445                );
446                seen_idx[g.index] = true;
447            }
448            debug_assert!(
449                seen_labels.insert(g.label.as_ref()),
450                "ClassGroupBreakdown duplicate group label",
451            );
452            for &cid in g.class_ids() {
453                debug_assert!(
454                    seen_ids.insert(cid),
455                    "ClassGroupBreakdown class id {cid} appears in multiple groups",
456                );
457            }
458        }
459        out
460    }
461
462    /// Axis name (e.g., `"object_size"`, `"vehicle_taxonomy"`).
463    pub fn axis(&self) -> &str {
464        &self.axis
465    }
466
467    /// All groups, in construction order.
468    pub fn groups(&self) -> &[ClassGroup] {
469        &self.groups
470    }
471
472    /// Number of groups — the size of the group axis the summarizer
473    /// emits one row per.
474    pub fn len(&self) -> usize {
475        self.groups.len()
476    }
477
478    /// `true` when [`Self::len`] is `0` (degenerate; only reachable in
479    /// release builds with a malformed [`Self::new`] call).
480    pub fn is_empty(&self) -> bool {
481        self.groups.is_empty()
482    }
483
484    /// Group at axis position `index`, or `None` if absent.
485    pub fn group_at(&self, index: usize) -> Option<&ClassGroup> {
486        self.groups.iter().find(|g| g.index == index)
487    }
488
489    /// Look up the group containing `class_id`. `None` if no group
490    /// covers it (partition allows un-grouped ids — they're simply
491    /// excluded from per-group rollups).
492    pub fn group_of(&self, class_id: u32) -> Option<&ClassGroup> {
493        self.groups
494            .iter()
495            .find(|g| g.class_ids().binary_search(&class_id).is_ok())
496    }
497}
498
499#[cfg(test)]
500mod tests {
501    use super::*;
502    use crate::accumulate::{accumulate, AccumulateParams};
503    use crate::evaluate::AreaRange;
504    use crate::parity::{iou_thresholds, recall_thresholds, ParityMode};
505    use crate::summarize::{summarize_with, Metric};
506    use ndarray::{Array4, Array5};
507
508    #[test]
509    fn coco_area_det_matches_legacy_area_range_coco_default_bitwise() {
510        // Strict-parity contract: the new Breakdown surface must produce
511        // identical bound bytes to the legacy `AreaRange::coco_default`.
512        // Anything looser silently re-bins annotations near a bucket
513        // boundary and breaks quirk D6.
514        let bd = Breakdown::coco_area_det();
515        let legacy = AreaRange::coco_default();
516        let ranges = bd.area_ranges();
517        assert_eq!(ranges.len(), legacy.len());
518        for (got, want) in ranges.iter().zip(legacy.iter()) {
519            assert_eq!(got.index, want.index, "index drift on bucket {}", got.index);
520            assert_eq!(
521                got.lo.to_bits(),
522                want.lo.to_bits(),
523                "lo bound drift on bucket {}",
524                got.index,
525            );
526            assert_eq!(
527                got.hi.to_bits(),
528                want.hi.to_bits(),
529                "hi bound drift on bucket {}",
530                got.index,
531            );
532        }
533    }
534
535    #[test]
536    fn coco_area_keypoints_matches_legacy_keypoints_default_bitwise() {
537        let bd = Breakdown::coco_area_keypoints();
538        let legacy = AreaRange::keypoints_default();
539        let ranges = bd.area_ranges();
540        assert_eq!(ranges.len(), legacy.len());
541        for (got, want) in ranges.iter().zip(legacy.iter()) {
542            assert_eq!(got.index, want.index);
543            assert_eq!(got.lo.to_bits(), want.lo.to_bits());
544            assert_eq!(got.hi.to_bits(), want.hi.to_bits());
545        }
546    }
547
548    #[test]
549    fn coco_area_det_summary_labels_pin_canonical_strings() {
550        let bd = Breakdown::coco_area_det();
551        let labels: Vec<&str> = bd.buckets().iter().map(|b| b.label.as_ref()).collect();
552        assert_eq!(labels, ["all", "small", "medium", "large"]);
553        assert_eq!(bd.axis(), "area");
554    }
555
556    #[test]
557    fn coco_area_keypoints_drops_small_bucket_per_d5() {
558        // D5 / ADR-0012: kp summary has no "small" row. The breakdown's
559        // labels and indices encode that — three entries (all, medium,
560        // large) at indices (0, 1, 2). A future regression that
561        // re-introduces "small" trips this assertion.
562        let bd = Breakdown::coco_area_keypoints();
563        assert_eq!(bd.len(), 3);
564        let labels: Vec<&str> = bd.buckets().iter().map(|b| b.label.as_ref()).collect();
565        assert_eq!(labels, ["all", "medium", "large"]);
566        assert!(!labels.contains(&"small"));
567        // Indices 0..3 dense, no gap — accumulator A-axis is dense.
568        let indices: Vec<usize> = bd.buckets().iter().map(|b| b.index).collect();
569        assert_eq!(indices, [0, 1, 2]);
570    }
571
572    #[test]
573    fn bucket_contains_is_inclusive_on_both_ends() {
574        // D6 strict: a key equal to either bound lands inside the bucket.
575        // Pycocotools' `not (area < lo or area > hi)` is bit-equivalent.
576        let small = Bucket::from_static(1, "small", 0.0, 32.0 * 32.0);
577        let medium = Bucket::from_static(2, "medium", 32.0 * 32.0, 96.0 * 96.0);
578        // Boundary value 1024 belongs to BOTH buckets (overlap-tolerant).
579        assert!(small.contains(1024.0));
580        assert!(medium.contains(1024.0));
581        // Strictly out-of-range values are excluded.
582        assert!(!small.contains(-1.0));
583        assert!(!medium.contains(1023.999));
584        assert!(small.contains(0.0));
585        assert!(medium.contains(96.0 * 96.0));
586    }
587
588    #[test]
589    fn detection_plan_matches_canonical_default_bitwise() {
590        // The Breakdown-built detection plan and the static
591        // `coco_detection_default` must produce stat-by-stat equal results
592        // when summarized over the same Accumulated. This pins the
593        // "default Breakdown produces byte-identical output to the prior
594        // hardcoded path" invariant.
595        let iou = iou_thresholds();
596        let max_dets = [1usize, 10, 100];
597        let accum = crate::Accumulated {
598            precision: Array5::<f64>::from_elem((iou.len(), 101, 1, 4, 3), 0.5),
599            recall: Array4::<f64>::from_elem((iou.len(), 1, 4, 3), 0.7),
600            scores: Array5::<f64>::from_elem((iou.len(), 101, 1, 4, 3), 1.0),
601        };
602
603        let static_plan = StatRequest::coco_detection_default();
604        let bd = Breakdown::coco_area_det();
605        let bd_plan = bd.detection_plan().expect("4-bucket layout");
606
607        let from_static = summarize_with(&accum, &static_plan, iou, &max_dets).unwrap();
608        let from_bd = summarize_with(&accum, &bd_plan, iou, &max_dets).unwrap();
609
610        assert_eq!(from_static.stats(), from_bd.stats());
611        // Stat-by-stat: metric, threshold, label, max_dets, value.
612        for (s, b) in from_static.lines.iter().zip(from_bd.lines.iter()) {
613            assert_eq!(s.metric, b.metric);
614            assert_eq!(s.iou_threshold, b.iou_threshold);
615            assert_eq!(s.area.label, b.area.label);
616            assert_eq!(s.area.index, b.area.index);
617            assert_eq!(s.max_dets, b.max_dets);
618            assert_eq!(s.value.to_bits(), b.value.to_bits());
619        }
620    }
621
622    #[test]
623    fn keypoints_plan_matches_canonical_default_bitwise() {
624        let iou = iou_thresholds();
625        let max_dets = [20usize];
626        let accum = crate::Accumulated {
627            precision: Array5::<f64>::from_elem((iou.len(), 101, 1, 3, 1), 0.5),
628            recall: Array4::<f64>::from_elem((iou.len(), 1, 3, 1), 0.7),
629            scores: Array5::<f64>::from_elem((iou.len(), 101, 1, 3, 1), 1.0),
630        };
631
632        let static_plan = StatRequest::coco_keypoints_default();
633        let bd = Breakdown::coco_area_keypoints();
634        let bd_plan = bd.keypoints_plan().expect("3-bucket kp layout");
635
636        let from_static = summarize_with(&accum, &static_plan, iou, &max_dets).unwrap();
637        let from_bd = summarize_with(&accum, &bd_plan, iou, &max_dets).unwrap();
638
639        assert_eq!(from_static.stats(), from_bd.stats());
640        for (s, b) in from_static.lines.iter().zip(from_bd.lines.iter()) {
641            assert_eq!(s.area.index, b.area.index);
642            assert_eq!(s.area.label, b.area.label);
643            assert_eq!(s.value.to_bits(), b.value.to_bits());
644        }
645    }
646
647    #[test]
648    fn detection_plan_returns_none_for_non_canonical_size() {
649        // Non-4-bucket breakdowns can't be cast into the canonical
650        // 12-stat plan; the helper returns None so callers fall back to
651        // composing their own plan rather than getting a silently-wrong
652        // shape.
653        let bd = Breakdown::coco_area_keypoints(); // 3 buckets
654        assert!(bd.detection_plan().is_none());
655    }
656
657    #[test]
658    fn keypoints_plan_returns_none_for_non_canonical_size() {
659        let bd = Breakdown::coco_area_det(); // 4 buckets
660        assert!(bd.keypoints_plan().is_none());
661    }
662
663    #[test]
664    fn fine_grained_five_bucket_breakdown_extends_a_axis() {
665        // The headline test: a non-default breakdown plugs in to the
666        // pipeline as a leaf change. We add a "tiny" bucket below
667        // "small" and a "huge" bucket above "large", run an Accumulated
668        // shaped to the 5-bucket A-axis, and assert that the resulting
669        // Summary has one line per requested bucket and that each value
670        // is what hand-tracing the Accumulated cells predicts.
671        let bd = Breakdown::new(
672            "area",
673            vec![
674                Bucket::from_static(0, "tiny", 0.0, 16.0 * 16.0),
675                Bucket::from_static(1, "small", 16.0 * 16.0, 32.0 * 32.0),
676                Bucket::from_static(2, "medium", 32.0 * 32.0, 96.0 * 96.0),
677                Bucket::from_static(3, "large", 96.0 * 96.0, 192.0 * 192.0),
678                Bucket::from_static(4, "huge", 192.0 * 192.0, AREA_UNBOUNDED),
679            ],
680        );
681        assert_eq!(bd.len(), 5);
682
683        // Hand-craft an Accumulated whose A-axis matches the 5-bucket
684        // layout. Each bucket's precision tensor is set to a distinct
685        // value (0.10..0.50) so the per-bucket Summary value pins which
686        // slice the summarizer read.
687        let iou = iou_thresholds();
688        let max_dets = [100usize];
689        let mut precision = Array5::<f64>::from_elem((iou.len(), 101, 1, 5, 1), -1.0);
690        let mut recall = Array4::<f64>::from_elem((iou.len(), 1, 5, 1), -1.0);
691        let scores = Array5::<f64>::from_elem((iou.len(), 101, 1, 5, 1), 1.0);
692        for a in 0..5 {
693            let pr_val = 0.1 * (a as f64 + 1.0);
694            let rc_val = 0.2 * (a as f64 + 1.0);
695            for t in 0..iou.len() {
696                for r in 0..101 {
697                    precision[(t, r, 0, a, 0)] = pr_val;
698                }
699                recall[(t, 0, a, 0)] = rc_val;
700            }
701        }
702        let accum = crate::Accumulated {
703            precision,
704            recall,
705            scores,
706        };
707
708        // Compose a custom plan: AP-wide, no IoU pin, one line per bucket.
709        let plan: Vec<StatRequest> = bd
710            .buckets()
711            .iter()
712            .map(|b| {
713                StatRequest::new(
714                    Metric::AveragePrecision,
715                    None,
716                    b.to_area_rng(),
717                    MaxDetSelector::Largest,
718                )
719            })
720            .collect();
721
722        let summary = summarize_with(&accum, &plan, iou, &max_dets).unwrap();
723        assert_eq!(summary.lines.len(), 5, "one line per bucket");
724
725        // Hand-traced expectations: AP for bucket a is the constant
726        // we packed into the precision tensor at A-axis position a
727        // (the mean over a constant slice is the constant).
728        let expected_labels = ["tiny", "small", "medium", "large", "huge"];
729        for (a, line) in summary.lines.iter().enumerate() {
730            let expected = 0.1 * (a as f64 + 1.0);
731            assert_eq!(
732                line.area.label.as_ref(),
733                expected_labels[a],
734                "bucket {a} label drift",
735            );
736            assert!(
737                (line.value - expected).abs() < 1e-12,
738                "bucket {a} value: got {got}, expected {expected}",
739                got = line.value,
740            );
741        }
742
743        // The breakdown can also be lifted into the AreaRange slice the
744        // orchestrator consumes — the `accumulate` round trip below pins
745        // that the A-axis size derived from the breakdown matches the
746        // Accumulated tensor's A-axis size, so the orchestrator → accum
747        // → summarize pipeline lines up.
748        let area_ranges = bd.area_ranges();
749        assert_eq!(area_ranges.len(), 5);
750        let acc_params = AccumulateParams {
751            iou_thresholds: iou,
752            recall_thresholds: recall_thresholds(),
753            max_dets: &max_dets,
754            n_categories: 1,
755            n_area_ranges: area_ranges.len(),
756            n_images: 0,
757        };
758        // Empty grid; we just verify the shape contract end-to-end.
759        let empty = accumulate(&[], acc_params, ParityMode::Strict).unwrap();
760        assert_eq!(empty.precision.shape()[3], 5);
761    }
762
763    #[test]
764    #[cfg(debug_assertions)]
765    #[should_panic(expected = "Breakdown must have >= 1 bucket")]
766    fn empty_breakdown_panics_in_debug() {
767        let _ = Breakdown::new("axis", vec![]);
768    }
769
770    #[test]
771    #[cfg(debug_assertions)]
772    #[should_panic(expected = "Breakdown bucket index out of range")]
773    fn out_of_range_index_panics_in_debug() {
774        let _ = Breakdown::new("axis", vec![Bucket::from_static(5, "x", 0.0, 1.0)]);
775    }
776
777    #[test]
778    #[cfg(debug_assertions)]
779    #[should_panic(expected = "Breakdown has duplicate bucket index")]
780    fn duplicate_index_panics_in_debug() {
781        let _ = Breakdown::new(
782            "axis",
783            vec![
784                Bucket::from_static(0, "a", 0.0, 1.0),
785                Bucket::from_static(0, "b", 1.0, 2.0),
786            ],
787        );
788    }
789
790    #[test]
791    fn class_group_new_sorts_and_dedups_class_ids() {
792        let g = ClassGroup::new(0, "vehicles", vec![8, 3, 6, 3, 8]);
793        assert_eq!(g.class_ids(), &[3, 6, 8]);
794        assert_eq!(g.index, 0);
795        assert_eq!(g.label, "vehicles");
796    }
797
798    #[test]
799    fn class_group_breakdown_basic_shape() {
800        let bd = ClassGroupBreakdown::new(
801            "vehicle_taxonomy",
802            vec![
803                ClassGroup::new(0, "small", [3, 4]),
804                ClassGroup::new(1, "large", [6, 8]),
805            ],
806        );
807        assert_eq!(bd.axis(), "vehicle_taxonomy");
808        assert_eq!(bd.len(), 2);
809        assert!(!bd.is_empty());
810    }
811
812    #[test]
813    fn class_group_breakdown_lookup_by_index_and_class_id() {
814        let bd = ClassGroupBreakdown::new(
815            "g",
816            vec![
817                ClassGroup::new(0, "a", [1, 2, 3]),
818                ClassGroup::new(1, "b", [10, 20]),
819            ],
820        );
821        assert_eq!(bd.group_at(0).unwrap().label, "a");
822        assert_eq!(bd.group_at(1).unwrap().label, "b");
823        assert!(bd.group_at(2).is_none());
824        assert_eq!(bd.group_of(2).unwrap().label, "a");
825        assert_eq!(bd.group_of(20).unwrap().label, "b");
826        assert!(bd.group_of(99).is_none());
827    }
828
829    #[test]
830    #[cfg(debug_assertions)]
831    #[should_panic(expected = "ClassGroupBreakdown must have >= 1 group")]
832    fn class_group_breakdown_empty_panics_in_debug() {
833        let _ = ClassGroupBreakdown::new("g", vec![]);
834    }
835
836    #[test]
837    #[cfg(debug_assertions)]
838    #[should_panic(expected = "ClassGroupBreakdown duplicate group label")]
839    fn class_group_breakdown_duplicate_label_panics_in_debug() {
840        let _ = ClassGroupBreakdown::new(
841            "g",
842            vec![
843                ClassGroup::new(0, "vehicles", [1]),
844                ClassGroup::new(1, "vehicles", [2]),
845            ],
846        );
847    }
848
849    #[test]
850    #[cfg(debug_assertions)]
851    #[should_panic(expected = "appears in multiple groups")]
852    fn class_group_breakdown_partition_violation_panics_in_debug() {
853        let _ = ClassGroupBreakdown::new(
854            "g",
855            vec![
856                ClassGroup::new(0, "a", [1, 2, 3]),
857                ClassGroup::new(1, "b", [3, 4]),
858            ],
859        );
860    }
861}