feat(motion_estimator): injectable RNG for ransac/lmeds minimal-sample draws - #191
Conversation
…e draws get_subset draws its minimal samples from the global Math.random directly, so cv::findHomography-style reproducibility (OpenCV seeds its own RNG with a fixed value) wasn't reachable, and this codebase's own tests had to fall back to globally mocking Math.random via vi.spyOn -- a pattern that can't run in parallel and that every downstream consumer wanting determinism would have had to reinvent (webarkit/webarkit's cv-backend-jsfeatnext adapter hit this exact gap, worked around with a loosened test tolerance in webarkit/webarkit#8). - Add `RandomFn = () => number` to src/types.ts, re-exported from the package root. - `ransac_params_t` gains an `rng` field (constructor's 5th, optional parameter), defaulting to a lazy `() => Math.random()` wrapper rather than a bare `Math.random` reference -- a bare reference would freeze whatever Math.random was at construction time into the instance, permanently bypassing a `vi.spyOn(Math, "random")` mock installed afterward (this codebase's own established test pattern: construct params, then seed). - `get_subset` accepts an optional trailing `rng` parameter (default `Math.random`, unchanged behavior for direct callers); `ransac()`/`lmeds()` now pass `params.rng` through to it. - `math.mulberry32(seed)` -- a small seedable generator returning a `RandomFn`, so a caller gets OpenCV-style determinism in one line without a dependency, without touching global state, and without depending on a test framework's mocking. - Migrated tests/parity/motion_estimator.test.ts's jsfeatNext-side calls off the global Math.random mock onto the injected rng (the vendored jsfeat oracle itself still needs the global mock -- it's a frozen dependency, not code this repo owns). No behavior change for existing callers that pass nothing: get_subset's default still resolves Math.random per call, exactly as before. Closes #189.
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
PR Summary by QodoAdd injectable RNGs for deterministic RANSAC and LMEDS sampling
AI Description
Diagram
High-Level Assessment
Files changed (9)
|
Code Review by Qodo
1.
|
…te injected RandomFn Qodo's review of PR #191 caught a real bug: get_subset's duplicate-index rejection loop (the `while (!ok) { ... }` drawing indices[i]) had no bound of its own -- only the outer ssiter/max_try budget did, and that budget never advances while stuck inside this inner loop. Math.random's collision rate makes this loop terminate almost immediately in practice, so it was latent until #189 made the generator injectable: a contract-compliant but degenerate RandomFn (e.g. one that always returns the same value) now hangs get_subset -- and therefore ransac()/lmeds()/find_homography() -- forever whenever need_cnt > 1. Fixed by making a duplicate draw spend the same ssiter/max_try budget a check_subset rejection already does, so the loop can only ever retry up to max_try times total before get_subset gives up and returns false, same as every other failure path in this function. No behavior change for Math.random: a real duplicate is rare enough that this doesn't measurably affect it, and the full parity suite against the vendored jsfeat oracle still passes unmodified. Added a regression test with `() => 0` as the injected rng (guaranteed to collide on every draw) asserting the call returns false rather than hanging.
Summary
get_subsetdraws its minimal samples from the globalMath.randomdirectly. This meant:cv::findHomography-style reproducibility (OpenCV seeds its own RNG with a fixed value per call).Math.randomviavi.spyOn— works, but can't run in parallel safely and every downstream consumer wanting determinism had to reinvent it.webarkit/webarkit'scv-backend-jsfeatnextadapter hit exactly this gap: a test flaky ~1 in 45 runs, worked around with a loosened tolerance inwebarkit/webarkit#8.Per #189's acceptance criteria:
RandomFn = () => numberadded tosrc/types.ts, re-exported from the package root.ransac_params_tgains anrngfield (5th, optional constructor param), defaulting to a lazy() => Math.random()wrapper — not a bareMath.randomreference, which would freeze whatever function objectMath.randomwas at construction time into the instance, permanently bypassing avi.spyOn(Math, "random")mock installed afterward (this codebase's own established pattern: construct params, then seed — caught this exact regression against the existing parity suite during development, see Testing below).get_subsetaccepts an optional trailingrngparam (defaultMath.random, unchanged behavior for direct callers);ransac()/lmeds()now passparams.rngthrough.math.mulberry32(seed)— a small seedable generator returning aRandomFn, reachable via the existing singleton (jsfeatNext.math.mulberry32(...)), matching the issue's suggested name/location.tests/parity/motion_estimator.test.tsmigrated off the global mock for the jsfeatNext side — the vendored jsfeat oracle itself still needs the global mock, since it's a frozen third-party dependency this repo doesn't own or modify.No behavior change for existing callers that pass nothing:
get_subset's default still resolvesMath.randomfresh per call, exactly as before.Testing
npm test: 326/326 passing (up from 320 before this PR — new tests below), run twice to confirm determinism.mulberry32reproducibility/divergence/range (tests/properties/math.test.ts),ransac_params_t.rngdefault + injection (tests/properties/data-structures.test.ts), end-to-endfind_homography()reproducibility via an injected seed with no globalMath.randommock anywhere in the test (tests/properties/find_homography.test.ts).npx tsc --noEmit,prettier --check,check-license-headers.mjs: clean.npx vitest run --coverage: patch coverage clean (learned frommotion_estimator.ransac: doc comment promises a refit over all inliers; jsfeatNext has no layer that performs one #185/fix(motion_estimator): add find_homography, the missing refit layer RANSAC/LMEDS never had #190's Codecov regression — verified no new uncovered branches)./code-review(two-axis, Standards + Spec againstmotion_estimator.get_subsetuses the globalMath.random; make the RNG injectable #189): 0 hard findings on both axes. One minor Spec-axis note (a documented-non-determinism mention in README vs. JSDoc-only) reviewed and left as-is — consistent with howmotion_estimator.ransac: doc comment promises a refit over all inliers; jsfeatNext has no layer that performs one #185/feat(cv_backend): descriptor selection & capability declaration #128/feat(cv_backend): optional filterMatches step (GMS seam) #129 were documented (README explicitly delegates detailed API docs to TSDoc/TypeDoc, never duplicates per-parameter notes there).Closes #189.