Skip to content

feat(examples): add simple_nft_dual.rs with DualFreakMatcher and per-frame divergence reporting (#157)#159

Merged
kalwalt merged 2 commits into
feat/freak-visual-databasefrom
feat/m9-2-simple-nft-dual
May 23, 2026
Merged

feat(examples): add simple_nft_dual.rs with DualFreakMatcher and per-frame divergence reporting (#157)#159
kalwalt merged 2 commits into
feat/freak-visual-databasefrom
feat/m9-2-simple-nft-dual

Conversation

@kalwalt
Copy link
Copy Markdown
Member

@kalwalt kalwalt commented May 23, 2026

Closes #157.

Summary

Adds crates/core/examples/simple_nft_dual.rs — a diagnostic sibling of simple_nft.rs that drives DualFreakMatcher (M9-2, #141) to compare the C++ and pure-Rust FREAK backends end-to-end on the pinball reference image. Output includes both backend homographies, the divergence count and reason, the C++-derived 3×4 KPM pose, and the AR2 refined pose.

Also adds two tiny #[cfg(feature = "dual-mode")]-gated accessors on DualFreakMatcher (cpp_matched_geometry(), rust_matched_geometry()) so the example can read each backend's homography without an Any-downcast on the trait.

Design

Two-phase structure (see docs/design/m9-2-simple-nft-dual.md for the full decision log):

  • Phase A — DualFreakMatcher diagnostic: owns the matcher concretely, feeds the KpmRefDataSet in via an inline replica of KpmHandle::set_ref_data_set's loop, queries directly, then reads divergence_count() / last_divergence_reason() / cpp_matched_geometry() / rust_matched_geometry(). Computes max corner displacement against the matched id's reference dimensions so the number matches DualFreakMatcher's internal tier-2 metric to four decimals.
  • Phase B — Production pipeline: fresh KpmHandle + CppFreakMatcher runs set_ref_data_setkpm_matchingget_pose → AR2 tracking, mirroring simple_nft.rs exactly. Equivalent to running through DualFreakMatcher since DualFreakMatcher::query returns C++ as ground truth (M9-2 D5).

Why two phases: KpmHandle wraps the backend in Box<dyn FreakMatcherBackend>, which forbids recovering the concrete DualFreakMatcher type after the move. Splitting cleanly into "diagnostic" and "production" phases avoids any API surface changes to the trait.

Conventions

  • LGPL-3.0 header per .claude/HEADER.txt.
  • Uses arlog_*! macros from day one per CLAUDE.md §2 (arlog_i! for narrative output, arlog_e! for error sites, ar_log_init_default() at startup). Existing simple_nft.rs is intentionally left untouched — that conversion is Convert remaining println!/eprintln! in examples and benches to arlog_*! #90 PR 4's scope.
  • Cargo.toml [[example]] entry declares required-features = ["dual-mode", "log-helpers"] so cargo run --example simple_nft_dual auto-enables both. The issue's literal spec said ["dual-mode"] only; we added log-helpers so the arlog macros actually emit output.
  • No CHANGELOG.md edit (release-only).

Measurement note

On pinball-demo.jpg the example reports divergence_count = 1 after one query — tier-1 (matched_id) agrees on both backends, but tier-2 corner reprojection comes out at ~13.8 px on db_id=2 (one of 9 internal db_ids spanning the page's image-scale variants), exceeding the 2.0 px M9 #152 tolerance.

This is not a regression. It's the cross-language BHC-variance envelope docs/design/m9-2-rust-backend.md §10 already discusses — sub-degree rotation and sub-percent translation differences manifesting as ~14 px at the smaller matched scale. The milestone-gate test (test_dual_mode_no_divergence_on_pinball) covers the found.jpg/img.jpg pair where divergence stays at zero; pinball is a separate dataset where it doesn't. The C++ pose still matches §10 exactly (KPM error 7.1455, pose row 0 [0.9862, 0.1671, 0.0641, -182.1635]) and AR2 behaves identically to simple_nft.rs.

The example surfaces this honestly so contributors investigating future regressions know what "normal" looks like on each fixture. Design doc §3.7 documents the correction relative to issue #157's original "expected zero divergences" wording.

Files changed

File Change
crates/core/examples/simple_nft_dual.rs new, ~430 LOC including LGPL header + module docstring
crates/core/src/kpm/rust_backend.rs +15 lines (two accessors on DualFreakMatcher)
crates/core/Cargo.toml +8 lines (one [[example]] entry)
docs/design/m9-2-simple-nft-dual.md new design doc with decision log

Test plan

  • cargo fmt --all -- --check clean
  • cargo build --all-features succeeds (no errors; pre-existing 17 warnings unchanged)
  • cargo clippy --all-targets --all-features -- --deny warnings exit 0
  • cargo test --all-features --lib kpm::rust_backend — 5 passed, including test_dual_mode_no_divergence_on_pinball
  • cargo run --features "dual-mode log-helpers" --example simple_nft_dual runs to completion on pinball assets; produces internally-consistent output (example's reported corner displacement matches DualFreakMatcher's internal warning exactly: 13.8016 px)

Refs

🤖 Generated with Claude Code

kalwalt and others added 2 commits May 23, 2026 18:29
…frame divergence reporting (#157)

Closes #157.

Diagnostic sibling of simple_nft.rs that drives DualFreakMatcher to
compare the C++ and pure-Rust FREAK backends end-to-end on the pinball
reference image. The example prints both backend homographies, the
divergence count and reason, the C++-derived 3x4 KPM pose, and the AR2
refined pose.

Two-phase structure: Phase A queries the DualFreakMatcher directly to
capture per-backend state (KpmHandle wraps the matcher in
Box<dyn FreakMatcherBackend>, which forbids recovering the concrete
type post-move); Phase B uses a fresh KpmHandle + CppFreakMatcher for
the production pose/AR2 pipeline, which is equivalent since
DualFreakMatcher::query returns C++ as ground truth (M9-2 D5).

Adds two ~3-line accessors on DualFreakMatcher (cpp_matched_geometry
and rust_matched_geometry, both #[cfg(feature = "dual-mode")]-gated) so
the example can read each backend's homography. New file uses arlog_*!
macros from day one per CLAUDE.md §2; existing simple_nft.rs is left
alone (issue #90 PR 4's scope). Cargo.toml entry declares
required-features = ["dual-mode", "log-helpers"] so cargo auto-enables
both when running the example.

Measurement note: on pinball-demo.jpg both backends agree on matched_id
but the tier-2 corner reprojection diverges (~13.8 px > 2.0 px
tolerance), producing divergence_count = 1. This is the cross-language
BHC-variance envelope §10 of docs/design/m9-2-rust-backend.md
discusses, not a regression — the C++ pose still matches §10 (KPM
error 7.1455, pose row 0 [0.9862, 0.1671, 0.0641, -182.1635]) and AR2
behaves identically to simple_nft.rs.

Refs #141 (M9-2), #156 (M9-2 PR landing matched_geometry accessors).
See docs/design/m9-2-simple-nft-dual.md for the full decision log.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…mparison (#157)

Refs #157. Followup on PR #159.

The "max corner displacement" line and module docstring in
simple_nft_dual.rs could be misread as a pose-level comparison.
Re-labels the metric as "Homography agreement (M9 #152 tier-2
metric): max corner displacement between H_cpp and H_rust", and
adds an explicit note in both the module docstring and Phase A
output that:

- Side-by-side comparison is at the 3×3 homography level (what
  matched_geometry() exposes per backend).
- Only one 3×4 camera pose is computed (C++-derived, fed to AR2).
- The Rust 3×4 pose is intentionally not printed — would require
  running kpm_util_get_pose_binary separately on Rust inliers.

No behavioural change. Matches PR #159 design-doc D2.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@kalwalt kalwalt merged commit 4d73f32 into feat/freak-visual-database May 23, 2026
16 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant