diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml
index 81e3154..5ce4c7b 100644
--- a/crates/core/Cargo.toml
+++ b/crates/core/Cargo.toml
@@ -89,6 +89,14 @@ name = "simple_nft"
# backend builds on the default feature set, so no `required-features` is
# needed anymore.
+[[example]]
+name = "simple_nft_dual"
+# M9-2 #157: diagnostic sibling of simple_nft, drives DualFreakMatcher to
+# compare C++ vs pure-Rust FREAK backends on the same query.
+# - dual-mode -> DualFreakMatcher (transitively pulls ffi-backend)
+# - log-helpers -> ar_log_init_default() so arlog_*! macros emit output
+required-features = ["dual-mode", "log-helpers"]
+
[[example]]
name = "load_nft"
required-features = ["log-helpers"]
diff --git a/crates/core/examples/simple_nft_dual.rs b/crates/core/examples/simple_nft_dual.rs
new file mode 100644
index 0000000..d89ec13
--- /dev/null
+++ b/crates/core/examples/simple_nft_dual.rs
@@ -0,0 +1,546 @@
+/*
+ * simple_nft_dual.rs
+ * WebARKitLib-rs
+ *
+ * This file is part of WebARKitLib-rs - WebARKit.
+ *
+ * WebARKitLib-rs is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * WebARKitLib-rs is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with WebARKitLib-rs. If not, see .
+ *
+ * As a special exception, the copyright holders of this library give you
+ * permission to link this library with independent modules to produce an
+ * executable, regardless of the license terms of these independent modules, and to
+ * copy and distribute the resulting executable under terms of your choice,
+ * provided that you also meet, for each linked independent module, the terms and
+ * conditions of the license of that module. An independent module is a module
+ * which is neither derived from nor based on this library. If you modify this
+ * library, you may extend this exception to your version of the library, but you
+ * are not obligated to do so. If you do not wish to do so, delete this exception
+ * statement from your version.
+ *
+ * Copyright 2026 WebARKit.
+ *
+ * Author(s): Walter Perdan @kalwalt https://github.com/kalwalt
+ *
+ */
+
+//! Diagnostic NFT example exercising [`DualFreakMatcher`].
+//!
+//! This is the **diagnostic** sibling of [`simple_nft.rs`](./simple_nft.rs);
+//! `simple_nft.rs` is the **production** example. Both run the same KPM
+//! detection → AR2 tracking flow on the pinball reference image, but this
+//! one drives [`DualFreakMatcher`] (M9-2, #141) so the C++ and pure-Rust
+//! FREAK backends can be compared on the same query.
+//!
+//! **Side-by-side comparison granularity**: the per-backend comparison is
+//! at the **homography (3×3 H)** level — that is what [`matched_geometry`]
+//! exposes on each backend. Only **one** 3×4 camera pose is computed (from
+//! the C++ ground-truth homography via [`KpmHandle::get_pose`]) and fed to
+//! AR2. Per-backend 3×4 poses are intentionally not computed here; if you
+//! need them, run `kpm_util_get_pose_binary` with each backend's inliers
+//! manually.
+//!
+//! [`matched_geometry`]: webarkitlib_rs::kpm::DualFreakMatcher::cpp_matched_geometry
+//! [`KpmHandle::get_pose`]: webarkitlib_rs::kpm::KpmHandle::get_pose
+//!
+//! What you should see (on the bundled pinball assets, modulo float noise
+//! across machines):
+//!
+//! - Both backends agree on the matched id (no tier-1 / matched_id divergence).
+//! - Two homographies printed side-by-side. Their corner reprojections
+//! on pinball typically diverge by single-digit-to-low-double-digit
+//! pixels — this is the cross-language BHC-variance envelope
+//! `docs/design/m9-2-rust-backend.md` §10 discusses, and it is
+//! **larger** than the M9 #152 tier-2 tolerance (2.0 px), so a
+//! non-zero `divergence_count` is the normal observation on this
+//! dataset. The milestone-gate test
+//! (`test_dual_mode_no_divergence_on_pinball`) covers the `found.jpg`/
+//! `img.jpg` pair, where divergence stays at zero.
+//! - One 3×4 KPM camera pose (C++-derived) matching §10 — KPM error
+//! ≈ 7.1455 and pose row 0 ≈ `[0.9862, 0.1671, 0.0641, -182.1635]`.
+//! The Rust-derived 3×4 pose is **not** printed (it would require
+//! running `kpm_util_get_pose_binary` on the Rust inliers separately).
+//!
+//! Read non-zero divergences here as informational, not as a regression
+//! — the C++ pose still feeds AR2 cleanly (M9-2 D5).
+//!
+//! After M9-3 flips the default backend off `ffi-backend` (#142), this
+//! example becomes a niche debugging tool — most useful right now,
+//! while both backends are still routinely available.
+//!
+//! Run with:
+//!
+//! ```sh
+//! cargo run -p webarkitlib-rs --example simple_nft_dual
+//! # or, explicit:
+//! cargo run -p webarkitlib-rs --features "dual-mode log-helpers" --example simple_nft_dual
+//! ```
+
+use std::io::Cursor;
+use std::path::Path;
+use std::sync::Arc;
+
+use webarkitlib_rs::ar2::{
+ ar2_read_surface_set, ar2_surface_set_marker_info, ar2_tracking, AR2Handle,
+};
+use webarkitlib_rs::arlog::ar_log_init_default;
+use webarkitlib_rs::icp::icp_create_handle;
+use webarkitlib_rs::kpm::types::{KpmRefDataSet, FREAK_SUB_DIMENSION};
+use webarkitlib_rs::kpm::{
+ CppFreakMatcher, DualFreakMatcher, FeaturePoint, FreakMatcherBackend, KpmHandle, Point3d,
+};
+use webarkitlib_rs::types::{ARParam, ARParamLT, ARPixelFormat};
+use webarkitlib_rs::{arlog_e, arlog_i};
+
+/// Project a 2D point through a 3×3 row-major homography (inhomogeneous).
+fn project(h: &[f32; 9], x: f32, y: f32) -> (f32, f32) {
+ let w = h[6] * x + h[7] * y + h[8];
+ (
+ (h[0] * x + h[1] * y + h[2]) / w,
+ (h[3] * x + h[4] * y + h[5]) / w,
+ )
+}
+
+/// Max per-corner displacement between two homographies' reprojections
+/// of the reference-image corners. Mirrors `DualFreakMatcher`'s private
+/// tier-2 metric (M9 #152 corner-reprojection check) so the example can
+/// report the same number for transparency.
+fn max_corner_displacement(cpp_h: &[f32; 9], rust_h: &[f32; 9], rw: f32, rh: f32) -> f32 {
+ let corners = [(0.0, 0.0), (rw, 0.0), (rw, rh), (0.0, rh)];
+ corners
+ .iter()
+ .map(|&(x, y)| {
+ let (cx, cy) = project(cpp_h, x, y);
+ let (rx, ry) = project(rust_h, x, y);
+ ((cx - rx).powi(2) + (cy - ry).powi(2)).sqrt()
+ })
+ .fold(0.0_f32, f32::max)
+}
+
+/// Feed a [`KpmRefDataSet`] into a `FreakMatcherBackend` by replicating
+/// the page/image grouping logic from `KpmHandle::set_ref_data_set`.
+/// Phase A doesn't go through `KpmHandle` (we need post-query access to
+/// the concrete `DualFreakMatcher`), so this loop lives in the example.
+///
+/// Returns the per-`db_id` reference image dimensions so the example can
+/// reproduce `DualFreakMatcher`'s tier-2 corner-reprojection metric for
+/// the matched id.
+fn feed_ref_data(
+ matcher: &mut M,
+ ref_data: &KpmRefDataSet,
+) -> Result, Box> {
+ let mut dims: Vec<(i32, i32)> = Vec::new();
+ let mut db_id: usize = 0;
+ for page in &ref_data.page_info {
+ for img in &page.image_info {
+ let mut points: Vec = Vec::new();
+ let mut descriptors: Vec = Vec::new();
+ let mut points_3d: Vec = Vec::new();
+
+ for rp in &ref_data.ref_point {
+ if rp.ref_image_no == img.image_no && rp.page_no == page.page_no {
+ points.push(FeaturePoint {
+ x: rp.coord2d.x,
+ y: rp.coord2d.y,
+ angle: rp.feature_vec.angle,
+ scale: rp.feature_vec.scale,
+ maxima: rp.feature_vec.maxima != 0,
+ });
+ points_3d.push(Point3d {
+ x: rp.coord3d.x,
+ y: rp.coord3d.y,
+ z: 0.0,
+ });
+ descriptors.extend_from_slice(&rp.feature_vec.v[..FREAK_SUB_DIMENSION]);
+ }
+ }
+
+ matcher.add_freak_features(
+ &points,
+ &descriptors,
+ &points_3d,
+ img.width as usize,
+ img.height as usize,
+ db_id,
+ )?;
+
+ dims.push((img.width, img.height));
+ db_id += 1;
+ }
+ }
+ Ok(dims)
+}
+
+fn main() {
+ ar_log_init_default();
+
+ let data_dir = Path::new(env!("CARGO_MANIFEST_DIR"))
+ .join("examples")
+ .join("Data");
+
+ arlog_i!("========================================");
+ arlog_i!(" WebARKitLib-rs — Simple NFT Dual Example");
+ arlog_i!("========================================");
+
+ // ---------------------------------------------------------------
+ // Step 1: Load camera parameters
+ // ---------------------------------------------------------------
+ let param_path = data_dir.join("camera_para.dat");
+ arlog_i!("Step 1: Loading camera parameters...");
+ let param_bytes = match std::fs::read(¶m_path) {
+ Ok(b) => b,
+ Err(e) => {
+ arlog_e!("failed to read camera_para.dat: {e}");
+ return;
+ }
+ };
+ let mut param = match ARParam::load(Cursor::new(¶m_bytes)) {
+ Ok(p) => p,
+ Err(e) => {
+ arlog_e!("failed to parse camera params: {e}");
+ return;
+ }
+ };
+ arlog_i!(
+ " Camera (original): {}x{}, mat[0][0]={:.2}",
+ param.xsize,
+ param.ysize,
+ param.mat[0][0]
+ );
+
+ // ---------------------------------------------------------------
+ // Step 2: Load test image (pinball demo) as grayscale luma
+ // ---------------------------------------------------------------
+ let img_path = data_dir.join("pinball-demo.jpg");
+ arlog_i!("Step 2: Loading test image...");
+ let jpeg_bytes = match std::fs::read(&img_path) {
+ Ok(b) => b,
+ Err(e) => {
+ arlog_e!("failed to read pinball-demo.jpg: {e}");
+ return;
+ }
+ };
+ let mut decoder = jpeg_decoder::Decoder::new(Cursor::new(&jpeg_bytes));
+ let pixels = match decoder.decode() {
+ Ok(p) => p,
+ Err(e) => {
+ arlog_e!("JPEG decode failed: {e}");
+ return;
+ }
+ };
+ let info = match decoder.info() {
+ Some(i) => i,
+ None => {
+ arlog_e!("no JPEG info");
+ return;
+ }
+ };
+ let width = info.width as i32;
+ let height = info.height as i32;
+ arlog_i!(" Image: {}x{}", width, height);
+
+ // RGB → luma (BT.601 integer formula).
+ let luma: Vec = pixels
+ .chunks_exact(3)
+ .map(|rgb| ((rgb[0] as u32 * 77 + rgb[1] as u32 * 150 + rgb[2] as u32 * 29) >> 8) as u8)
+ .collect();
+ arlog_i!(" Luma: {} bytes", luma.len());
+
+ // Scale camera params to match image dimensions.
+ let sx = width as f64 / param.xsize as f64;
+ let sy = height as f64 / param.ysize as f64;
+ for col in 0..4 {
+ param.mat[0][col] *= sx;
+ param.mat[1][col] *= sy;
+ }
+ param.xsize = width;
+ param.ysize = height;
+ arlog_i!(
+ " Camera (scaled): {}x{}, mat[0][0]={:.2}",
+ param.xsize,
+ param.ysize,
+ param.mat[0][0]
+ );
+
+ // ---------------------------------------------------------------
+ // Step 3: Load NFT marker reference data
+ // ---------------------------------------------------------------
+ let marker_name = "pinball";
+ let marker_base = data_dir.join(marker_name);
+ arlog_i!("Step 3: Loading NFT marker '{}'...", marker_name);
+
+ let fset3_path = data_dir.join(format!("{}.fset3", marker_name));
+ let mut ref_data_a = match KpmRefDataSet::load(&fset3_path) {
+ Ok(r) => r,
+ Err(e) => {
+ arlog_e!("failed to load .fset3: {e}");
+ return;
+ }
+ };
+ ref_data_a.change_page_no(
+ webarkitlib_rs::kpm::ref_data_set::KPM_CHANGE_PAGE_NO_ALL_PAGES,
+ 0,
+ );
+ arlog_i!(
+ " .fset3: {} features, {} pages",
+ ref_data_a.num,
+ ref_data_a.page_num
+ );
+
+ // ---------------------------------------------------------------
+ // Phase A — DualFreakMatcher diagnostic
+ //
+ // Drives the dual matcher directly (bypassing KpmHandle) so we can
+ // read per-backend state after the query. KpmHandle wraps the
+ // backend in Box, which forbids recovering
+ // the concrete DualFreakMatcher type — hence the split phases.
+ // ---------------------------------------------------------------
+ arlog_i!("Phase A: DualFreakMatcher diagnostic");
+
+ let mut dual = match DualFreakMatcher::new(width, height) {
+ Ok(d) => d,
+ Err(e) => {
+ arlog_e!("failed to create DualFreakMatcher: {e:?}");
+ return;
+ }
+ };
+ let ref_dims = match feed_ref_data(&mut dual, &ref_data_a) {
+ Ok(d) => d,
+ Err(e) => {
+ arlog_e!("failed to feed ref data into DualFreakMatcher: {e}");
+ return;
+ }
+ };
+ arlog_i!(
+ " Reference data loaded into both backends ({} db_ids).",
+ ref_dims.len()
+ );
+
+ let dual_result = match dual.query(&luma, width as usize, height as usize) {
+ Ok(r) => r,
+ Err(e) => {
+ arlog_e!("DualFreakMatcher::query failed: {e:?}");
+ return;
+ }
+ };
+ arlog_i!(
+ " Query complete. matched_id = {} (C++ ground truth)",
+ dual_result.matched_id
+ );
+
+ arlog_i!(" divergence_count = {}", dual.divergence_count());
+ match dual.last_divergence_reason() {
+ Some(reason) => arlog_i!(" last_divergence_reason = {reason}"),
+ None => arlog_i!(" last_divergence_reason = (none)"),
+ }
+
+ match (dual.cpp_matched_geometry(), dual.rust_matched_geometry()) {
+ (Some(cpp_h), Some(rust_h)) => {
+ arlog_i!(" Side-by-side comparison is at the HOMOGRAPHY level (3x3 H).");
+ arlog_i!(" The 3x4 camera pose printed in Phase B is derived from the");
+ arlog_i!(" C++ homography only (DualFreakMatcher returns C++ as ground");
+ arlog_i!(" truth, M9-2 D5); per-backend 3x4 poses are not computed here.");
+ arlog_i!(" C++ homography (3x3):");
+ for r in 0..3 {
+ arlog_i!(
+ " [{:>10.4} {:>10.4} {:>10.4}]",
+ cpp_h[r * 3],
+ cpp_h[r * 3 + 1],
+ cpp_h[r * 3 + 2]
+ );
+ }
+ arlog_i!(" Rust homography (3x3):");
+ for r in 0..3 {
+ arlog_i!(
+ " [{:>10.4} {:>10.4} {:>10.4}]",
+ rust_h[r * 3],
+ rust_h[r * 3 + 1],
+ rust_h[r * 3 + 2]
+ );
+ }
+ // Look up the matched id's reference dimensions so the
+ // displacement metric matches DualFreakMatcher's internal
+ // tier-2 check exactly.
+ let matched = dual_result.matched_id;
+ match (matched >= 0)
+ .then(|| matched as usize)
+ .and_then(|i| ref_dims.get(i))
+ {
+ Some(&(ref_w, ref_h)) => {
+ let max_disp =
+ max_corner_displacement(cpp_h, rust_h, ref_w as f32, ref_h as f32);
+ arlog_i!(" Homography agreement (M9 #152 tier-2 metric):");
+ arlog_i!(" max corner displacement between H_cpp and H_rust");
+ arlog_i!(
+ " = {:.4} px (ref image {}x{}, tolerance: 2.0 px)",
+ max_disp,
+ ref_w,
+ ref_h
+ );
+ }
+ None => {
+ arlog_i!(
+ " (no ref dims for matched_id={}; skipping corner-displacement metric)",
+ matched
+ );
+ }
+ }
+ }
+ _ => {
+ arlog_i!(" (one or both backends produced no homography — no comparison possible)");
+ }
+ }
+
+ // ---------------------------------------------------------------
+ // Phase B — Production pipeline (mirror simple_nft.rs flow)
+ //
+ // Fresh KpmHandle + CppFreakMatcher. DualFreakMatcher::query returns
+ // C++ as ground truth (M9-2 D5), so using CppFreakMatcher directly
+ // here produces the same pose AR2 would receive in either case.
+ // ---------------------------------------------------------------
+ arlog_i!("Phase B: Production pipeline (KpmHandle + CppFreakMatcher)");
+
+ let ref_data_b = match KpmRefDataSet::load(&fset3_path) {
+ Ok(mut r) => {
+ r.change_page_no(
+ webarkitlib_rs::kpm::ref_data_set::KPM_CHANGE_PAGE_NO_ALL_PAGES,
+ 0,
+ );
+ r
+ }
+ Err(e) => {
+ arlog_e!("failed to reload .fset3 for Phase B: {e}");
+ return;
+ }
+ };
+
+ let mut surface_set = match ar2_read_surface_set(&marker_base) {
+ Ok(s) => s,
+ Err(e) => {
+ arlog_e!("failed to load surface set: {e:?}");
+ return;
+ }
+ };
+ if let Some((w, h, dpi)) = ar2_surface_set_marker_info(&surface_set) {
+ arlog_i!(" Surface set: marker {}x{} @ {:.0} DPI", w, h, dpi);
+ }
+
+ let param_lt = ARParamLT::new_basic(param.clone());
+ let param_lt_arc = Arc::new(param_lt);
+
+ let cpp_backend = match CppFreakMatcher::new(width, height) {
+ Ok(b) => b,
+ Err(e) => {
+ arlog_e!("failed to create CppFreakMatcher: {e:?}");
+ return;
+ }
+ };
+ let mut kpm_handle = KpmHandle::new(
+ width,
+ height,
+ Some(param_lt_arc.clone()),
+ Box::new(cpp_backend),
+ );
+
+ if let Err(e) = kpm_handle.set_ref_data_set(ref_data_b) {
+ arlog_e!("failed to set ref data set: {e:?}");
+ return;
+ }
+
+ if let Err(e) = kpm_handle.kpm_matching(&luma) {
+ arlog_e!("kpm_matching failed: {e:?}");
+ return;
+ }
+
+ let pose = kpm_handle.get_pose();
+ let Some((cam_pose, page_no, error)) = pose else {
+ arlog_i!(" KPM produced no valid pose — example ends here.");
+ return;
+ };
+
+ arlog_i!(" KPM match: page={}, error={:.4}", page_no, error);
+ arlog_i!(" Initial 3x4 pose matrix:");
+ for r in 0..3 {
+ arlog_i!(
+ " [{:>10.4} {:>10.4} {:>10.4} {:>10.4}]",
+ cam_pose[r][0],
+ cam_pose[r][1],
+ cam_pose[r][2],
+ cam_pose[r][3]
+ );
+ }
+
+ // AR2 tracking refinement (same flow as simple_nft.rs).
+ arlog_i!(" Running AR2 tracking...");
+ surface_set.set_init_trans(cam_pose);
+
+ let mut ar2_handle = AR2Handle::new(width, height, ARPixelFormat::MONO);
+ let param_lt_for_ar2 = Box::new(ARParamLT::new_basic(param.clone()));
+ ar2_handle.cparam_lt = Box::into_raw(param_lt_for_ar2);
+ let icp_handle_ptr = match icp_create_handle(¶m.mat) {
+ Ok(p) => p,
+ Err(e) => {
+ arlog_e!("failed to create ICP handle: {e:?}");
+ return;
+ }
+ };
+ ar2_handle.icp_handle = icp_handle_ptr;
+
+ let mut refined_pose = *cam_pose;
+ let mut tracking_err = 0.0f32;
+
+ match ar2_tracking(
+ &mut ar2_handle,
+ &mut surface_set,
+ &luma,
+ &mut refined_pose,
+ &mut tracking_err,
+ ) {
+ Ok(()) => {
+ arlog_i!(" AR2 tracking succeeded! Error={:.4}", tracking_err);
+ arlog_i!(" Refined 3x4 pose matrix:");
+ for r in 0..3 {
+ arlog_i!(
+ " [{:>10.4} {:>10.4} {:>10.4} {:>10.4}]",
+ refined_pose[r][0],
+ refined_pose[r][1],
+ refined_pose[r][2],
+ refined_pose[r][3]
+ );
+ }
+ }
+ Err(code) => {
+ arlog_i!(
+ " AR2 tracking returned error code: {} (expected for a single static frame)",
+ code
+ );
+ arlog_i!(" The KPM initial pose above is still valid and usable.");
+ }
+ }
+
+ // Clean up raw pointers held by AR2Handle.
+ unsafe {
+ if !ar2_handle.cparam_lt.is_null() {
+ let _ = Box::from_raw(ar2_handle.cparam_lt);
+ ar2_handle.cparam_lt = std::ptr::null_mut();
+ }
+ if !ar2_handle.icp_handle.is_null() {
+ let _ = Box::from_raw(ar2_handle.icp_handle);
+ ar2_handle.icp_handle = std::ptr::null_mut();
+ }
+ }
+
+ arlog_i!("========================================");
+ arlog_i!(" Simple NFT Dual example complete.");
+ arlog_i!("========================================");
+}
diff --git a/crates/core/src/kpm/rust_backend.rs b/crates/core/src/kpm/rust_backend.rs
index 25e6ef8..2ad919f 100644
--- a/crates/core/src/kpm/rust_backend.rs
+++ b/crates/core/src/kpm/rust_backend.rs
@@ -412,6 +412,21 @@ impl DualFreakMatcher {
self.last_divergence_reason.as_deref()
}
+ /// Homography from the most recent `query` as observed by the C++
+ /// backend, or `None` if no query has matched yet. Delegates to
+ /// [`CppFreakMatcher::matched_geometry`]. Used by the
+ /// `simple_nft_dual` example (#157) to print per-backend geometry.
+ pub fn cpp_matched_geometry(&self) -> Option<&[f32; 9]> {
+ self.cpp.matched_geometry()
+ }
+
+ /// Homography from the most recent `query` as observed by the
+ /// pure-Rust backend, or `None` if no query has matched yet.
+ /// Delegates to [`RustFreakMatcher::matched_geometry`].
+ pub fn rust_matched_geometry(&self) -> Option<&[f32; 9]> {
+ self.rust.matched_geometry()
+ }
+
/// Project the four reference-image corners through `h` (3x3 row-major
/// homography). Returns the projected points in order: top-left,
/// top-right, bottom-right, bottom-left. Mirrors M9 #152's
diff --git a/docs/design/m9-2-simple-nft-dual.md b/docs/design/m9-2-simple-nft-dual.md
new file mode 100644
index 0000000..bd8aa12
--- /dev/null
+++ b/docs/design/m9-2-simple-nft-dual.md
@@ -0,0 +1,219 @@
+# Milestone 9 — Step 2 follow-up: `simple_nft_dual.rs` diagnostic example
+
+**Status**: Design approved, ready for implementation
+**Branch**: `feat/m9-2-simple-nft-dual` (PR target: `feat/freak-visual-database`)
+**Parent milestone issue**: [#139](https://github.com/webarkit/WebARKitLib-rs/issues/139)
+**Issue**: [#157](https://github.com/webarkit/WebARKitLib-rs/issues/157)
+**Depends on**: [#141](https://github.com/webarkit/WebARKitLib-rs/issues/141) / [#156](https://github.com/webarkit/WebARKitLib-rs/pull/156) (M9-2 — `DualFreakMatcher` and concrete `matched_geometry()` accessors) — merged into `feat/freak-visual-database`
+**Unblocks (informational)**: contributors verifying M9-2 design doc §10 numbers locally before [#142](https://github.com/webarkit/WebARKitLib-rs/issues/142) (M9-3) flips the default off `ffi-backend`
+**Author**: Walter Perdan ([@kalwalt](https://github.com/kalwalt))
+**Date**: 2026-05-23
+
+---
+
+## 1. Understanding Summary
+
+- **What**: Create `crates/core/examples/simple_nft_dual.rs` — a diagnostic sibling of `simple_nft.rs` that exercises `DualFreakMatcher` on the pinball reference image and reports per-query divergence + side-by-side homographies. Add two tiny public accessors on `DualFreakMatcher` so the example can read each backend's matched homography.
+- **Why**: Final end-to-end integration signal before M9-3 (#142) flips the default backend off `ffi-backend`. While both backends are still routinely available, this example lets contributors reproduce the M9-2 design doc §10 parity numbers locally and catch any late-arriving cross-backend regression.
+- **Who for**: WebARKitLib-rs contributors debugging Rust-backend regressions; reviewers cross-checking M9-2 §10. After M9-3 lands, it becomes a niche debugging tool.
+- **Key constraints**:
+ - Compiles only under `--features dual-mode` (transitively pulls `ffi-backend`).
+ - Logging via `arlog_*!` (per `CLAUDE.md` §2) — no `println!` in the new file.
+ - No new tests (M9-2 already has `test_dual_mode_no_divergence_on_pinball`); no CI integration; no C-FFI surface changes.
+ - Branch off `origin/feat/freak-visual-database`; PR back into `feat/freak-visual-database`.
+- **Non-goals**:
+ - No conversion of the existing `simple_nft.rs` to `arlog_*!` (separate scope — [#90](https://github.com/webarkit/WebARKitLib-rs/issues/90) PR 4).
+ - No multi-frame iteration loop (single query — mirror `simple_nft.rs`).
+ - No `max_corner_displacement` promotion to public API (only one caller).
+ - No downcast helper on `FreakMatcherBackend` trait.
+
+---
+
+## 2. Decision Log
+
+| # | Decision | Alternatives | Rationale |
+|---|----------|--------------|-----------|
+| D1 | **Two-phase structure**: Phase A drives `DualFreakMatcher` directly for diagnostics; Phase B uses a fresh `KpmHandle` + `CppFreakMatcher` for the production pose-estimation + AR2 pipeline | (a) Move dual into KpmHandle — loses post-move access since `FreakMatcherBackend` lacks `Any` bound and inner state is unreachable through `Box`; (b) add `as_any` to trait | No API surface change beyond the two accessors in D4. Cleanest "diagnostic vs production" mental model for a reader. C++ ground-truth pose feeding AR2 is identical either way (`DualFreakMatcher::query` returns C++ result per M9-2 D5). |
+| D2 | **Output**: print both 3×3 homographies side-by-side + max corner displacement + one 3×4 KPM pose (C++-derived) + AR2 refined pose | (a) Compute two 3×4 poses via `kpm_util_get_pose_binary` for both backends — adds ~50 LOC + couples example to internals; (b) divergence-summary only — too minimal | Strictly uses `matched_geometry()` (the API the issue references) and reports the M9 #152 tier-2 metric. Keeps the example ~80 LOC. |
+| D3 | **Single query iteration** (mirror `simple_nft.rs`) | 3 iterations (mirror M9-2 milestone-gate test) | Contributor reading both files side-by-side sees a near-symbol-level diff; "example shape ≠ test shape" stays clean. Multi-iteration coverage already lives in `test_dual_mode_no_divergence_on_pinball`. |
+| D4 | **Two tiny accessors on `DualFreakMatcher`**: `cpp_matched_geometry()` and `rust_matched_geometry()`, each returning `Option<&[f32; 9]>` | (a) Three-matcher pattern (DualFreakMatcher + standalone CppFreakMatcher + standalone RustFreakMatcher) — triples setup + runs three queries; (b) also promote `max_corner_displacement` to public | Smallest delta. Not an FFI change. Rust-only, already `#[cfg(feature = "dual-mode")]`-gated. `max_corner_displacement` stays private — example reimplements ~10 LOC inline (identical algorithm). |
+| D5 | **Branch off `origin/feat/freak-visual-database`, PR back into same** | Branch off `dev` | DualFreakMatcher only exists on the M9 integration branch. Matches the M9 PR pattern (#140, #141, etc.). |
+| D6 | **Use `arlog_*!` macros from day one** in the new file: `arlog_i!` for narrative output, `arlog_e!` for error sites, call `ar_log_init_default()` at startup | `println!` (matches existing `simple_nft.rs` style); mix | Per CLAUDE.md §2 — all new code uses the project log macros. Issue [#90](https://github.com/webarkit/WebARKitLib-rs/issues/90) PR 4 will eventually retrofit `simple_nft.rs`; we don't preempt that scope. |
+| D7 | **Don't touch `simple_nft.rs`** | Combined PR converting it to `arlog_*!` (closes part of #90) too | Strict CLAUDE.md "fresh branch per issue/sub-issue" rule. The two issues are logically related but procedurally distinct. |
+| D8 | **`Cargo.toml` `[[example]]` entry: `required-features = ["dual-mode", "log-helpers"]`** | `["dual-mode"]` only (the issue's literal spec); add `log-helpers` to `dual-mode`'s feature chain | `dual-mode` for `DualFreakMatcher`; `log-helpers` for the `ar_log_init_default()` logger init so the arlog macros emit. The issue's literal spec was written assuming `println!`-based output; with arlog we need both. Adding `log-helpers` to `dual-mode`'s chain would force every `dual-mode` consumer to pull `env_logger`/`console_log` — wrong scope. |
+| D9 | **Phase A loads ref data manually**: replicates `KpmHandle::set_ref_data_set`'s feature-feeding loop inline (~30 LOC) for the `DualFreakMatcher` | (a) Factor a shared helper into the library; (b) use `add_image` with a raw reference image extracted from the `.iset` surface set | (a) is library-API scope creep for a one-off example. (b) wouldn't be apples-to-apples with `simple_nft.rs`'s `.fset3`-driven path. Inline loop is honest about what `set_ref_data_set` does and the duplication is contained to one file. |
+| D10 | **Phase B uses `CppFreakMatcher` inside `KpmHandle`**, not `DualFreakMatcher` | Move the same `DualFreakMatcher` from Phase A into `KpmHandle` | Can't — `Box` is one-way (no downcast). Re-creating a fresh matcher for Phase B is the cost of D1. Two queries on a static image is negligible. `CppFreakMatcher` produces identical ground-truth pose to `DualFreakMatcher::query` (per M9-2 D5). |
+
+---
+
+## 3. Final Design
+
+### 3.1 File layout
+
+| File | Change |
+|---|---|
+| `crates/core/src/kpm/rust_backend.rs` | **Modify** — add `cpp_matched_geometry()` and `rust_matched_geometry()` to `impl DualFreakMatcher`. Both `#[cfg(feature = "dual-mode")]`-gated (the entire `impl DualFreakMatcher` block already is). |
+| `crates/core/examples/simple_nft_dual.rs` | **Create** — new diagnostic example. LGPL-3.0 header per `.claude/HEADER.txt`. |
+| `crates/core/Cargo.toml` | **Modify** — add `[[example]]` entry with `required-features = ["dual-mode", "log-helpers"]`. |
+
+No other files touched. No CHANGELOG.md edit (release-only per CLAUDE.md §4).
+
+### 3.2 `DualFreakMatcher` accessor additions
+
+```rust
+#[cfg(feature = "dual-mode")]
+impl DualFreakMatcher {
+ // ... existing items unchanged ...
+
+ /// Homography from the most recent `query` as observed by the C++
+ /// backend, or `None` if no query has matched yet. Used by the
+ /// `simple_nft_dual` example to print backend-by-backend geometry.
+ pub fn cpp_matched_geometry(&self) -> Option<&[f32; 9]> {
+ self.cpp.matched_geometry()
+ }
+
+ /// Homography from the most recent `query` as observed by the
+ /// pure-Rust backend, or `None` if no query has matched yet.
+ pub fn rust_matched_geometry(&self) -> Option<&[f32; 9]> {
+ self.rust.matched_geometry()
+ }
+}
+```
+
+Both delegate to the existing concrete-impl accessors. Zero new state; zero behaviour change for current callers.
+
+### 3.3 Example structure
+
+Module docstring opens with:
+
+> Diagnostic sibling of `simple_nft.rs`. Drives `DualFreakMatcher` instead of `RustFreakMatcher` to compare C++ vs pure-Rust homographies on a static image. For production use, see `simple_nft.rs`.
+
+Flow:
+
+```text
+[init logger via ar_log_init_default()]
+
+Step 1: Load camera_para.dat -> ARParam (scaled to image)
+Step 2: Load pinball-demo.jpg as luma -> Vec
+Step 3: Load pinball.fset3 -> KpmRefDataSet
+
+Phase A — DualFreakMatcher diagnostic
+─────────────────────────────────────
+Step 4a: DualFreakMatcher::new(w, h)
+Step 4b: For each page/image in ref_data:
+ dual.add_freak_features(...) [inline loop, ~30 LOC]
+Step 4c: dual.query(&luma)
+Step 4d: arlog_i! output:
+ - divergence_count
+ - last_divergence_reason (if any)
+ - cpp_matched_geometry (3×3 H)
+ - rust_matched_geometry (3×3 H)
+ - max corner displacement (computed inline against
+ the reference image dimensions from ref_data.page_info[0])
+
+Phase B — Production pipeline (mirror simple_nft.rs)
+───────────────────────────────────────────────────
+Step 5a: Reload .fset3 + .iset/.fset surface set
+Step 5b: CppFreakMatcher::new(w, h)
+Step 5c: KpmHandle::new(...) with cpp backend
+Step 5d: kpm_handle.set_ref_data_set(ref_data)
+Step 5e: kpm_handle.kpm_matching(&luma)
+Step 5f: kpm_handle.get_pose() -> 3×4 pose
+ arlog_i! the pose
+Step 5g: surface_set.set_init_trans(cam_pose); AR2 tracking
+ arlog_i! the refined pose
+```
+
+### 3.4 Inline `max_corner_displacement` (D4)
+
+```rust
+fn project(h: &[f32; 9], x: f32, y: f32) -> (f32, f32) {
+ let w = h[6] * x + h[7] * y + h[8];
+ ((h[0] * x + h[1] * y + h[2]) / w, (h[3] * x + h[4] * y + h[5]) / w)
+}
+
+fn max_corner_displacement(cpp_h: &[f32; 9], rust_h: &[f32; 9], rw: f32, rh: f32) -> f32 {
+ let corners = [(0.0, 0.0), (rw, 0.0), (rw, rh), (0.0, rh)];
+ corners
+ .iter()
+ .map(|&(x, y)| {
+ let (cx, cy) = project(cpp_h, x, y);
+ let (rx, ry) = project(rust_h, x, y);
+ ((cx - rx).powi(2) + (cy - ry).powi(2)).sqrt()
+ })
+ .fold(0.0_f32, f32::max)
+}
+```
+
+Mirrors `DualFreakMatcher::reproject_corners` + `max_corner_displacement` (which stay private per D4). Reference dimensions sourced from `ref_data.page_info[0].image_info[0].{width,height}`.
+
+### 3.5 `Cargo.toml` entry
+
+```toml
+[[example]]
+name = "simple_nft_dual"
+# M9-2 #157: diagnostic sibling of simple_nft, drives DualFreakMatcher.
+# - dual-mode -> DualFreakMatcher (transitively pulls ffi-backend)
+# - log-helpers -> ar_log_init_default() so arlog_* macros emit output
+required-features = ["dual-mode", "log-helpers"]
+```
+
+### 3.6 Run command (run-book in module docstring)
+
+```sh
+# With required-features declared, cargo auto-enables them:
+cargo run -p webarkitlib-rs --example simple_nft_dual
+
+# Explicit form:
+cargo run -p webarkitlib-rs --features "dual-mode log-helpers" --example simple_nft_dual
+```
+
+### 3.7 Observed output (measured during implementation)
+
+On `pinball-demo.jpg`:
+
+- **Tier-1 (matched_id) divergence**: none — both backends agree on `matched_id = 2` (one of 9 internal db_ids spanning the page's image-scale variants).
+- **Tier-2 (corner reprojection) divergence**: ~13.80 px on db_id=2 with reference dimensions 595×745. This exceeds the 2.0 px tolerance, so `divergence_count = 1` after a single query and `last_divergence_reason` populates with the reprojection message.
+- C++ KPM error = **7.1455** and C++ 3×4 pose row 0 = `[0.9862, 0.1671, 0.0641, -182.1635]` — matches §10 of `m9-2-rust-backend.md` exactly.
+- The C++ pose feeds AR2 cleanly (per M9-2 D5); AR2 returns the usual single-static-frame error code, identical to `simple_nft.rs` behavior.
+- The example's internally-computed corner displacement matches `DualFreakMatcher`'s internal tier-2 number to four decimals (we look up the matched id's ref dims via the `Vec<(i32, i32)>` returned from `feed_ref_data`, exactly mirroring `DualFreakMatcher::ref_dims`).
+
+**Interpretation correction vs. the original issue**: Issue #157 anticipated "zero divergences, two near-identical poses, max corner displacement < 2.0 px" on pinball. That expectation was extrapolated from §10's `simple_nft` measurement (which never used `DualFreakMatcher` — it ran the two backends through `KpmHandle` separately). The actual `DualFreakMatcher` measurement reported here is what the milestone-gate test asserts for the `found.jpg`/`img.jpg` pair (zero divergence) but **not** for pinball. The pinball divergence is the cross-language BHC-variance envelope §10 already discusses — sub-degree rotation and sub-percent translation differences, but on the corner-reprojection metric they manifest as ~14 px because the matched scale (db_id=2) is at a smaller reference resolution where any rotation/translation difference projects to more pixels.
+
+This is informational, not a regression — the example surfaces a real signal that the milestone-gate test's fixture choice happens to miss.
+
+### 3.8 Risks acknowledged
+
+| Risk | Mitigation |
+|---|---|
+| AR2 tracking may fail on single frame (already documented in `simple_nft.rs`) | Same fallback message style; KPM pose still printed |
+| Float-noise reproducibility across machines may make exact §10 numbers differ slightly | Module docstring explicitly says "or very close to them, modulo nondeterministic float behavior" |
+| `log-helpers` feature requirement is a tiny departure from issue's literal spec | Documented in D8; the issue's spec predates the arlog decision |
+
+---
+
+## 4. Implementation Checklist
+
+- [ ] `git fetch origin && git checkout -b feat/m9-2-simple-nft-dual origin/feat/freak-visual-database`
+- [ ] Add `cpp_matched_geometry()` + `rust_matched_geometry()` to `impl DualFreakMatcher` in `crates/core/src/kpm/rust_backend.rs`
+- [ ] Create `crates/core/examples/simple_nft_dual.rs` with LGPL-3.0 header
+- [ ] Add `[[example]]` entry to `crates/core/Cargo.toml`
+- [ ] `cargo fmt --all`
+- [ ] `cargo build --all-features` clean
+- [ ] `cargo clippy --all-targets --all-features -- --deny warnings` clean
+- [ ] `cargo test --all-features` green (M9-2 dual-mode test still passes)
+- [ ] `cargo run --example simple_nft_dual` produces expected output (divergence_count = 0, two near-identical homographies, max corner displacement < 2.0 px)
+- [ ] PR title: `feat(examples): add simple_nft_dual.rs with DualFreakMatcher and per-frame divergence reporting`
+- [ ] PR body references #157 and notes the §10 reference numbers
+- [ ] PR base: `feat/freak-visual-database`
+
+---
+
+## 5. References
+
+- Issue [#157](https://github.com/webarkit/WebARKitLib-rs/issues/157) — this work
+- Issue [#141 comment-4482406138](https://github.com/webarkit/WebARKitLib-rs/issues/141#issuecomment-4482406138) — original "optional deliverable" framing of M9-2
+- [docs/design/m9-2-rust-backend.md §10](./m9-2-rust-backend.md) — reference parity numbers
+- M9 #152 — corner reprojection metric (`max_displacement` < 2.0 px tolerance)
+- Issue [#90](https://github.com/webarkit/WebARKitLib-rs/issues/90) PR 4 — future `simple_nft.rs` arlog conversion (not in this PR)
+- [`CLAUDE.md`](../../CLAUDE.md) §2 — `arlog_*!` logging convention