feat(kpm): port HoughSimilarityVoting::autoAdjustXYNumBins (#150)#151
Merged
kalwalt merged 1 commit intoMay 20, 2026
Merged
Conversation
Implements issue #150 — port the C++ auto-adjusting x/y bin grid for Hough similarity voting (visual_database.h:312 + hough_similarity_voting.cpp:204-236). Adds the missing `fast_median_f32` + `partial_sort_f32` primitives and wires auto-adjust into find_hough_similarity so make_hough_voter no longer needs the hand-tuned 12x12 bin grid that M9-1 used as a placeholder. Pre-brainstorm finding: the C++ HoughSimilarityVoting::autoAdjustXYNumBins method is `private` (verified in hough_similarity_voting.h:302). No public getter exposes the resulting `mNumXBins` / `mNumYBins` either. The dual-mode FFI shim sidesteps the access issue by reimplementing the formula using public primitives `vision::SafeDivision` + `vision::FastMedian`, testing the same arithmetic without needing private state access. Changes ------- * math.rs (+196): new pub fn `fast_median_f32(values: &mut [f32]) -> f32` + private `partial_sort_f32` helper. Direct port of C++ `FastMedian<T>` (single-value overload). Preserves the C++ "biased estimator" quirk: returns the (n/2 - 1)-th smallest element (0-indexed), NOT the true median. For [1,2,3,4,5] returns 2.0, not 3.0. Documented thoroughly. +6 unit tests covering odd/even/single/n=100/two-element/pivot-position. * hough.rs (+437 net): BinParams API expansion — `num_x_bins` and `num_y_bins` become private (grep-verified no external readers/writers). New pub `num_x_bins()` / `num_y_bins()` getters. New pub `new_auto_xy(...)` factory (initializes both to clamp floor 5, sets `auto_adjust_xy: bool = true`). New pub(crate) `set_xy_bins(x, y)` atomic mutator that recomputes `a` / `b` strides. New private `auto_adjust_xy` field on BinParams. New private `HoughSimilarityVoting::recompute_xy_bins_from_matches` mirrors C++ autoAdjustXYNumBins via fast_median_f32 + safe_division_f32. `find_hough_similarity` invokes it when the flag is set, before the vote loop. +3 unit tests (initial state, atomic stride update, known-input/clamp/empty cases) + 1 dual-mode test. * visual_database.rs (+34 net): removed `HOUGH_NUM_X_BINS` / `HOUGH_NUM_Y_BINS` constants (M9-1 vestigial; C++ has no equivalent — it passes 0 to trigger auto-adjust). `make_hough_voter` switches to `BinParams::new_auto_xy`. The parity test `test_visual_database_matches_cpp_pipeline` updated with the new diagnosis (see R2 below). * kpm_c_api.h + kpm_c_api.cpp (+96): two new FFI shims — `webarkit_cpp_partial_sort_f32` (D10 lower-level diagnostic) and `webarkit_cpp_auto_adjust_xy_num_bins` (D4 auto-adjust isolation). The auto-adjust shim reimplements the formula directly using public primitives because the C++ method is private. * docs/design/m9-hough-auto-adjust-xy-bins.md (NEW, 370 lines): full brainstorming outcome — 10 decisions, 4 assumptions, 3 risks with post-implementation status, complete algorithm reference, post-PR parity diagnosis. Two layered dual-mode tests — both passing byte-equivalently ----------------------------------------------------------- * `math::dual_mode_tests::dual_mode_partial_sort_f32_matches_cpp` — 50 seeded random trials, including injected duplicates to stress the tie-break. Confirms `partial_sort_f32` produces byte-identical k-th order statistic to `vision::PartialSort<float>`. * `hough::dual_mode_tests::auto_adjust_xy_num_bins_matches_cpp` — 40 seeded random trials with varied (size, ref dims, x/y ranges). Confirms `recompute_xy_bins_from_matches` produces byte-identical `(num_x_bins, num_y_bins)` to C++ `autoAdjustXYNumBins`. Risks materialized / did not materialize ---------------------------------------- * R1 (DID NOT materialize). `partial_sort_f32` is byte-equivalent to C++ first try; the Lomuto partition port worked correctly. The R1 two-layer detection added value as proof rather than as a fallback trigger. * R2 (MATERIALIZED differently than predicted). The `test_visual_database_matches_cpp_pipeline` end-to-end parity gate STILL shows `diff=15` inliers — identical to the M9-1 and M9 #146 baselines. The auto-adjust port is correct (proven byte-equivalent at the algorithm level), but the residual gap is upstream: BHC produces different match sets in Rust vs C++ (the unresolved cross-language tree-topology nondeterminism from M9 #146 R1, caused by unordered_map iteration in both languages). Auto-adjust runs on different inputs and consequently produces different bin counts even though the formula is identical. Resolution: re-#[ignore] the parity test with a thorough docstring naming the now-confirmed root cause (BHC tree-topology nondeterminism). The originally-planned `skip-parity-gate` cargo feature was added then removed — the unconditional #[ignore] made the cfg_attr soft-skip redundant. * R3 (DID NOT materialize). C++ `autoAdjustXYNumBins` is indeed private, but the shim sidesteps cleanly by reimplementing the formula with public primitives. No `friend` declaration; no third-party patches. Verification (CLAUDE.md §5) --------------------------- cargo fmt --all -- --check clean cargo build --all-features --offline clean cargo clippy --all-targets --all-features 0 new warnings in modified files cargo test --lib --offline 407 passed, 2 ignored cargo test --features dual-mode --lib --offline 431 passed, 4 ignored What we ruled out — diagnostic value ------------------------------------ By isolating auto-adjust and proving it byte-equivalent to C++ at the algorithm level, this PR rules it out as the cause of the residual gap. The remaining divergence is now narrowed to the BHC tree-topology nondeterminism that has persisted since M9-1. The path forward to closing the parity gate requires either (a) patching the C++ source to use std::map instead of std::unordered_map for child iteration, (b) vendoring a fork with that change, or (c) redefining the parity metric to something less sensitive to tree-topology variance (e.g. pose accuracy or inlier ratio). To be addressed in a separate architectural issue. Refs: #139 (M9 parent), #140 (M9-1 baseline), #146 / #149 (M9 BHC architecture, R1 origin). Closes #150 — auto-adjust algorithmic port is complete and verified byte-equivalent to C++. The dual-mode parity gate test_visual_database_matches_cpp_pipeline remains #[ignore]d for a structural reason that's out of scope for #150. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
12 tasks
kalwalt
added a commit
that referenced
this pull request
May 21, 2026
Implements issue #152 — close the M9 dual-mode parity gate test_visual_database_matches_cpp_pipeline by replacing the absolute- inlier-count assertion with a corner-reprojection-error metric that's intrinsically invariant to BHC tree-topology cross-language nondeterminism (M9 #146 R1). Pre-brainstorm finding (closes #152 R3 by inspection rather than implementation): the C++ `kpm_query` `pose_out[12]` parameter is actually the 3x3 row-major homography in `pose_out[0..9]` with three trailing zeros for FFI convenience — same object Rust's `matched_geometry()` returns, not a 3x4 pose. See `kpm_c_api.cpp:156-166`. This eliminates the need for any new FFI shim; the existing kpm_query already exposes everything we need. The diagnostic trail (now complete) ----------------------------------- The original M9-1 parity assertion `|rust - cpp| <= 5 inliers` failed at `rust=441 cpp=456 (diff=15)` and stayed there across three PRs: * #145 (M9-1): introduced the gate, observed 15-inlier divergence * #149 (#146): BHC architecture (build-once + max_nodes_to_pop) — diff unchanged * #151 (#150): autoAdjustXYNumBins port — diff unchanged Both #149 and #151 shipped dedicated dual-mode FFI tests proving the Rust algorithmic ports byte-equivalent to C++ at the unit level (BHC partition + auto-adjust both pass byte-equivalence across 90 combined seeded random trials). The pipeline math is correct. The residual gap is BHC tree-topology cross-language nondeterminism: both Rust (`BTreeMap`/`HashMap`) and C++ (`std::unordered_map`) use unordered-key maps when grouping K-medoids assignments into child clusters during BHC build (binary_hierarchical_clustering.h:217). Hash orderings differ across toolchains — BHC trees differ — matches differ — downstream metrics differ by a stable ~15 inliers. The BHC algorithm tolerates this (priority-queue traversal handles ties), but byte-equivalent cross-language tree-build determinism isn't achievable without patching the WebARKit C++ source. Rather than chase upstream changes, this PR redefines the metric to one that's intrinsically invariant to the variance. Changes ------- * visual_database.rs (+116/-61): added a private `reproject_corners` helper inside the test module (YAGNI-correct — only caller is the parity test; promote later if M9-2 needs it). Rewrote test_visual_database_matches_cpp_pipeline to: - Extract Rust H via db.matched_geometry(). - Extract C++ H via the existing kpm_query's pose_out[0..9]. - Project the 4 reference corners through both homographies. - Compute per-corner Euclidean displacement; assert max <= 2.0 px. - arlog_i! the per-corner values for future tightening visibility. Removed the #[ignore] annotation — the test now runs by default. * docs/design/m9-parity-metric.md (NEW, ~330 lines): full brainstorming output (Understanding Summary, diagnostic trail, C++ pose_out layout finding, 10 decisions with alternatives + rationale, 4 assumptions, 3 risks with post-implementation status, files modified estimate, verification workflow, exit criteria, §10.5 measured outcome with the actual numbers from the first run, §10.6 milestone implications). Measured outcome (now baked into the design doc) ------------------------------------------------ First run on the pinball pair: max_displacement = 0.237754 px per corner: tl=0.109074, tr=0.237754, br=0.068354, bl=0.055763 Sub-pixel parity. Even with the 15-inlier divergence in matches, RANSAC converges to essentially the same homography because the matches are drawn from the same underlying images. Tolerance set per M9 #146 Decision 10 (max(2.0, ceil(observed))): const TOLERANCE_PX: f32 = 2.0; This is 8.4× the observed value — substantial safety margin against float-rounding drift in upstream M6-M8 components, hardware/toolchain rounding variation, and small RANSAC-seed-induced drift from future work. Risks materialized / did not materialize ---------------------------------------- * R1 (observed > 5 px ceiling) — did not materialize. Observed 0.24 px. * R2 (tolerance brittle) — mitigated by 8.4× margin from the 2.0 px floor. * R3 (C++ homography layout surprise) — did not materialize. A1 verified by source inspection (`kpm_c_api.cpp:156-166`); the pose_out layout is exactly as documented. Verification (CLAUDE.md §5) --------------------------- cargo fmt --all -- --check clean cargo build --all-features clean cargo clippy --all-targets --all-features 0 new warnings in modified files cargo test --lib --offline 407 passed, 2 ignored cargo test --features dual-mode --lib --offline 432 passed, 3 ignored (+1 active test = the un-#[ignore]'d parity gate) What this means for the M9 milestone ------------------------------------ The M9 dual-mode parity gate is closed. The test runs by default and asserts sub-pixel agreement between Rust and C++ homographies on the pinball pair. The heads-up posted to #141 (M9-2) recommends adopting the same corner-reprojection metric there instead of the current "zero divergence" framing. With this PR landed, M9-2 has a clear runway: land RustFreakMatcher + DualFreakMatcher, write its milestone gate using this metric pattern, then M9-3 flips the default off ffi-backend and Milestone 9 closes. Refs: #139 (M9 parent), #140/#145 (M9-1 baseline), #146/#149 (BHC R1 origin), #150/#151 (auto-adjust diagnostic). Closes #152 — corner reprojection metric defined, implemented, and verified passing on the pinball pair with sub-pixel agreement. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
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
Implements #150 — ports C++
HoughSimilarityVoting::autoAdjustXYNumBins(hough_similarity_voting.cpp:204-236) so the RustHoughSimilarityVotingauto-sizes its x/y bin grid from the median projected dimension of input matches per voting pass. Adds the missingfast_median_f32+partial_sort_f32primitives that the auto-adjust formula needs.make_hough_voterswitches toBinParams::new_auto_xy, removing the M9-1 vestigial 12×12 bin constants.HoughSimilarityVoting::autoAdjustXYNumBinsportedfast_median_f32/partial_sort_f32portedBinParamsAPI: auto-adjust factorynew_auto_xyHOUGH_NUM_X_BINS = 12(Rust-only workaround)diffDetailed Description
Pre-brainstorm finding
C++
HoughSimilarityVoting::autoAdjustXYNumBinsisprivate(hough_similarity_voting.h:302) andmNumXBins/mNumYBinshave no public getters. The dual-mode FFI shim sidesteps the access issue by reimplementing the formula using public primitivesvision::SafeDivision+vision::FastMedian, testing the same arithmetic without needing private state access. Nofrienddeclaration; no third-party patches.Two-layer dual-mode test diagnostics (both passing byte-equivalently)
math::dual_mode_tests::dual_mode_partial_sort_f32_matches_cpphough::dual_mode_tests::auto_adjust_xy_num_bins_matches_cppThe lower-level
partial_sort_f32test localizes any future algorithm-level regression; the auto-adjust test verifies the surrounding-math composition.What changed, by file
math.rs(+196 LOC) —pub fn fast_median_f32(values: &mut [f32]) -> f32and privatepartial_sort_f32helper. Direct port of C++FastMedian<T>(single-value overload). Preserves the C++ "biased estimator" quirk: returns the(n/2 - 1)-th smallest element (0-indexed), NOT the true mathematical median. For[1,2,3,4,5]returns2.0, not3.0. Documented thoroughly. +6 unit tests.hough.rs(+437 LOC net) —BinParamsAPI expansion:num_x_binsandnum_y_binsbecome private (grep-verified safe: no external readers/writers).pub fn num_x_bins() -> i32/pub fn num_y_bins() -> i32getters.pub fn new_auto_xy(...)factory (initializes both bins to the clamp floor5, setsauto_adjust_xy: bool = true).pub(crate) fn set_xy_bins(x, y)atomic mutator that recomputesa/bstrides.auto_adjust_xy: boolfield.HoughSimilarityVoting::recompute_xy_bins_from_matchesmirrors C++autoAdjustXYNumBinsviafast_median_f32+safe_division_f32.find_hough_similarityinvokes it when the flag is set, before the vote loop.visual_database.rs(+34 LOC net) —HOUGH_NUM_X_BINS/HOUGH_NUM_Y_BINSconstants removed (M9-1 vestigial; C++ has no equivalent — it passes0to trigger auto-adjust).make_hough_voterswitches toBinParams::new_auto_xy. The parity testtest_visual_database_matches_cpp_pipelinegets an updated docstring naming the now-confirmed root cause of the residual gap.kpm_c_api.h+kpm_c_api.cpp(+96 LOC) — two new diagnostic FFI shims.webarkit_cpp_partial_sort_f32wrapsvision::PartialSort<float>directly.webarkit_cpp_auto_adjust_xy_num_binsreimplements the C++ formula using public primitives because the original C++ method is private.docs/design/m9-hough-auto-adjust-xy-bins.md(NEW, 370 lines) — full brainstorming outcome: 10 decisions, 4 assumptions, 3 risks with post-implementation status, complete algorithm reference, post-PR parity diagnosis.Review Checklist
Algorithm parity
fast_median_f32returns the(n/2 - 1)-th smallest element (NOT the true median). Test values intentionally encode this C++ quirk.partial_sort_f32uses Wirth's k-smallest with 1-indexedk(matches C++PartialSort<T>).recompute_xy_bins_from_matchesis invoked once perfind_hough_similaritycall (matches C++ batchedvote(ins, ref, size)semantics), not per individualvote(x, y, angle, scale).BinParams::new_auto_xyinitializesnum_x_bins = num_y_bins = 5(the clamp floor), keeping the struct in a valid state pre-recompute.matches.is_empty()orbin_size <= 0.0(defensive).API hygiene
num_x_bins/num_y_binsprivate with getters — locks down the stride invariant.set_xy_binsispub(crate)— only callable from the recompute method.params.num_x_bins/num_y_binsstill compile (verified — same-module access is unrestricted).Tests
math.rs(mod tests).math.rs(mod dual_mode_tests).hough.rs(mod tests).hough.rs(mod dual_mode_tests).Documentation
docs/design/m9-hough-auto-adjust-xy-bins.mdexists with full decision log + post-implementation risk status.C equivalent:doc line.Risk Assessment (post-implementation)
partial_sort_f32tie-break may diverge from C++diff=15unchanged from M9-1 / M9 #146 baselines. Auto-adjust port is byte-equivalent to C++ (40/40 trials), but runs on different inputs because BHC produces different matches (M9 #146 R1 — cross-languageunordered_maptree-topology nondeterminism).autoAdjustXYNumBinsmay be privateTest Coverage
math::tests(kpm::freak)math::dual_mode_testsdual_mode_partial_sort_f32_matches_cpp)hough::testshough::dual_mode_testsauto_adjust_xy_num_bins_matches_cpp)Visual Aid: pipeline before/after
Size & Splitting Notes
Total: 5 modified files + 1 new design doc, ~1000 lines net change. Tight scope around #150's three pieces (median primitives, BinParams API, auto-adjust algorithm + invocation). Splitting further wouldn't help:
fast_median_f32from auto-adjust would land a primitive with no callers.Review Automation Findings
cargo fmt --all -- --checkcargo build --all-featurescargo clippy --all-targets --all-features -- --deny warningscargo test --libcargo test --features dual-mode --libcargo test --features dual-mode -- dual_mode_partial_sort_f32_matches_cppcargo test --features dual-mode -- auto_adjust_xy_num_bins_matches_cppcargo test --features dual-mode -- test_visual_database_matches_cpp_pipeline#[ignore]d atdiff=15(root cause: BHC tree-topology nondeterminism, M9 #146 R1)What this PR ruled out — diagnostic value
By isolating auto-adjust and proving it byte-equivalent to C++ at the algorithm level (40/40 trials with seeded random inputs), this PR rules out auto-adjust as the cause of the residual
diff=15gap. The remaining divergence is now narrowed to the BHC tree-topology nondeterminism that has persisted since M9-1.The path forward to closing the parity gate requires one of:
std::mapinstead ofstd::unordered_mapfor child iteration during BHC build (third_party submodule change).These will be addressed in a separate architectural follow-up issue.
Refs: #139 (M9 parent), #140 / #145 (M9-1), #146 / #149 (M9 BHC architecture).
Closes #150 — auto-adjust algorithmic port is complete and verified byte-equivalent to C++. The end-to-end dual-mode parity gate
test_visual_database_matches_cpp_pipelineremains#[ignore]d for a structural reason that's out of scope for #150.🤖 Generated with Claude Code