M7 follow-up: Port ArrayShuffle for BHC C++ parity (#116)#117
Conversation
Replaces the LCG-based shuffle in BinaryHierarchicalClustering with an exact port of vision::FastRandom and vision::ArrayShuffle from math/rand.h. With this change, the Rust BHC index produces a tree topology byte-identical to the C++ baseline given the same seed. This unlocks pair-equality dual-mode validation for match_indexed, which previously could only assert non-negative match counts because of RNG divergence (documented as a follow-up in PR #114 / PR #115). Changes: clustering.rs: - Add fast_random(seed) — 15-bit LCG matching vision::FastRandom exactly. Uses i32 with wrapping arithmetic to match C++ int. - Add array_shuffle(v, sample_size, seed) — matches vision::ArrayShuffle. Note: NOT Fisher-Yates; k is drawn from [0, pop_size) per C++. - Replace SimpleRng (LCG) usage in KMedoids::assign with array_shuffle. - Initialize rand_indices ONCE before the hypothesis loop (matches C++ kmedoids.h:163 SequentialVector + per-hypothesis ArrayShuffle). - Change KMedoids::rand_seed type from u64 to i32 to match C++ int and enable byte-identical PRNG sequence. - Add KMedoids::rand_seed() getter for state-evolution tests. - Fix BHC query: query_recursive now visits ALL children with the minimum Hamming distance, matching C++ Node::nearest behavior in binary_hierarchical_clustering.h ("Any nodes that are the SAME distance as the minimum node are added to the output vector"). kpm_c_api.cpp/.h: - Add webarkit_cpp_fast_random — wraps vision::FastRandom - Add webarkit_cpp_array_shuffle — wraps vision::ArrayShuffle - Include math/rand.h matcher.rs: - Strengthen dual_mode_match_indexed_within_tolerance: now asserts count agreement (±2) AND sorted pair equality with C++, matching the brute-force and guided variants. Tests added (clustering.rs): - 8 unit tests for fast_random and array_shuffle (frozen expected values + property-based checks) - 3 dual-mode tests (gated on `dual-mode` feature) verifying byte-identical PRNG output and persistent seed evolution vs C++ All tests pass: - cargo test -p webarkitlib-rs --lib -> 274 passed - cargo test --features dual-mode -p webarkitlib-rs -> 292 passed - cargo clippy --workspace -- -D warnings -> clean - cargo fmt --all -- --check -> clean Note: dual-mode tests are local-only (CI does not run --features dual-mode currently). Adding a dual-mode CI job is a follow-up. Fixes #116
Adds a test step to the existing kpm-build job that runs: cargo test -p webarkitlib-rs --features dual-mode This step runs after the FFI backend build (the dependency C++ shims are already compiled by that point) and exercises the pure-Rust ports against the C++ baseline via the webarkit_cpp_* FFI bridges: - homography exp / robust homography (M6-3, #65) - math utils: fast_atan2, fast_sqrt1, fast_exp6 (M6-1, #63) - linear solvers (M6-2, #64) - feature matcher: brute, indexed, guided (M7-3, #111) - PRNG: fast_random, array_shuffle (M7, #116) Without this CI step, dual-mode regressions could slip in unnoticed because the pure-Rust test job (build-and-test) does not enable the dual-mode feature. The kpm-build job already runs on all three platforms (ubuntu, macOS, windows) so we get cross-platform parity coverage at no extra setup. Refs #116
…test failures The previous step ran all tests in the package including integration tests under tests/, which have pre-existing cross-platform failures (test_full_pipeline_pose on Windows, linker errors on macOS) that are unrelated to dual-mode validation. The dual-mode tests live inside the library (crates/core/src/...) under #[cfg(feature = "dual-mode")] modules, so --lib is the correct scope for this CI step. Refs #116
…stdc++ on Linux)
The build script unconditionally linked stdc++ on all non-MSVC targets,
which fails on macOS where the system C++ stdlib is libc++. The error
was hidden until now because the existing kpm-build CI step only ran
'cargo build' (compiles rlib without linking a binary). The new
dual-mode test step links a test binary and exposed the bug:
ld: library 'stdc++' not found
Fix: select the C++ stdlib based on target triple:
- *-apple-* targets link libc++
- Other non-MSVC targets link libstdc++ (Linux convention)
- MSVC handles its own C++ runtime
Refs #116
…tests
The fixed 1e-5 absolute tolerance in solve_linear_system_2x2_matches_cpp
and solve_symmetric_linear_system_3x3_matches_cpp is too tight for f32
values with magnitude > ~10. Apple Silicon ARM64 uses FMA differently
from x86_64, producing tiny precision differences that exceed 1e-5 for
larger results.
Example failure on macOS ARM64:
rust=-40.826614, cpp=-40.8266, diff=1.5e-5
The diff of 1.5e-5 represents a relative error of ~3.7e-7, which is
well within f32 precision but exceeds the absolute tolerance.
Fix: use combined tolerance:
tol = max(1e-5, |value| * 1e-6)
This gives:
- |x| <= 10: tolerance unchanged at 1e-5 (preserves existing strictness)
- |x| = 100: tolerance 1e-4 (~1e-6 relative)
- |x| = 1000: tolerance 1e-3 (~1e-6 relative)
These bounds are consistent with f32's ~7-digit precision and
accommodate platform-specific FMA rounding without losing test
sensitivity for typical values.
Surfaced now because PR #117's new dual-mode CI step exposed this
pre-existing issue on macOS runners. The math itself is correct;
only the test tolerance was too strict.
Refs #116
The previous 1e-6 relative scaling was still too tight for near-singular
2x2 systems on Apple Silicon ARM64. A failing case showed:
rust=-358.62683, cpp=-358.62802, diff=1.19e-3, tol=3.59e-4
The relative error of ~3.3e-6 exceeded the 1e-6 scale factor.
Random 2x2 systems occasionally hit ill-conditioned configurations where
solutions have large magnitude and are very sensitive to FMA rounding
order. f32 has ~7 decimal digits of precision, so even well-conditioned
results can diverge by ~1e-5 relative across platforms; ill-conditioned
ones diverge more.
Bump relative scale from 1e-5 (per |x|) to ensure cross-platform
stability without losing test sensitivity for typical values:
tol = max(1e-5, |x| * 1e-5)
This is consistent with f32 precision floor and accommodates the worst
observed cross-platform divergence with a safety margin.
Refs #116
Final state — all 16 CI checks green ✅Beyond the planned ArrayShuffle port, this PR's new dual-mode CI gate exposed three dormant pre-existing bugs that were lying in wait. Documenting them here for the record: 1.
|
5bb2895
into
feat/milestone7-hough-voting-feature-match
…tests
The fixed 1e-5 absolute tolerance in solve_linear_system_2x2_matches_cpp
and solve_symmetric_linear_system_3x3_matches_cpp is too tight for f32
values with magnitude > ~10. Apple Silicon ARM64 uses FMA differently
from x86_64, producing tiny precision differences that exceed 1e-5 for
larger results.
Example failure on macOS ARM64:
rust=-40.826614, cpp=-40.8266, diff=1.5e-5
The diff of 1.5e-5 represents a relative error of ~3.7e-7, which is
well within f32 precision but exceeds the absolute tolerance.
Fix: use combined tolerance:
tol = max(1e-5, |value| * 1e-6)
This gives:
- |x| <= 10: tolerance unchanged at 1e-5 (preserves existing strictness)
- |x| = 100: tolerance 1e-4 (~1e-6 relative)
- |x| = 1000: tolerance 1e-3 (~1e-6 relative)
These bounds are consistent with f32's ~7-digit precision and
accommodate platform-specific FMA rounding without losing test
sensitivity for typical values.
Surfaced now because PR #117's new dual-mode CI step exposed this
pre-existing issue on macOS runners. The math itself is correct;
only the test tolerance was too strict.
Refs #116
Summary
Closes the BHC RNG divergence noted in PR #114 / PR #115 by porting
vision::FastRandomandvision::ArrayShufflefrommath/rand.hto Rust with byte-identical semantics. With this change:match_indexeddual-mode test now asserts sorted pair equality with C++ (matching the brute-force and guided variants).Changes
Algorithm port (commit 6146ab8)
clustering.rs:fast_random(seed: &mut i32) -> i32— exact port ofvision::FastRandom. Usesi32+ wrapping arithmetic so the bit pattern matches C++int.array_shuffle<T>(v, sample_size, seed)— exact port ofvision::ArrayShuffle. Note: NOT Fisher–Yates;kis drawn from[0, pop_size)per C++.KMedoids::assign()reworked to:rand_indicesonce before the hypothesis loop (matches C++kmedoids.h:163)array_shufflewith persistentrand_seed(mutated across calls and recursive BHC builds)rand_seedtype changed fromu64toi32to match C++intquery_recursivenow visits all children with the minimum Hamming distance, matching C++Node::nearest(""Any nodes that are the SAME distance as the minimum node are added"").kpm_c_api.cpp/.h: Addwebarkit_cpp_fast_randomandwebarkit_cpp_array_shuffleFFI shims.matcher.rs: Strengthendual_mode_match_indexed_within_tolerancefrom count-only to count + sorted pair equality.CI integration (commit f15f30a)
.github/workflows/ci.yml: Addcargo test -p webarkitlib-rs --features dual-modestep to the existingkpm-buildjob (runs on ubuntu, macOS, windows).Tests
New unit tests (always run in CI)
test_fast_random_first_call— frozen expected values (seed 1234 → 4068)test_fast_random_deterministictest_fast_random_rangetest_fast_random_negative_seedtest_array_shuffle_preserves_elementstest_array_shuffle_deterministictest_array_shuffle_empty_poptest_array_shuffle_zero_sampletest_array_shuffle_seed_evolvesNew dual-mode tests (now run in CI via the new job)
dual_mode_fast_random_byte_identical— 1000 calls × 9 starting seeds, value AND seed state match C++ at every stepdual_mode_array_shuffle_byte_identical— 8 (pop, sample, seed) cases, permutation AND seed match C++dual_mode_array_shuffle_persistent_seed— 10 sequential shuffles, state evolution matches C++ at every roundStrengthened existing test
dual_mode_match_indexed_within_tolerance— was count-only; now asserts sorted pair equality with C++Verification
cargo test -p webarkitlib-rs --lib→ 274 passedcargo test --features dual-mode -p webarkitlib-rs --lib→ 292 passedcargo clippy --workspace -- -D warnings→ cleancargo fmt --all -- --check→ cleanWhy this matters
Before this PR:
match_indexeddual-mode could only assert non-negative match counts because BHC RNG diverged. A bug in the indexed matcher would have been invisible to the dual-mode validation gate.After this PR: the indexed matcher is held to the same correctness standard as brute-force and guided — sorted pair equality with the C++ baseline. The CI dual-mode job ensures regressions are caught automatically, not just when someone happens to run
--features dual-modelocally.Related
Base Branch
Target:
feat/milestone7-hough-voting-feature-match(M7 milestone branch)