Repository navigation
perf(detection): reconcile candidates with a sorted neighbour check - #395
binaryaaron wants to merge 1 commit into
Conversation
[Medium impact] The PR appears safe to merge; no actionable issues were found. Summary
Reviews (2) · Last reviewed commit: "perf(detection): reconcile candidates wi..." · Reviewed by Greptile |
28f8439 to
4c88896
Compare
4c88896 to
6352cba
Compare
lipikaramaswamy
left a comment
There was a problem hiding this comment.
Thanks for the focused optimization. I reviewed the intended two-file change at 21cbc72 separately from the older #305 history currently included in this PR. The neighbour check looks correct under the validated, non-empty/disjoint interval invariants; I found no acceptance, provenance, or publication-order regression.
Requesting changes for two items before merge:
-
Restack only the performance commit onto rebased #305. This head still has 28f8439 as its direct parent, while the current target is 6352cba. GitHub currently shows 31 commits / 54 changed files and a non-mergeable PR; local merge analysis reports six conflicting paths. Please replay only 21cbc72 onto current #305, preserve the newer base behavior, and rerun relevant tests. The intended change should then be reviewable as the small candidates.py/test patch, without replaying obsolete #305 commits.
-
Correct the total/worst-case complexity claim. At candidates.py:176-177, each accepted candidate can perform two O(K) Python list insertions. Binary-search overlap lookup is O(log K), but the complete algorithm remains O(M²) in the worst case. For a concrete valid input, use M disjoint spans at start=4*i: left half length 1, right half length 2. Length ranking accepts the right half first; each left-half insertion then shifts M/2 entries in each list, totaling M²/2 shifted entries. The existing 5000-span equal-length/increasing-start test exercises the append-shaped case instead. Please distinguish logarithmic lookup, linear insertion, and the substantial practical speedup in the summary/measurements. I am not requesting a new tree structure or an admission/policy rewrite.
Independent validation of this head:
- 415 candidate/lifecycle/admission/schema tests passed.
- 4,440 exhaustive/random scenarios, including iterator/reversed/coalesced and shuffled variants, matched the original full-scan implementation loaded directly from Git.
- All 2,155 full-suite tests passed across an initial run (2,150 pass, five environment-blocked failures) and a targeted rerun of those five with local socket access and short temp paths; this was not one uninterrupted green full-suite run.
- Changed-file lint/format and diff checks passed.
Local synthetic timings support the optimization: 8k disjoint spans took about 32–36 ms versus 1.63–2.39 s for the old scan. These are machine-specific observations, not a worst-case bound. Happy with the direction once the stack and complexity wording are corrected.
reconcile_candidates scanned every accepted span for each candidate, which is O(M^2) Python comparisons per source text and becomes the bottleneck on dense agent traces with thousands of spans per record. Accepted spans are disjoint, so keep their bounds sorted by start and test overlap only against the predecessor and successor of the insertion point. The lookup is O(log M) per candidate. Keeping the bounds sorted uses list.insert, which shifts O(M) entries per accepted span, so the worst case is still O(M^2) element moves; those are list shifts rather than interpreted overlap checks. Acceptance decisions and output order are identical: candidates are still visited in ranked order and the overlap test keeps half-open semantics. A differential test compares against the old full-scan algorithm over 300 random seeds, including identical-geometry ties. Probe (one source, best of 3): M=8000 append-shaped 3741 ms -> 64 ms; M=8000 front-insertion worst case 4616 ms -> 71 ms. 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: Aaron Gonzales <aagonzales@nvidia.com>
21cbc72 to
9240ccd
Compare
At 32k spans the shifts add about 40% over the append case. Both stay far below the full scan. |
Summary
reconcile_candidatesnow checks each candidate for overlap against its two neighbours in a start-sorted list of accepted spans, instead of scanning every accepted span. Finding the neighbours is a binary search, O(log M) per candidate. Keeping the list sorted useslist.insert, which shifts O(M) entries per accepted span, so the worst case is still O(M^2) element moves; those are list shifts, not interpreted overlap checks, and are much cheaper in practice. Acceptance decisions and output order are unchanged.Why
Dense agent traces produce thousands of candidate spans in a single record. The old
any(_overlaps(candidate, existing) for existing in accepted)scan runs O(M^2) Python-level overlap checks: going from 2k to 8k spans made reconciliation about 16x slower.What changed
src/anonymizer/engine/detection/candidates.py: accepted bounds are kept in two parallel lists (starts and ends) ordered by start. Because accepted spans are disjoint and non-empty (enforced byAdmittedCandidate), ordering by start also orders by end. A candidate[s, e)overlaps an accepted span iff the predecessor (greatest start belows) ends afters, or the successor (smallest start at or aboves) starts beforee. That is two comparisons per candidate after the binary search._rank_candidatesand_rank_identical_geometrystill decide ties exactly as before. The neighbour check keeps the same half-open semantics as_overlaps(touching spans do not overlap);_overlapsis now used only by the test reference.Equivalence test
tests/engine/test_detection_candidates.pyadds a differential test. It keeps the old full-scan algorithm as a reference inside the test and compares it withreconcile_candidatesover 300 seeded random inputs (nested, touching, and identical-geometry spans; several source texts; mixed labels, source kinds, explicit and fallback admissions, and scores), both in generated order and shuffled. A separate test uses many identical-geometry candidates across overlapping starts so identical-bounds ranking still picks the winner. A plain test reconciles 5000 disjoint spans and checks all are kept in order.Measurements
One source text, M disjoint spans, best of 3 runs of
reconcile_candidates. "Append" accepts spans in start order, so every insertion is at the end. "Front insertion" is the worst case from review: the left half has length 1 and the right half length 2, so longest-first ranking accepts the right half first and every left-half span then inserts near the front.These are single-machine timings, not a bound. At 32k spans the worst-case shifts add about 40% over the append case.
Testing
Beyond CI: the timing probe above, which also asserts that the new and full-scan results are identical for both shapes at 2000 and 8000 spans.
🤖 Generated with Claude Code