fix(data): search and compare closest_index_sorted exactly - #173
Open
janickm wants to merge 1 commit into
Open
Conversation
janickm
force-pushed
the
dev/janickm/closest-index-sorted-exact
branch
from
August 31, 2026 15:05
d6f99b2 to
65dd8ae
Compare
janickm
commented
Sep 1, 2026
| # dtype keeps the search exact; out-of-range queries cannot be converted at | ||
| # all -- numpy 2 raises OverflowError for a negative one -- but they are also | ||
| # trivially answered by the first or last element. | ||
| if sorted_array.dtype.kind in "iu" and isinstance(value, (int, np.integer)) and not isinstance(value, bool): |
Collaborator
Author
There was a problem hiding this comment.
how can the input be int | np.integer if it's declared as int? do we not trust the inputs? - could we use value = np.asarray(value) or so? why should it be bool?
janickm
commented
Sep 1, 2026
| if abs(value - sorted_array[idx - 1]) < abs(sorted_array[idx] - value): | ||
| # Compare as python scalars: `.item()` yields an arbitrary-precision int for | ||
| # integer dtypes, so these differences are exact and cannot overflow. | ||
| value = value.item() if isinstance(value, np.generic) else value |
Collaborator
Author
There was a problem hiding this comment.
same question on input type - it should be a form of int
janickm
force-pushed
the
dev/janickm/closest-index-sorted-exact
branch
from
September 1, 2026 19:22
65dd8ae to
38e95ba
Compare
closest_index_sorted located the query with np.searchsorted and then compared the two neighbouring elements with numpy arithmetic. Both steps lose exactness on uint64 timestamps, with one failure mode per numpy major version -- both found by fuzzing against the intended algorithm evaluated in python ints, and both returning a silently wrong index: - numpy 1 compares a python int against an integer array by converting both to float64, which is lossy above 2**53, so searchsorted lands next to the true insertion point and the wrong neighbour wins. - numpy 2 searches exactly (NEP 50) but, for the same reason, keeps the neighbour subtraction in the array dtype. Where the old code computed `sorted_array[idx] - value` with a larger value on the right, the uint64 result wrapped to ~1.8e19 instead of going negative, so the nearer neighbour lost the comparison. numpy 1 had hidden that wrap by promoting to float64. numpy 2 also raises OverflowError for a negative query, which cannot be represented as uint64. Over 80000 fuzzed uint64 queries the old code returned the wrong index in 1889 cases under numpy 1.26.4 and 2242 under numpy 2.3.5; the new code matches the reference in all of them, on both versions, and differs from the old code only where the old code was wrong. Bound the query by the array's own range first: out-of-range queries are answered by the end elements, and what remains is representable in the array's dtype, so the search can run in that dtype and stay exact. That also guarantees both neighbours exist, which removes the two index guards the old code needed. Compare the neighbours as python scalars -- `.item()` yields an arbitrary-precision int for integer dtypes, so the differences cannot overflow or round. Float arrays are unaffected: nothing is truncated to an integer. The query is normalised with int() up front so the bounds check and the dtype conversion see the same value. Every caller today already passes a python int -- ncore's own via get_frame_timestamp_us, NRE's timestamp frame sampler via .item() -- but a numpy scalar is easy to pass by accident, and left alone it would be compared under numpy's promotion rules (wrong index in 4 of 20000 fuzzed uint64 queries under numpy 1) while a float would round differently in the two places and could push the search past the end of the array, raising IndexError. The upper bound is deliberately strict, so a query landing exactly on a repeated last element still goes through the search and resolves to the start of that run, as before. The affected callers (SequenceProtocol.get_closest_frame_index via compat.py, and the ncore_vis camera component) are otherwise unchanged for in-range microsecond timestamps, which sit below 2**53 today. Note the existing regression test passed a python list rather than a uint64 array, so it could not observe any of this; the input is now normalised with np.asarray so the dtype is well defined either way. Validation: //ncore/impl/data:all, //ncore/impl/sensors:all and //tools/... pass on both python 3.8 (numpy 1.19.5, torch 1.12.1) and 3.11. The new tests fail on the unfixed code under numpy 1.26.4 *and* 2.3.5 and pass here under numpy 1.26.4 + torch 2.7.0 and numpy 2.3.5 + torch 2.13.0. NRE's timestamp frame sampler was replayed end to end against this branch over 4000 trials, with identical indices before and after on both versions.
janickm
force-pushed
the
dev/janickm/closest-index-sorted-exact
branch
from
September 1, 2026 19:48
38e95ba to
d0647d4
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
closest_index_sortedlocated the query withnp.searchsortedand then compared the two neighbouring elements with numpy arithmetic. Both steps lose exactness on uint64 timestamps, with one failure mode per numpy major version — both found by fuzzing against the intended algorithm evaluated in python ints, and both returning a silently wrong index:searchsortedlands next to the true insertion point and the wrong neighbour wins.sorted_array[idx] - valuewith a larger value on the right, the uint64 result wrapped to ~1.8e19 instead of going negative, so the nearer neighbour lost the comparison. numpy 1 had hidden that wrap by promoting to float64. numpy 2 also raisesOverflowErrorfor a negative query.Fuzzing 80,000 uint64 queries (mixed magnitudes, ~40% with duplicate elements) against the reference:
What changed
Bound the query by the array's own range first. Out-of-range queries are answered by the end elements, and what remains is representable in the array's dtype — so the search can run in that dtype and stay exact. That also guarantees both neighbours exist, which removes the two index guards the old code needed. The neighbours are then compared as python scalars:
.item()yields an arbitrary-precision int for integer dtypes, so the differences cannot overflow or round.The net result is shorter than the original:
The query is normalised with
int()up front so the bounds check and the dtype conversion see the same value. No dtype branching, no type introspection, and float arrays are unaffected — nothing is truncated to an integer.Why the
int(value)normalisation earns its lineEvery caller today already passes a python int (ncore's own via
get_frame_timestamp_us, NRE's timestamp sampler via.item()), so this is a safety net rather than a functional requirement. It is worth having: without it, an accidentally-passed numpy scalar is compared under numpy's own promotion rules, and a float rounds differently in the bounds check than in the dtype conversion.Fuzzing 20,000 high-magnitude queries per value type:
int()int()np.uint64np.int64np.float64/ python floatThe
IndexErroris the sharp edge: the float passes the upper-bound check but converts up to the last element, so the search runs off the end. The 3 remaining float misses are unfixable — a float64 cannot hold these timestamps, and is already off by 112–220 before the call; the function answers correctly for the value it was actually given.The upper bound is deliberately strict: a query landing exactly on a repeated last element still goes through the search and resolves to the start of that run, as before. A non-strict bound silently changed that;
test_duplicate_elementspins it.Affected callers —
SequenceProtocol.get_closest_frame_indexviacompat.py:407andtools/ncore_vis/components/camera.py:1027— are otherwise unchanged for in-range microsecond timestamps, which sit below 2^53 today.Validation
main//ncore/impl/data:all,//ncore/impl/sensors:all,//tools/...bazel run format,tyDownstream
nre/datasets/samplers/timestamp.py:72is the only NRE caller of this symbol, and NRE has no test covering that sampler. So its call path was replayed directly: anint64frame-timestamp tensor (perRigTrajectory.cameras_frame_timestamps_us),rng.choice(...).item()as the query, over 4000 trials including duplicated end timestamps and magnitudes above 2^53.Identical indices before and after on both numpy versions, no overflow warnings, and every sampled timestamp still resolves to a frame carrying exactly that timestamp.
NRE bazel tests against this branch via
--test_env=PYTHONPATH(sentinel-verified the override was live)://nre/datasets/samplers:all,//libs/vren:lidars_test,//nre/models/gaussians:gsplat_lidar_test— 8 pass.Also audited the wider surface: every production caller reaches
closest_index_sortedthroughget_frame_timestamp_us, which already wraps inint()— ncore'sncore_viscomponents and tools, and NRE'snre/viewer/ncore_dataset_interface.pyandinternal/scripts/ncore_vis/loader.py. (nre/nrm/datasets/samplers.pydefines its own unrelatedget_closest_frame_index.)