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}