Skip to content

test(parity): accumulated arrays vs pycocotools - #15

Merged
derekallman merged 1 commit into
derekallman:mainfrom
Borda:test/accumulate-array-parity
Sep 25, 2026
Merged

derekallman merged 1 commit into
derekallman:mainfrom
Borda:test/accumulate-array-parity

Conversation

@Borda

@Borda Borda commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

Description

A fast parity test that compares the accumulated precision, recall, and scores arrays against pycocotools bit for bit, on a dataset built so that ranking order is observable. Test only; no source change.

Branch test/accumulate-array-parity is cut from main (1bfe6ad) and is independent of the perf/* branches. It passes on main and on perf/A1.

Motivation

Every existing test in scripts/test_parity.py compares the 12 headline metrics. Each of those averages 1,010 precision points, so one score tie broken in the wrong order moves AP by about 1e-4 — inside the tolerance the val2017 parity run accepts — and can cancel against another tie in the same average. The fast suite also had no dataset in which scores collide across images while maxDets truncates each image's list, which is exactly the situation where accumulate()'s stable sort and per-image truncation decide the curve.

That leaves the three open accumulate() performance PRs (perf/A1, perf/A2, perf/A3) anchored to pycocotools only through just fuzz (7 minutes, four-decimal scores so ties are rare) and just parity on val2017 (data/ is local-only). Their own Rust tests use a fresh hotcoco run as the oracle, which proves the optimized path equals the unoptimized path, not that either equals the reference. This test closes that gap in 0.13 s, in CI (ci.yml runs scripts/test_parity.py).

Change

  • scripts/test_parity.py: new test_accumulated_arrays_match_pycocotools_bit_for_bit, with three helpers.
    • _Lcg: a fixed 64-bit linear congruential generator, so the ties never depend on a seed anyone can change.
    • _tie_heavy_dataset(): 12 images × 3 categories, 14 detections per (image, category) cell, scores quantized to two decimals (98 distinct values over 506 detections), one hand-built cross-image tie (a TP in image 1 and an FP in image 2, both scoring exactly 0.70), and a third category with no ground truth. Every cell holds more detections than the middle maxDets cap, so per-image truncation fires.
    • _accumulated_arrays(): runs evaluate() + accumulate() through both tools and returns the two eval dicts. Accepts a second maxDets list to assign between the two calls — pycocotools' accumulate(p) idiom, which leaves cells holding more detections than the current cap.
    • The test asserts two sanity conditions on the data (scores collide across cells; the cap-1 and cap-10 curves differ and are non-trivial), then compares all three arrays as uint64 views for maxDets=[1, 10, 100] and for evaluate([1, 10, 100]) → accumulate([10, 1]). A failure names the first differing (t, r, k, a, m) and both values.

Correctness evidence

The test was checked to fail on three violations injected into main's accumulate(), each rebuilt and run:

Injection Result
inds.sort_by → inds.sort_unstable_by 1,533 precision positions differ
per-image truncation .min(max_det) → .min(max_det + 1) 1,586 differ
cell concatenation for eval_img in &evals → evals.iter().rev() (tie order flipped) 3,076 differ

A fourth attempt — swapping the comparator arguments and appending .reverse() — is a semantic no-op and passed; it is not counted.

With the injections reverted: pytest scripts/test_parity.py crates/hotcoco-pyo3/tests → 108 passed, 24 skipped (107 before). ruff check and ruff format --check clean. Pre-commit hook (fmt, clippy, cargo test, ruff) green on the commit.

Not in this PR

  • No (image, category) cell in the dataset is empty, so the known per-cell None-gate divergence from pycocotools (len(gt)==0 and len(dt)==0; affects eval["scores"] at recall threshold 0 for non-"all" area ranges) is not exercised. scores matching exactly here says nothing about that case.
  • No CHANGELOG entry: test-only change.
  • Keypoints and segmentation go through the same accumulate() and are not separately covered here; just fuzz covers them at tolerance.

- Add `test_accumulated_arrays_match_pycocotools_bit_for_bit` to `scripts/test_parity.py`: compares `eval["precision"]`, `eval["recall"]`, and `eval["scores"]` against pycocotools as `uint64` views, so a single tie broken the wrong way fails with the first differing `(t, r, k, a, m)` instead of averaging into a headline metric.
- Add `_tie_heavy_dataset()`: 12 images x 3 categories, 14 detections per cell, scores quantized to two decimals so equal scores span images, one hand-built cross-image TP/FP tie at 0.70, and a category with no ground truth. Cells exceed the middle `maxDets` cap so per-image truncation fires.
- Add `_Lcg`, a fixed 64-bit LCG, so the ties do not depend on a seed anyone can change.
- Add `_accumulated_arrays()`: runs both tools with `maxDets=[1, 10, 100]` and, in a second case, re-assigns the caps to `[10, 1]` between `evaluate()` and `accumulate()`.
- Checked to fail on three injected violations in `accumulate()`: an unstable sort, per-image truncation at `max_det + 1`, and reversed cell concatenation order.
---
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Borda
Borda force-pushed the test/accumulate-array-parity branch from 3c23031 to 9e524ac Compare September 16, 2026 09:54
@derekallman
derekallman merged commit 080ed1a into derekallman:main Sep 25, 2026
6 checks passed
derekallman added a commit that referenced this pull request Sep 25, 2026
… perf PRs

Post-merge cleanup of #11, #12, #13, and #15. No behavior change: every
Rust suite, the fast pytest suite, and real-data parity on val2017 pass
bit-identically before and after.

- Extract one `tie_heavy_datasets()` builder and one `Lcg` into the
  integration-test helpers; both accumulate tests call it instead of
  carrying their own copy, and it now matches the seed and cell size of
  `_tie_heavy_dataset` in scripts/test_parity.py, with cross-references
  in both directions.
- Borrow the shared score order for the unfiltered `maxDets` slot instead
  of copying it once per work item.
- Drop the dead `nd` counter and early return from
  `precision_recall_curve_of_order_into`; the fall-through already returns
  the same value. Remove unused derives on `Tally`.
- Replace comments that narrated the pre-merge code or a rejected
  experiment with the constraint they were standing in for, and fix a
  comment that wrongly claimed `rand` is not a dependency.
- Test nits: compare slice metrics straight from the `BTreeMap`, turn a
  five-parameter closure into a nested fn, hoist a loop-invariant assert.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0122uhTHqxEPRhN3cEZoSaaU
@Borda
Borda deleted the test/accumulate-array-parity branch September 25, 2026 07:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants