Compare commits

...

5 Commits

Author SHA1 Message Date
rUv 8c24b8bdfe refactor(beyond-sota): ADR-154 M3 — clear §7.4 P3 backlog (22 de-magic + 6 boundary tests, backlog 36→0) (#1057)
* refactor(signal): de-magic motion.rs tuning constants (ADR-154 §7.4 #18)

Lift the bare fusion weights, normalization scales, confidence-indicator
weights, and adaptive-threshold clamp bounds in motion.rs out of the
scoring functions into named, documented EMPIRICAL-DEFAULT consts. Values
are bit-identical to the prior literals — this is cleanup, no behaviour
change.

Adds boundary/characterization tests pinning current behaviour:
- motion_tuning_consts_unchanged_from_literals (consts == old literals)
- doppler_component_saturates_at_full_scale (/100 then clamp(0,1))
- correlation_score_zero_below_n2_boundary (n<2 guard)
- temporal_variance_zero_below_two_history (len<2 guard)
- adaptive_threshold_engages_at_history_boundary (history 9 vs 10)

Co-Authored-By: claude-flow <ruv@ruv.net>

* refactor(signal): gesture.rs euclidean length guard + de-magic (ADR-154 §7.4 #12)

- Add a debug_assert! to euclidean_distance documenting the same-dimension
  caller contract: zip() silently truncates on a length mismatch, so a
  mismatch is now loud in debug builds while the release operating path and
  output are unchanged.
- De-magic the bare 1e-10 confidence epsilon into a documented const
  CONFIDENCE_SECOND_BEST_EPSILON (value unchanged).

Tests pinning current behaviour:
- confidence_epsilon_unchanged_from_literal
- dtw_empty_sequence_is_infinite (n=0/m=0 boundary)
- euclidean_distance_equal_length_is_l2 (same-dim contract)

Co-Authored-By: claude-flow <ruv@ruv.net>

* refactor(signal): de-magic longitudinal.rs drift thresholds (ADR-154 §7.4)

Lift the bare drift-detection literals (7-day baseline, 2-sigma z-score,
3-day sustained, 7-day escalation, EMA alpha, cosine epsilon) into named,
documented EMPIRICAL-DEFAULT consts encoding the module's Key Invariants.
The duplicated `>= 7` in is_ready/is_ready_at now share one const. EMA alpha
kept as the exact 0.05 literal (1.0 - 0.95_f32 is not bit-identical in f32).
Values unchanged.

Tests:
- drift_consts_unchanged_from_literals
- is_ready_at_day_boundary (day 6 vs 7)
- cosine_similarity_zero_vector_is_zero (zero-norm guard)

Co-Authored-By: claude-flow <ruv@ruv.net>

* refactor(signal): de-magic division/zero-norm epsilons + boundary tests (ADR-154 §7.4)

De-magic the bare division-guard epsilons in four modules into named,
documented consts (values unchanged) and pin the previously-untested
zero-norm / zero-variance / degenerate boundaries:

- cross_room.rs: COSINE_SIMILARITY_EPSILON (1e-9) + test_cosine_similarity_zero_vector
- multiband.rs: PEARSON_DENOMINATOR_EPSILON (1e-12) + pearson_correlation_zero_variance
- intention.rs: LEAD_TIME_MIN_ACCEL (1e-10) + lead_time_zero_for_static_stream
- hampel.rs: ZERO_MAD_EPSILON (1e-15) + test_zero_half_window_error
  + test_zero_mad_constant_window; documented hampel_filter # Errors

Each module also gets a *_unchanged_from_literal const-pin test.

Co-Authored-By: claude-flow <ruv@ruv.net>

* refactor(signal): de-magic rf_slam + attractor_drift constants (ADR-154 §7.4)

rf_slam.rs:
- NS_PER_DAY (86_400_000_000_000.0), MIGRATION_MIN_SPAN_DAYS (1e-9), and the
  fixed-map defaults (FIXED_MAP_ASSOC_RADIUS_M/MIN_SIGHTINGS/MIN_COHERENCE)
  lifted out of inline literals (values unchanged).
- migration_zero_span_is_zero_rate pins the single-sighting zero-span guard.

attractor_drift.rs:
- METRIC_BUFFER_CAPACITY (365), STABLE_CENTER_WINDOW (10) de-magicked.
- Documented the implicit recent.len()>=1 divide-safety in the PointAttractor
  branch (guaranteed by the count < min_observations guard).
- analyze_min_observations_boundary pins the off-by-one boundary.

Each module gets a *_consts_unchanged_from_literals pin test.

Co-Authored-By: claude-flow <ruv@ruv.net>

* refactor(signal): de-magic coherence.rs variance floor + default decay (ADR-154 §7.4)

Completes the M1 #9 de-magic for coherence.rs: the four bare 1e-6 variance-floor
literals (update_reference floor + coherence_score/per_subcarrier_zscores epsilon)
collapse to one VARIANCE_FLOOR const, and the inline 0.95 default decay becomes
DEFAULT_EMA_DECAY. Values unchanged.

Tests:
- drift_consts_unchanged_from_literals extended (VARIANCE_FLOOR, DEFAULT_EMA_DECAY)
- coherence_score_finite_with_zero_variance pins the floor's effect

Co-Authored-By: claude-flow <ruv@ruv.net>

* refactor(signal): de-magic calibration.rs thresholds + min-frames default (ADR-154 §7.4 #2)

Lift the bare calibration literals into named EMPIRICAL-DEFAULT consts (values
unchanged, bit-identical; calibration is off the Python proof path):
- DEFAULT_MIN_FRAMES (600) — was repeated across all four tier constructors
- AMP_STD_FLOOR (1e-12) z-score divisor floor
- MOTION_AMP_Z_THRESHOLD (2.0) / MOTION_PHASE_DRIFT_THRESHOLD (π/6) — the two
  motion_flagged sites now share one definition
- SUBTRACT_MIN_NORM (1e-30) baseline-subtraction guard

Test calibration_consts_unchanged_from_literals pins all five and asserts every
tier constructor shares DEFAULT_MIN_FRAMES.

Co-Authored-By: claude-flow <ruv@ruv.net>

* refactor(signal): de-magic fusion_quality + temporal_gesture constants (ADR-154 §7.4)

fusion_quality.rs:
- CONTRADICTION_PENALTY (0.8) and CONTRADICTION_BOUND_HALFWIDTH (0.1) named.
- no_contradiction_is_identity pins the n=0 boundary (penalty 0.8^0 = 1.0,
  zero-width bounds).

temporal_gesture.rs:
- CONFIDENCE_SECOND_BEST_EPSILON (1e-10, mirrors gesture.rs) and
  NORM_QUANTIZATION_SCALE (1000.0) named.

Each module gets a *_consts_unchanged_from_literals pin test. Values unchanged.

Co-Authored-By: claude-flow <ruv@ruv.net>

* docs(adr-154): record Milestone-3 — §7.4 row #21-45 P3 backlog cleared

Replace the lumped #21-45 backlog row with the enumerated M3 resolution: 22
magic constants de-magicked into named EMPIRICAL-DEFAULT consts (each pinned ==
prior literal), 6 boundary/characterization tests, ~4 doc-only, across 11
modules; not-real findings reported + skipped (unreachable attractor_drift
div0, non-existent gesture thresholds, proof-path features.rs). Update residual
P3 rows #2/#12/#17/#18 to RESOLVED, the deferred count (36 -> 0), the scope
field, and the Horizon-ledger one-liner. §7.4 backlog fully cleared across
M0-M3. CHANGELOG [Unreleased] entry added.

Validation: signal lib --no-default-features 476/0/1; --features cir 476/0;
workspace 3,275/0; Python proof PASS, hash f8e76f21...46f7a UNCHANGED.

Co-Authored-By: claude-flow <ruv@ruv.net>

---------

Co-authored-by: ruv <ruvnet@gmail.com>
2026-06-13 19:36:05 -04:00
rUv 91248536bc feat(beyond-sota): ADR-156 M2 — RaBitQ unbiased distance estimator (rigorous published negative on strict-K) (#1056)
* feat(ruvector): RaBitQ unbiased distance estimator (ADR-156 M2)

Implement the real Gao & Long (SIGMOD 2024) RaBitQ contribution on top of
the existing Pass-2 rotation: an unbiased estimator of the inner product /
squared distance recovered from the 1-bit code plus 8 B/vec per-vector side
info (residual_norm + x_dot_o), used to rerank the candidate set instead of
raw Hamming.

- src/estimator.rs (new): EstimatorSketch, SideInfo, EstimatorQuery,
  DistanceEstimator (estimate_inner_product / estimate_sq_distance /
  ranking_key / cosine_ranking_key), EstimatorBank (topk_estimated[_cosine],
  with_centroid). Zero-centroid simplification documented; paper-faithful
  centroid path also built.
- src/rotation.rs: extract apply_padded() (full padded FHT frame the code
  lives in); apply() now truncates apply_padded(). No behaviour change.
- lib.rs: export estimator types.

Additive + backward-compatible: Pass-1 Sketch / Pass-2 SketchBank / WireSketch
wire format unchanged; all external callers use Pass-1 and are unaffected.

Co-Authored-By: claude-flow <ruv@ruv.net>

* test(ruvector): estimator strict-K coverage harness (ADR-156 M2)

Add measure_estimator (cosine rerank) + measure_estimator_euclidean to the
coverage harness, on the BIT-IDENTICAL fixture / cluster centres / query
stream / cosine ground truth as measure_pass1/measure_pass2 — apples-to-apples
sign-Hamming vs unbiased-estimator-rerank.

Regression tests:
- estimator_rerank_not_worse_than_sign (>= sign-only Pass-2 on a fixed fixture)
- estimator_coverage_is_deterministic
- estimator_coverage_report (--nocapture prints the strict-K table)

MEASURED strict-K (candidate_k=K=8): Pass-1 36.13% -> Pass-2-sign 46.39% ->
estimator-cosine 49.71%. Still short of the ADR-084 90% strict bar; estimator
reaches 95.12% at candidate_k=24 (vs sign 91.60%). Published negative.

Co-Authored-By: claude-flow <ruv@ruv.net>

* docs(ruvector): record RaBitQ estimator measured negative (ADR-156 §11, ADR-084)

- sketch_bench: estimator cosine/euclid columns in the coverage table.
- ADR-156 §11 (new): estimator formula + zero-centroid simplification stated
  honestly; strict-K coverage table; RESOLVED-NEGATIVE verdict (49.71% strict,
  short of 90%); pinning test names. §5 #2 + §10.5 updated.
- ADR-084 'Pass 2b' (new): estimator landed + measured strict-K vs the bar.
- CHANGELOG [Unreleased]: ADR-156 §11 Milestone-2 entry.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-13 18:24:40 -04:00
rUv 865f9dee77 perf(beyond-sota): ADR-154 M2 — FFT planner hoist (1.84x, bit-identical) + 3 honest perf nulls + boundary tests (#1055)
* perf(signal): hoist FFT planner across subcarriers (ADR-154 §7.4 #20)

compute_multi_subcarrier_spectrogram called compute_spectrogram once per
subcarrier, and each call built a fresh FftPlanner + re-planned the same
length-window_size FFT. Hoist the plan + window out of the per-subcarrier
loop via a new compute_spectrogram_with_plan core that takes a pre-planned
Arc<dyn Fft> and pre-built window. compute_spectrogram delegates to it
(unchanged behaviour); the multi-subcarrier path plans once and reuses.

MEASURED-HOT (dsp_perf_bench, this box): at 56 subcarriers, window 128,
fresh-planner-per-subcarrier 467.88 µs -> hoisted-plan 254.75 µs = 1.84x;
window 256: 627.27 µs -> 448.39 µs = 1.40x. Plan-forward cost alone is
~1.86 µs (w128), x56 subcarriers ~= the removed delta.

Output is bit-identical: multi_subcarrier_hoisted_plan_bit_identical
compares f64::to_bits of every spectrogram value + freq/time resolution
against the per-call fresh-planner path across all 4 window functions x
{power,magnitude} on a 56-subcarrier matrix. The numeric STFT body is the
old loop verbatim; only plan/window construction is lifted.

Co-Authored-By: claude-flow <ruv@ruv.net>

* test(signal): boundary/tolerance tests for ADR-154 §7.4 #14 #16 #19

Three "+ test" backlog gaps closed — pure additions, no behaviour change
(phase_align refactor is internal: estimate_phase_offsets still returns the
identical offset vector; a counted core is split out only to observe the
iteration count).

#14 cir.rs fft_operator — fft_operator_within_tolerance_of_dense_canonical56:
  the opt-in FFT Φ/Φᴴ path changes the witness hash, so pin it numerically
  CLOSE to the dense path (not silently divergent). Asserts the full Cir
  output (every tap within 1e-2·dominant, dominant idx/ratio, active_tap_count,
  ranging_valid, rms_delay_spread) on the production canonical-56 config
  across τ ∈ {20,50,90} ns. Extends the existing HT20/single-τ test.

#16 phase_align.rs — refinement_terminates_at_iteration_cap_when_not_converging:
  forces non-convergence (tolerance=0.0, unreachable) and asserts the loop
  runs exactly max_iterations then returns — proving the cap, not convergence,
  bounds the loop (no infinite spin). Companion
  refinement_converges_before_cap_on_easy_input proves the cap is an upper
  bound, not the only exit.

#19 csi_ratio.rs — ratio_finite_at_and_below_1e_12_epsilon: the module
  implements the CSI ratio as the conjugate product H_i·conj(H_j) (no
  division), so it is finite even at/below the 1e-12 magnitude boundary a
  naive H_i/H_j division would need an epsilon to guard. Pins finiteness +
  bit-exact conjugate product at the boundary (zero target → zero, never
  inf/NaN), through the amplitude/phase extraction.

cargo test -p wifi-densepose-signal --no-default-features --lib: 447 passed,
0 failed; --features cir --lib: 447 passed, 0 failed.

Co-Authored-By: claude-flow <ruv@ruv.net>

* docs(adr-154): record Milestone-2 P2-perf verdicts + boundary tests (§7.4)

§7.4: #20 MEASURED-HOT (1.40–1.84× spectrogram FFT-plan hoist, bit-identical);
#5/#6/#7 MEASURED-NULL (benched, not hot, left as-is — sub-µs / stack-only /
alloc-once); #8 MEASUREMENT-ONLY (per-call 56×56 eigh cost; eigenvalue/BLAS
backend un-buildable on this Windows host, number deferred to a BLAS box, NOT
fabricated; also corrects the finding — extract_perturbation reuses cached
modes, the recompute is in estimate_occupancy). #14/#16/#19 RESOLVED (tolerance
/ convergence-cap / epsilon-boundary tests). Updated §7.4 intro + Horizon-ledger
(deferred count 41→36). CHANGELOG [Unreleased] entry added.

Co-Authored-By: claude-flow <ruv@ruv.net>

* bench(signal): committed P2 bench-first benches (ADR-154 §7.4 #5/#6/#7/#8/#20)

New dsp_perf_bench.rs backs every Milestone-2 perf verdict with a committed
criterion bench — no speedup claimed without a before/after number here, and
a benched NULL is the proof a micro-opt was unnecessary (the §5.x "already
amortized" pattern). Registered in Cargo.toml [[bench]].

MEASURED (this box, criterion medians):
  #20 spectrogram_multi_subcarrier (fresh vs hoisted plan):
      MEASURED-HOT — 467.88→254.75 µs (1.84x) @ sc56/w128; 627.27→448.39 µs
      (1.40x) @ sc56/w256. Optimized in the prior commit.
  #5 multistatic_attention/weights: MEASURED-NULL — 181 ns (2 nodes) ..
      848 ns (8 nodes); sub-µs, no hot-path alloc — left as-is.
  #6 tomography_reconstruct/solve: MEASURED-NULL — 47.5 µs (16 links) /
      60.4 µs (32 links) for a full 50-iter ISTA solve; the 2 per-solve voxel
      buffers (~4 KB) are negligible vs O(iters·links·voxels) compute, and
      reconstruct(&self) reuses them across iterations already — left as-is.
  #7 pose_kalman_update/cycles: MEASURED-NULL — 150 ns (17 kpts) / 2.82 µs
      (170); the Kalman "gain matrices" are fixed-size STACK arrays
      ([[f32;3];6]), zero heap — nothing to reuse — left as-is.
  #8 field_model_occupancy (eigenvalue feature): MEASUREMENT-ONLY — quantifies
      the per-call n×n eigendecomposition cost; incremental SVD is a sized
      future project, not attempted (number recorded in ADR-154 §7.4).

Reproduce:
  cargo bench -p wifi-densepose-signal --no-default-features --bench dsp_perf_bench
  cargo bench -p wifi-densepose-signal --bench dsp_perf_bench  # adds #8

Cargo.lock: dev-dep (criterion/clap) graph + crate version bumps from the
build; no runtime-dependency change.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-13 17:34:37 -04:00
rUv cf2a85db66 feat(beyond-sota): ADR-157 M1 — constant-time HMAC compare + MEASURED 5.57x native wlanapi scan (#1054)
* fix(hardware): constant-time HMAC sync-beacon tag compare (ADR-157 §B4)

AuthenticatedBeacon::verify compared the 8-byte HMAC-SHA256 tag with
`self.hmac_tag == expected`, which short-circuits on the first differing
byte and leaks, via verification latency, how many leading bytes a forged
tag matched — a byte-by-byte tag-recovery oracle (~256·N trials vs 256^N).

Replace with a hand-rolled branch-free `constant_time_tag_eq`: XOR-accumulate
every byte difference into a single u8 with no early exit, compare to zero
once. `#[inline(never)]` + `core::hint::black_box(diff)` resist the optimizer
reintroducing a short-circuit or a non-constant-time memcmp; length mismatch
returns false without inspecting contents. No new dependency — ADR-157 had
deferred this only to avoid the `subtle` crate; a fixed 8-byte compare needs
none.

Test (hard gate): tag_compare_is_constant_time_shape — equal / first-differ /
last-differ / all-differ / length-mismatch + end-to-end verify() last-byte
tamper. Proven to fail on a last-byte-skipping constant-time bug. A coarse
timing smoke check (tag_compare_timing_invariance_smoke) is #[ignore]d to
avoid CI flakiness. Grade MEASURED (constant-time construction).

ADR-157 §8 §B4 → RESOLVED. wifi-densepose-hardware: 164 passed / 0 failed.

Co-Authored-By: claude-flow <ruv@ruv.net>

* feat(wifiscan): MEASURE native wlanapi.dll vs netsh throughput (ADR-157 §5 #4)

ADR-157 §5 #4 recorded the native wlanapi.dll multi-BSSID fast path as
"asserted but NOT implemented; live scanner is the ~2 Hz netsh shim". Audit
finding: that status is stale — wlanapi_native::scan_native already implements
the real WlanOpenHandle → WlanEnumInterfaces → WlanGetNetworkBssList →
WlanFreeMemory/WlanCloseHandle FFI (handle cleanup on all exits, length-bounded
buffer walks, #[cfg(windows)] with typed Unsupported off-Windows), and
WlanApiScanner::scan_instrumented already wires it native-first with a netsh
fallback. The missing piece was an honest MEASUREMENT.

Add benchmark_backend(backend, window): drives one specific backend over a
fixed wall-clock window so netsh is timed independently (the existing
benchmark() picks native-first and so never measures netsh on a box where
native works). Returns None for an unavailable native path (honest negative,
not a fabricated number).

MEASURED on this box (Intel Wi-Fi 7 BE201 320MHz, 2026-06-13), 10 s window:
  native 21.42 Hz vs netsh 3.84 Hz = 5.57× (mean 5.0 BSSIDs/scan each).
  native-only run: 18.0 Hz. 50/50 back-to-back native scans, no handle leak.
A real positive result — NOT a fabricated 10×. Achieved 21.4 Hz is in the
asserted >2 Hz regime, below the asserted 10–20 Hz upper bound.

Tests (live-WLAN, #[ignore] for CI, RUN here):
  measure_native_vs_netsh_throughput, native_scans_dont_leak_handles,
  measure_native_scan_rate. Non-ignored pin native_scan_runs_real_ffi_on_windows
  (pre-existing) stays green. wifi-densepose-wifiscan: 94 passed / 0 failed.

ADR-157 §5 #4 + §8 → MEASURED (was ACCEPTED-FUTURE / CLAIMED-unmeasured).

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-13 16:32:34 -04:00
rUv 9b07dff298 feat(beyond-sota): ADR-155 metric unification + ADR-156 RaBitQ Pass-2 (honest negative + latent topk bugfix) (#1053)
* refactor(train): hoist canonical PCK/OKS to un-gated metrics_core; fold test_metrics onto production (ADR-155 M1 §8)

ADR-155 §8 deferred item: test_metrics.rs reference kernels validated
production against their OWN reimplementation — a test that cannot catch a
canonical-impl bug (both could be wrong the same way).

- Extract canonical_torso_size / pck_canonical / oks_canonical / sigmas /
  bounding_box_diagonal into a new NON-tch-gated `metrics_core` module, so
  the single metric definition is reachable under
  `cargo test --no-default-features` (the `metrics` module is tch-gated).
  `metrics` re-exports every item → still exactly ONE implementation.
- Rewrite tests/test_metrics.rs to assert the PRODUCTION pck_canonical /
  oks_canonical equal hand-computed fixtures (not a reimplementation):
  canonical_pck_matches_hand_computed_fixture (corr=3/total=4/pck=0.75),
  hip↔hip normalizer pin, zero-visible⇒0.0, OKS perfect⇒1.0, fake-Gold pin.
- Keep an INDEPENDENT raw-threshold reference kernel only as a differential
  cross-check: test_kernel_agrees_with_canonical asserts it AGREES with
  canonical where torso==1.0 (genuine cross-check, not duplication).

Grade: MEASURED. test_metrics 10→12 tests, 0 failed.

Co-Authored-By: claude-flow <ruv@ruv.net>

* fix(sensing-server): relabel divergent live PCK/OKS so they're never conflated with canonical (ADR-155 M1 §2.1/§8 Goal C)

Goal C named training_api.rs:804 (torso-HEIGHT PCK). Auditing it surfaced
TWO findings the ADR-155 §1 table missed:

1. training_api.rs is an ORPHAN file — not declared `mod` in lib.rs OR main.rs,
   so it does NOT compile into the crate. It does not drive the live server.
2. The REAL live `best_pck`/`best_oks` (main.rs training path → RVF metadata
   JSON read by model_manager.rs) come from trainer.rs:
   - `pck_at_threshold` = RAW-threshold PCK, NO torso normalization (the most
     divergent kind), printed/serialized as bare "PCK@0.2".
   - `oks_map` calls `oks_single(area=1.0)` = the EXACT fake-Gold pattern
     ADR-155 §2.1 claimed closed elsewhere — still live here, inflating best_oks.

Resolution = RELABEL (torso/raw math is load-bearing on different data; the
pub fns can't be renamed without breaking API; sensing-server has no train/
ndarray dep). Honest unify is a tracked §8 backlog item.

- training_api.rs: `compute_pck` → `compute_pck_torso_height` + divergence doc;
  val_pck/best_pck/val_oks struct fields documented as torso-HEIGHT proxies;
  logs say `pck_torso_h@0.2`. Test torso_pck_is_labelled_distinctly_from_canonical.
- trainer.rs (LIVE): `pck_at_threshold` documented raw-unnormalized; `oks_map`
  area=1.0 flagged fake-Gold; test pck_at_threshold_is_raw_unnormalized_not_canonical.
- main.rs: live print relabelled `pck_raw@0.2` / `oks_map(area=1.0 proxy)`.

No wire-format field renames (back-compat); no pub-API rename (no silent break).
Grade: MEASURED (relabel + divergence pinned). sensing-server 450→451 lib tests, 0 failed.

Co-Authored-By: claude-flow <ruv@ruv.net>

* docs(adr-155): mark §8 metric items RESOLVED + audit map + honest §1 under-count correction (M1b Goals A/D)

- §8.1: full PCK/OKS audit map (every def: file:line, basis, canonical/
  legacy/distinct), the two §8 items marked RESOLVED with resolution+why.
- Honest finding: §1's "seven divergent metrics" was an UNDER-count —
  sensing-server's LIVE trainer.rs has a raw-unnormalized PCK and an
  area=1.0 fake-Gold OKS the table omitted, and the file §8 named
  (training_api.rs) is orphaned dead code. §9 honest-limits updated.
- Goal D: metrics.rs *_v2 variants confirmed caller-less + deprecated;
  noted for future cleanup, NOT deleted (public API, tch-gated).
- CHANGELOG [Unreleased] Fixed entry.

Co-Authored-By: claude-flow <ruv@ruv.net>

* feat(ruvector): RaBitQ Pass-2 randomized rotation + topk bugfix (ADR-156 §8)

Implements the deferred "Multi-bit / Extended RaBitQ Pass 2" backlog item
from ADR-156 §8: a deterministic randomized orthogonal rotation applied
before sign-quantization, the published RaBitQ construction (Gao & Long,
SIGMOD 2024).

Rotation construction: Fast Hadamard Transform + seeded ±1 sign flips
("HD" / randomized Hadamard), O(d log d) time and O(d) memory — a dense
d×d rotation is O(d²) and infeasible at the 65,535-d the wire format
provisions for. Pads to the next power of two; SplitMix64 seeds the sign
stream so index-time and query-time rotations are bit-identical.

API is additive and backward-compatible: Pass 1 (`from_embedding`) is
untouched; Pass 2 is opt-in via `Sketch::from_embedding_rotated` and
`SketchBank::with_rotation` (+ `insert_embedding` / `topk_embedding` /
`novelty_embedding` helpers that rotate consistently). Default behaviour
is unchanged.

While building the Pass-2 coverage harness, found and fixed a PRE-EXISTING
correctness bug in `SketchBank::topk`: the n>k heap path used
`BinaryHeap<Reverse<(d,id)>>` (a min-heap) but treated its peek as the
max, so it returned the k FARTHEST sketches as "nearest". The shipped unit
tests only exercised the n≤k fast path, so it went unnoticed. Fixed to a
plain max-heap; pinned by `topk_heap_path_returns_nearest` and
`tight_clusters_give_high_coverage_with_overfetch` (the latter measured
0.072 on the old code).

New tests (+17, 100→117 in the crate): rotation determinism/norm-preservation
(`rotation_is_deterministic_for_seed`, `rotation_preserves_norm`), Pass-2
shape-compatibility, `pass2_coverage_not_worse_than_pass1`, and a
deterministic coverage report.

MEASURED top-K coverage (anisotropic planted-cluster fixture, cosine ground
truth; dim=128 N=2048 K=8 64 clusters noise=0.35 128 queries):
  candidate_k=K=8 : Pass1 36.13% -> Pass2 46.39%  (both << 90% bar)
  candidate_k=24  : Pass1 83.89% -> Pass2 91.60%  (Pass2 clears 90%)
  candidate_k=32  : Pass1/Pass2 100%
Honest result: rotation consistently helps (+10pp at strict K), but neither
pass clears the ADR-084 90% bar at candidate_k==K on this distribution.
Pass 2 reaches 90% only with ~3x over-fetch (the ADR-084 "candidate set"
deployment pattern). Multi-bit Pass 3 evaluated separately.

Co-Authored-By: claude-flow <ruv@ruv.net>

* feat(ruvector): multi-bit Pass-3 experiment + ADR-156/084 measured results

Adds the multi-bit half of the ADR-156 §8 "Multi-bit / Extended RaBitQ"
item as a MEASURED experiment (coverage::measure_multibit): rotate, then
b-bit uniform scalar-quantize each coord, rank by L1 over codes — the
natural multi-bit generalization of hamming. Measures the bit/coverage
tradeoff the backlog item asked for.

MEASURED at the strict bar (candidate_k=K=8, anisotropic planted-cluster
fixture, cosine ground truth):
  Pass1 (1-bit, no rot)  36.13%   16 B/vec
  Pass2 (1-bit, rot)     46.39%   16 B/vec
  Pass3 (rot, 2-bit)     54.39%   32 B/vec
  Pass3 (rot, 3-bit)     66.70%   48 B/vec
  Pass3 (rot, 4-bit)     74.22%   64 B/vec
Honest: multi-bit monotonically helps but even 4-bit (4x memory) reaches
only 74% at the strict bar — neither rotation nor <=4-bit multi-bit clears
the strict-K 90% bar on this distribution. The bar is met via over-fetch
(Pass2 @ candidate_k=24). Tests: multibit_tradeoff_report,
multibit_1bit_matches_pass2_approx (+ sanity that 1-bit ~= Pass-2).

Docs:
- ADR-156 §8 item #2 marked RESOLVED-PARTIAL; §5 #2 grade CLAIMED ->
  MEASURED-on-our-hardware; new §10 with full measured tables, the topk
  bugfix disclosure, and graded deferred sub-items.
- ADR-084: "Pass 2" section answering the rotation open-question with
  measured numbers + the topk bug note.
- CHANGELOG [Unreleased]: Added (Pass-2 milestone) + Fixed (topk heap).

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-13 16:02:18 -04:00
41 changed files with 4734 additions and 483 deletions
+14
View File
@@ -8,12 +8,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Security
- **ADR-157 Milestone-1 B4 - constant-time HMAC sync-beacon tag compare (`wifi-densepose-hardware`).** `AuthenticatedBeacon::verify` compared the 8-byte HMAC-SHA256 tag with `self.hmac_tag == expected`, which short-circuits on the first differing byte and leaks, through verification latency, how many leading bytes an attacker's forged tag matched - a byte-by-byte tag-recovery oracle (~256*N trials instead of 256^N). Replaced with a hand-rolled branch-free `constant_time_tag_eq` (XOR-accumulate every byte difference into a single `u8`, no early exit, `#[inline(never)]` + `core::hint::black_box` to stop the optimizer reintroducing a short-circuit or a non-constant-time `memcmp`). **No new dependency** - ADR-157 had deferred this only to avoid adding the `subtle` crate; a fixed 8-byte compare needs none. Grade MEASURED (constant-time *construction*; micro-timing on a noisy host is a smoke check only, gated `#[ignore]`). Pinned by `tag_compare_is_constant_time_shape` (equal/first-differ/last-differ/all-differ/length-mismatch + an end-to-end `verify()` last-byte tamper), proven to fail on a last-byte-skipping constant-time bug. ADR-157 §8 B4 -> RESOLVED.
- **ADR-080 open HIGH findings closed on the Rust `wifi-densepose-sensing-server` boundary (ADR-164 G11).** The QE sweep's three HIGH findings — XFF-spoofing bypass, leaked stack traces, JWT-in-URL (CWE-598) — were logged against the Python v1 API and never re-verified against the shipped Rust sensing-server; the HOMECORE/M7 sweep (ADR-161) covered `homecore-server`, not this crate.
- **#2 leaked internal errors (the one live exposure) — FIXED.** Six handlers in `main.rs` serialized the internal error `Display` straight into the JSON response body: `edge_registry_endpoint` returned a panicked `spawn_blocking` `JoinError` (`"task … panicked"`) in a `500`, plus the raw upstream error in a `503`; `delete_model`/`delete_recording`/`start_recording` returned `std::io::Error` strings (OS detail / path); `calibration_start`/`calibration_stop` returned the `FieldModel` error chain. New `error_response` module logs the full detail **server-side only** (with a correlation id) and returns a generic body (`{"error":"internal_error","correlation_id":…}`) — no `panicked`, no file paths, no Debug chain. 5 module tests (a leak-substring guard proven to fail on the reverted old body) + the existing handler suite.
- **#1 XFF-spoofing bypass — VERIFIED ABSENT, regression-pinned.** The sensing-server has no XFF-trusting control to bypass: there is no IP-based rate-limiter or IP-allowlist, and neither `bearer_auth` (token-only) nor `host_validation` (Host-header only) reads `X-Forwarded-For`/`X-Forwarded-Host` (no `forwarded`/`peer_addr`/`client_ip` anywhere in the crate). Added regression tests proving a spoofed `X-Forwarded-For` never flips an auth decision and a spoofed `X-Forwarded-Host` never bypasses the Host allowlist.
- **#3 JWT-in-URL (CWE-598) — VERIFIED ABSENT, regression-pinned.** `require_bearer` reads the token only from the `Authorization` header; the WebSocket handlers take no token query param and the sole `Query` extractor (`EdgeRegistryParams`) is a non-secret `refresh` flag. Added a regression proving `?token=`/`?access_token=` in the URL never authenticates while the header path still does.
### Fixed
- **ADR-155 Milestone-1b — metric-definition unification, the §8 backlog subset (Goals A/B/C).** Closed the two §8 metric-integrity items; every change pinned by a test, graded MEASURED. The audit (Goal A) also surfaced findings the §1 table under-counted — recorded honestly in ADR-155 §8.1, not hidden. Workspace stays green; Python proof unchanged (metrics are not on the deterministic proof's signal path).
- **Goal B — `test_metrics.rs` now validates the production metric, not a reimplementation.** The integration test previously asserted properties of its OWN local `compute_pck`/`compute_oks` (a test that can't catch a canonical-impl bug — both could be wrong the same way). Hoisted the canonical core (`pck_canonical`/`oks_canonical`/`canonical_torso_size`/sigmas/`bounding_box_diagonal`) into a new **un-gated** `metrics_core` module so the single definition is reachable under `cargo test --no-default-features` (the `metrics` module is `tch-backend`-gated); `metrics` re-exports it → still exactly ONE implementation. Rewrote the test to assert the production `pck_canonical`/`oks_canonical` equal **hand-computed** fixtures (`canonical_pck_matches_hand_computed_fixture` = 3/4 correct ⇒ 0.75; hip↔hip normalizer pin; zero-visible⇒0.0; OKS perfect⇒1.0; fake-Gold pin) plus a differential cross-check (`test_kernel_agrees_with_canonical`: an independent raw-threshold kernel must AGREE with canonical where torso==1.0). `wifi-densepose-train --no-default-features`: test_metrics **10→12**, 0 failed.
- **Goal C — divergent live-server PCK/OKS relabelled so they're never conflated with canonical.** Goal C named `training_api.rs:804` (torso-HEIGHT PCK); the audit found that file is an **orphan (not `mod`-declared, does not compile)** and the **real** live `best_pck`/`best_oks` come from `trainer.rs` — a **raw, unnormalized** `pck_at_threshold` and an **`area=1.0` fake-Gold** `oks_map` (both MISSED by ADR-155 §1, both on the claim-inflating side, both serialized as bare "PCK@0.2"/"OKS"). Torso-height/raw math is load-bearing (pixel-space, different scale axis, no `ndarray`/train dep), so the honest fix is **relabel, not force-unify**: `training_api.rs` `compute_pck``compute_pck_torso_height` + field/log docs; `trainer.rs` kernels documented raw/fake-Gold; `main.rs` prints `pck_raw@0.2` / `oks_map(area=1.0 proxy)`. No wire-format field or `pub`-fn renames (no silent API break). Pinned by `torso_pck_is_labelled_distinctly_from_canonical` + `pck_at_threshold_is_raw_unnormalized_not_canonical`. `wifi-densepose-sensing-server --no-default-features`: lib **450→451**, 0 failed. True unification onto `pck_canonical`/`oks_canonical` remains a tracked ADR-155 §8 item.
- **Pre-existing `SketchBank::topk` heap inversion returned the FARTHEST sketches (found during ADR-156 §8 Pass-2 work).** The `n > k` partial-sort path in `wifi-densepose-ruvector/src/sketch.rs` used `BinaryHeap<Reverse<(dist,id)>>` (a min-heap) but its eviction logic treated the peek as the max, so it kept the k *farthest* sketches and returned them as "nearest." The shipped unit tests only exercised the `n ≤ k` fast path (≤ 3 entries), so the inversion shipped silently in ADR-084. Fixed to a plain max-heap. Pinned by `topk_heap_path_returns_nearest` (farthest-first insertion exposes it) and `tight_clusters_give_high_coverage_with_overfetch` (**measured 0.072 coverage on the old code** — effectively random — vs >0.99 fixed). Every ADR-084 top-K coverage number depends on the fixed path. MEASURED, not a no-op.
- **ADR-154 Milestone-1 — cleared the P1 deferred backlog in `wifi-densepose-signal` (§7.4 #1, #10; partial #9, #13).** Each fix pinned by a regression test that fails on the old behaviour; every claim graded MEASURED / DATA-GATED; no fabricated thresholds. Python proof unchanged (`f8e76f21…46f7a`, bit-exact — the CIR ghost-tap guard is not on the deterministic proof path).
- **#1 (MEASURED metric / DATA-GATED threshold): circular phase variance.** `cir.rs::phase_variance` computed a *linear* sample variance over phase angles that wrap at ±π, so a tightly-clustered set straddling the branch cut reported spuriously HIGH dispersion — false-tripping the `> TAU` ghost-tap **guard** on real, tightly-clustered CIR taps. Replaced with Mardia's **circular variance** V = 1 R̄, bounded **[0,1]** and invariant to where the cluster sits on the circle. The old TAU-scaled threshold is meaningless on [0,1]; re-derived against a named const `GHOST_TAP_CIRCULAR_VARIANCE_MAX = 0.99` (fires only when R̄ ≤ 0.01 — essentially uniform phase). The **metric is MEASURED**; the **threshold value is DATA-GATED** (a clean single-path ramp also sweeps the circle, so V alone can't separate clean from unsanitized without labelled frames — the default is deliberately conservative, strictly more permissive at the wrap boundary than the buggy linear guard). Fails-on-old: `phase_variance_circular_not_fooled_by_branch_cut` (old linear variance > TAU on wrap-straddling phases while circular V≈0, guard no longer trips) + `phase_variance_circular_is_bounded_and_extremal` (V∈[0,1], V≈0 identical, V≈1 uniform).
- **#10 (MEASURED): Welford n=0/n=1 finiteness guard pinned.** The shared `WelfordStats` (`field_model.rs`) `count < 2` guards keep `variance`/`sample_variance`/`std_dev`/`z_score` finite at the boundaries, but the n=0 case was untested (same family as the §4 divide-by-(n1) trio). Added `welford_finite_at_n0_and_n1` — finite + documented-sentinel (0.0) at n=0/n=1. Fails-on-old proof: removing the `sample_variance` guard makes the test panic with "attempt to subtract with overflow" at the `(count 1)` underflow (guard restored).
@@ -22,9 +27,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Published HuggingFace model was unloadable — RVF format mismatch (#894).** The `ProgressiveLoader` rejected the published `ruvnet/wifi-densepose-pretrained` model with the opaque `invalid magic at offset 0: expected 0x52564653 (RVFS), got 0x77455735`, then silently fell back to signal heuristics (the "10 persons for 1" garbage reporters saw). The HF repo ships `model.safetensors`, `model-q{2,4,8}.bin` (magic `0x77455735` = "5WEw"), and `model.rvf.jsonl` — none carry the binary-RVF magic. New `model_format` module **auto-detects** RVFS / safetensors / HF-quant-bin / JSONL by magic+name, returns a **typed actionable** `ModelLoadError` (lists accepted formats + the one-command convert path — never the opaque magic), and **converts** `model.safetensors` / `model.rvf.jsonl` → RVF in-memory so the published full-precision model now loads via `--model`. A `--convert-model <in> --convert-out <out>` CLI subcommand gives a one-command offline path; the silent heuristics fallback is now a loud, actionable error. **Honest scope:** the converter wires the format/load path (safetensors F32 tensors → RVF weight segment, manifest written, Layer A/B/C all succeed, weights round-trip) — it does **not** claim end-to-end pose accuracy, since the HF pose-decoder architecture differs from this crate's inference head (still data-gated in #894). Quantized `.bin` blobs are rejected with a typed error pointing at the safetensors path. Pinned by `safetensors_converts_and_loads` + `hf_quant_classifies_to_actionable_error` (both fail on the old opaque-magic path).
### Changed
- **ADR-157 Milestone-1 §5 #4 - native `wlanapi.dll` multi-BSSID throughput MEASURED on real hardware (`wifi-densepose-wifiscan`).** The ADR's prior status ("asserted but NOT implemented; live scanner is the ~2 Hz netsh shim") is now stale: `wlanapi_native.rs` already implements the real `WlanOpenHandle` -> `WlanEnumInterfaces` -> `WlanGetNetworkBssList` -> `WlanFreeMemory`/`WlanCloseHandle` FFI and `WlanApiScanner` already wires it native-first with a netsh fallback. This milestone **measured it on this box** (Intel Wi-Fi 7 BE201 320MHz, 2026-06-13): a new `benchmark_backend(backend, window)` drives each backend over the same fixed 10 s wall-clock window so netsh is timed independently (the prior `benchmark()` picked native-first and never measured netsh on a Windows box where native works). **MEASURED: native 21.42 Hz vs netsh 3.84 Hz = 5.57x** (mean 5.0 BSSIDs/scan, both paths); a separate native-only run measured 18.0 Hz. Native genuinely beats netsh - this is a real positive result, not a fabricated "10x". 50 back-to-back native scans completed 50/50 with no handle leak/degradation. Live-WLAN tests (`measure_native_vs_netsh_throughput`, `native_scans_dont_leak_handles`, `measure_native_scan_rate`) are `#[ignore]` for CI but were RUN here; `native_scan_runs_real_ffi_on_windows` is a non-ignored schema-valid pin. ADR-157 §5 #4 + §8 -> MEASURED (was ACCEPTED-FUTURE / CLAIMED-unmeasured).
- **Mesh partition risk now demotes the privacy class and is witnessed (ADR-032).** The dynamic min-cut guard's `at_risk` signal was advisory-only (it fed the recalibration advisor). It now also contributes to the ADR-141 privacy demotion alongside fusion- and array-level contradictions: a mesh close to partitioning makes the fused belief less trustworthy, so the cycle emits at a more restricted class (monotonic — information only removed). Because `effective_class` feeds the BLAKE3 witness, a fragmenting array now shifts the witness — partition risk is auditable, not just logged. The mesh computation moved ahead of the demotion step in `process_cycle`; new `mesh_guard_mut()` exposes risk-threshold tuning. Test proves a forced-risk 3-node cycle demotes PrivateHome Anonymous→Restricted and shifts the witness vs a clean *same-topology* baseline (the only delta between the two cycles is the forced risk).
### Added
- **ADR-154 Milestone-3 — cleared the §7.4 row #2145 P3 backlog in `wifi-densepose-signal` (the lumped "remaining clarity/doc/magic-constant/missing-boundary-test findings across `ruvsense/*`, `features.rs`, `motion.rs`").** Honest enumeration first (grep, not the ADR's estimate): the lumped row was **~25 findings → 22 real, de-magicked across 11 modules; 6 boundary/characterization tests added; ~4 doc-only; the rest were already-handled or not-real and are reported as such** (the "row #2145" count was an estimate — there were not 25 *distinct* magic constants left after M0M2). **This is cleanup — no operating value or behaviour changed:** every de-magicked literal becomes a named, documented EMPIRICAL-DEFAULT const that **equals the prior literal exactly** (each module ships a `*_consts_unchanged_from_literals` pin test), and every boundary test pins **current** behaviour so a future retune is a visible, tested change. Modules touched: `motion.rs` (#18, fusion weights/normalization/adaptive-threshold consts + 5 tests), `gesture.rs` (#12, `euclidean_distance` length-mismatch `debug_assert` documenting the silent-truncation contract + DTW n=0/m=0 boundary), `longitudinal.rs` (drift thresholds 7-day/2σ/3-day/7-day/EMA + day-6/7 + zero-vector cosine), `cross_room.rs`/`multiband.rs`/`intention.rs`/`hampel.rs` (division-guard epsilons + zero-norm/zero-variance/zero-MAD boundary + `half_window==0` error path), `rf_slam.rs` (`NS_PER_DAY` + fixed-map defaults + zero-span guard), `attractor_drift.rs` (buffer/recent-window consts + documented the implicit `recent.len()≥1` divide-safety + `min_observations` off-by-one boundary), `coherence.rs` (#9 completion — variance-floor + default-decay), `calibration.rs` (#2`DEFAULT_MIN_FRAMES` deduped across 4 tier constructors + motion/subtract thresholds), `fusion_quality.rs` (contradiction penalty/bounds + n=0 identity), `temporal_gesture.rs` (confidence epsilon + quantization scale). **A "magic" the agents flagged that was NOT real:** an `attractor_drift.rs:301` "divide-by-zero" is unreachable (the `count < min_observations` guard guarantees `recent.len()≥1`) — documented + boundary-tested rather than guarded, per the no-behaviour-change rule. Signal crate lib `--no-default-features`: **476 passed, 0 failed, 1 ignored**; `--no-default-features --features cir`: **476 passed, 0 failed** (plain `--features cir` is unbuildable on this Windows host — the default `eigenvalue` feature pulls `openblas-src`, the same BLAS gate documented in M2 #8). Workspace `--no-default-features`: **3,275 / 0 failed** (single clean run). Python proof **VERDICT: PASS**, hash **`f8e76f21…46f7a` UNCHANGED, bit-exact** (asserted explicitly — these modules are off the deterministic PSD/Doppler proof path, and the de-magicked consts are bit-identical regardless). **This clears ADR-154's §7.4 deferred backlog to zero across M0M3.**
- **ADR-154 Milestone-2 — bench-first P2 perf subset + missing boundary tests (`wifi-densepose-signal`, §7.4 #5/#6/#7/#8/#14/#16/#19/#20).** PROOF discipline (ADR-154 §0): every perf item was **benched before being touched** (new committed `benches/dsp_perf_bench.rs`, criterion, this Windows box); only the one item the bench proved hot was optimized, the rest are committed MEASURED-NULLs — a benched null is the proof the micro-opt was unnecessary, the §5.1 "already amortized" pattern. Every behaviour-changing edit is pinned bit-identical (or documented-tolerance). Signal crate lib `--no-default-features`: **447 passed, 0 failed, 1 ignored**; `--features cir`: **447 passed, 0 failed**.
- **#20 MEASURED-HOT, optimized (bit-identical).** `compute_multi_subcarrier_spectrogram` re-planned a fresh `FftPlanner` for *every* subcarrier (via `compute_spectrogram`). Hoisted the plan + window out of the per-subcarrier loop (new `compute_spectrogram_with_plan` core; `compute_spectrogram` delegates, unchanged). **56-subcarrier: 467.88 µs → 254.75 µs = 1.84×** (window 128); **627.27 µs → 448.39 µs = 1.40×** (window 256). Bit-identical via `multi_subcarrier_hoisted_plan_bit_identical` (`f64::to_bits` of every value across all 4 window functions × {power,magnitude}). The §7.4 intro's predicted "most likely real win" — confirmed.
- **#5 / #6 / #7 MEASURED-NULL, left as-is.** `node_attention_weights` 181 ns (2 nodes)…848 ns (8) — sub-µs, no hot-path alloc. `tomography reconstruct` (full 50-iter ISTA, 256 voxels) 47.5 µs (16 links) / 60.4 µs (32) — the 2 voxel buffers are already alloc-once + `.fill`-reused, negligible vs O(iters·links·voxels). `pose_tracker` Kalman cycle 150 ns (17 keypoints) / 2.82 µs (170) — the "gain matrices" are fixed-size **stack** arrays, zero heap to reuse. No rewrite shipped; the committed benches prove each is not hot.
- **#8 MEASUREMENT-ONLY, BLAS-gated (number deferred, not fabricated).** Correction to the finding: `extract_perturbation` does **not** recompute the SVD (it projects against cached `finalize_calibration` modes); the real per-call eigendecomposition is the `eigenvalue`-feature `estimate_occupancy` (`cov.eigh()` on a 56×56 covariance). The `eig` bench is committed but `openblas-src` won't build on this Windows host ("Non-vcpkg builds are not supported on Windows" — the exact reason the project gate runs `--no-default-features`), so its µs cost must come from a Linux/BLAS box. Recorded, not estimated. Incremental SVD stays a sized future item.
- **#14 / #16 / #19 RESOLVED — tests added (no behaviour change).** `fft_operator_within_tolerance_of_dense_canonical56` pins the full `Cir` output of the opt-in FFT path within a documented relative tolerance of the dense path on the production canonical-56 config (τ ∈ {20,50,90} ns) — it changes the witness hash, so it must be provably *close*, not silently divergent. `refinement_terminates_at_iteration_cap_when_not_converging` (+ convergent companion) proves the LO-offset refinement terminates at exactly `max_iterations` on a non-converging input (cap, not convergence, bounds the loop; internal `…_counted` refactor returns the identical offsets). `ratio_finite_at_and_below_1e_12_epsilon` pins that the conjugate-product CSI-ratio (no division → no `1e-12` divide-guard needed) is finite + bit-exact at/below the epsilon boundary and at exact zero (where a naive `H_i/H_j` ratio is ±inf/NaN).
- **ADR-156 §11 Milestone-2: RaBitQ unbiased distance estimator — IMPLEMENTED & MEASURED (RESOLVED-NEGATIVE on the strict-K bar).** Closes the §10.5 / §8 backlog "full RaBitQ residual-distance estimator (not just a uniform scalar code)" item — the **real** Gao & Long (SIGMOD 2024) contribution, not just sign bits. New `wifi-densepose-ruvector/src/estimator.rs`: `EstimatorSketch` carries the Pass-2 sign code (over the padded FHT length `D = next_pow2(dim)`) **plus 8 B/vec side info** (`residual_norm` + `x_dot_o = ⟨x̄, o'⟩`, 2× f32); `DistanceEstimator` computes the **unbiased** estimate `⟨o',q'⟩ ≈ ⟨x̄,q'⟩ / x_dot_o` (the random rotation makes the 1-bit code's quantization error orthogonal-in-expectation to the query, paper `O(1/√D)` bound); `EstimatorBank::topk_estimated_cosine` reranks the candidate set by the estimate instead of raw Hamming. **Zero-centroid simplification (`c = 0`) stated honestly** — the paper-faithful per-cluster centroid path (`from_embedding_centred` / `EstimatorBank::with_centroid`) is also built so the simplification is a measured choice (no centroid coverage number is reported against the cosine ground truth, because cosine-of-residual ≠ cosine-of-raw would be a metric mismatch). **Purely additive + backward-compatible** — new types only; Pass-1 `Sketch` / Pass-2 `SketchBank` / `WireSketch` wire format unchanged; all external callers (`event_log.rs`, `signal/longitudinal.rs`, `sensing-server`) use Pass-1 and are unaffected. **MEASURED strict-K coverage** (same fixture/seeds as §10: dim=128 N=2048 K=8, 64 clusters, noise=0.35, 128 queries, cosine ground truth): the estimator lifts the strict `candidate_k=K` bar **46.39% (Pass-2 sign) → 49.71% (estimator, cosine rerank)** — a real **+3.3 pp** lift, **still ~40 pp short of the ADR-084 ≥90% strict bar.** At over-fetch the estimator beats sign (candidate_k=24: **95.12%** vs 91.60%). **Honest verdict — RESOLVED-NEGATIVE: the unbiased estimator does NOT clear the strict-K 90% bar on this distribution** (the binding constraint is the 1-bit code's information ceiling, not estimator variance); the bar is still met only via the over-fetch "candidate set" pattern ADR-084 specifies, though the estimator **reduces the over-fetch factor** needed. A published negative, reported as such — no benchmark tuned to manufacture a pass. Unbiasedness pinned by `estimator_unbiased_on_fixture` (Monte-Carlo mean over 4000 rotation seeds → true inner product within tolerance); not-worse-than-sign pinned by `estimator_rerank_not_worse_than_sign`; determinism by `estimator_is_deterministic`. +12 tests in the crate (119→131). Workspace **3,228 / 0 failed** (`cargo test --workspace --no-default-features`, 162 test binaries, single clean run), Python proof **VERDICT: PASS** (`f8e76f21…46f7a`, unchanged — estimator is not on the proof's signal path). Full numbers + reproduce commands in ADR-156 §11 / ADR-084 "Pass 2b".
- **ADR-156 §8 Milestone-1: RaBitQ Pass-2 randomized rotation + multi-bit experiment — IMPLEMENTED & MEASURED (RESOLVED-PARTIAL).** Closes the §8 "Multi-bit / Extended RaBitQ" backlog item. New `wifi-densepose-ruvector/src/rotation.rs`: a deterministic randomized orthogonal rotation `R = H·D`**Fast Hadamard Transform** (`O(d log d)`, in-place, `1/√m`-normalized so norm-preserving) + seeded ±1 sign flips (SplitMix64 from a stored `u64` seed; identical at index + query time). Chosen over a dense `d×d` matrix (`O(d²)`, infeasible at the 65,535-d the wire format provisions for); pads to `next_pow2(d)`. Additive, backward-compatible API (`Sketch::from_embedding_rotated`, `SketchBank::with_rotation` + `insert_embedding`/`topk_embedding`/`novelty_embedding`); Pass-1 and the wire format are byte-for-byte unchanged. New `coverage.rs` single-source-of-truth top-K coverage harness (anisotropic planted-cluster fixture, cosine ground truth) backs both a `#[test]` report and the `sketch_bench` coverage table. **MEASURED (dim=128 N=2048 K=8, 64 clusters, noise=0.35, 128 queries, seeded):** at the strict `candidate_k=K` bar, rotation lifts coverage **36.13% → 46.39%**; Pass-2 reaches the **ADR-084 ≥90% bar at candidate_k=24 (~3× over-fetch)**; multi-bit Pass-3 reaches 54%/67%/74% at 2/3/4-bit (strict bar). **Honest verdict: neither rotation nor ≤4-bit multi-bit clears the strict-K 90% bar on this distribution — the bar is met only via the over-fetch "candidate set" pattern ADR-084 specifies.** No benchmark was tuned to manufacture a pass; the strict-bar gap is documented (ADR-156 §10, ADR-084 "Pass 2" section). +19 tests in the crate (100→119), workspace **3,225 / 0 failed**, Python proof VERDICT: PASS (`f8e76f21…`, unchanged — sketch is not on the proof's signal path).
- **Beyond-SOTA `v2/crates/` sweep (ADR-154158) + full stub-implementation push — every claim MEASURED or graded.** A 5-milestone review/optimize/secure/benchmark/validate sweep, then a verified-audit-driven push to replace every production stub with real, tested logic (no labels, no placeholders). Each fix is pinned by a test that fails on the old code; every number ships with a reproduce command. Workspace: **3,122 tests / 0 failed** (`cargo test --workspace --no-default-features`), Python proof **VERDICT: PASS** (bit-exact).
- **ADR-154 Signal/DSP** — revived a dead ADR-134 CIR coherence gate (canonical-56 vs ht20 mismatch meant it never ran in production: 8/8 Err → 8/8 Ok); NaN-bypass + window div0 guards; PSD FFT-planner cache (**2.03.1×**) + honored DTW band (**2.44.1×**).
- **ADR-155 NN/Training** — unified 7 divergent PCK/OKS metric definitions into one canonical torso-normalized source (fixed two claim-inflating bugs: zero-visible PCK 1.0→0.0, OKS fake-Gold); leak-free subject-disjoint MM-Fi split + injected-leak detector; rapid_adapt replaced fake gradients with real finite-difference; proof.rs gained a min-decrease margin + committed-hash requirement; zero-copy ORT input (**1.48×**).
+66 -5
View File
@@ -259,14 +259,75 @@ Validation runs against:
- **ADR-083** (Proposed) — Per-cluster Pi compute hop. Defines the
device class that hosts the sketch bank.
## Pass 2 — randomized rotation + multi-bit (ADR-156 §8, landed 2026-06)
The "Open question" below ("does `BinaryQuantized` need a randomized
rotation pre-pass?") is now **answered with measured numbers** via
ADR-156 §10. Summary:
- **Pass 2 (randomized rotation) is implemented** —
`crates/wifi-densepose-ruvector/src/rotation.rs`: a deterministic
`R = H·D` (Fast Hadamard Transform + seeded ±1 sign flips), `O(d log d)`
/ `O(d)`, norm-preserving, reproducible from a stored `u64` seed. Opt-in
via `Sketch::from_embedding_rotated` / `SketchBank::with_rotation`;
Pass-1 API and wire format unchanged.
- **Measured top-K coverage** (anisotropic planted-cluster fixture,
cosine ground truth, dim=128 N=2048 K=8): rotation lifts coverage
**36.13% → 46.39%** at the strict `candidate_k = K` bar, and Pass-2
reaches the **≥90% acceptance bar at candidate_k = 24 (~3× over-fetch)**.
Multi-bit (≤4-bit) reaches 74% at the strict bar. **Honest verdict:
neither rotation nor ≤4-bit multi-bit clears the strict-K 90% bar on
this distribution; the bar is met via the over-fetch "candidate set"
pattern this ADR specifies** (Decision §"the canonical pattern" — sketch
picks the candidate set, full precision refines). Full numbers and
reproduce commands in ADR-156 §10.
- **Pre-existing `SketchBank::topk` bug fixed** — the `n > k` heap path
returned the k *farthest* sketches (min-heap mistaken for max-heap);
only the `n ≤ k` fast path had test coverage. Fixed + regression-pinned
(`topk_heap_path_returns_nearest`,
`tight_clusters_give_high_coverage_with_overfetch`). This makes every
prior top-K acceptance number in this ADR depend on the fixed path; the
≥90% coverage criterion is only meaningful post-fix.
## Pass 2b — RaBitQ unbiased distance estimator (ADR-156 §11, landed 2026-06)
The **real** RaBitQ contribution (Gao & Long, SIGMOD 2024) — an
**unbiased estimator of the inner product / distance** from the 1-bit
code + per-vector side info, not just sign bits — is now implemented and
**MEASURED against this ADR's ≥90% strict-K bar**:
- **Implemented** — `crates/wifi-densepose-ruvector/src/estimator.rs`:
`EstimatorSketch` (Pass-2 sign code + 8 B/vec side info:
`residual_norm` + `x_dot_o = ⟨x̄, o'⟩`), `DistanceEstimator`
(`⟨o',q'⟩ ≈ ⟨x̄,q'⟩ / x_dot_o`, the paper's unbiased rescale), and
`EstimatorBank` reranking candidates by the estimate instead of raw
Hamming. **Zero-centroid simplification** (`c = 0`) documented;
paper-faithful centroid path also built (`with_centroid`). Additive —
Pass-1/Pass-2 and the wire format are unchanged.
- **MEASURED strict-K coverage** (same fixture as §"Pass 2", cosine
ground truth): the estimator lifts the strict `candidate_k = K` bar
**46.39% (Pass-2 sign) → 49.71% (estimator, cosine rerank)** — a real
**+3.3 pp** lift, but **still ~40 pp short of the ≥90% strict bar.**
At over-fetch the estimator does better than sign (95.12% vs 91.60% at
candidate_k = 24). **Honest verdict: the unbiased estimator does NOT
clear the strict-K 90% bar on this distribution** — the binding
constraint is the 1-bit code's information ceiling, not estimator
variance. The ≥90% acceptance bar is still met only via the over-fetch
"candidate set" pattern this ADR's Decision specifies; the estimator
**reduces the over-fetch factor** needed but does not remove it. This
is a **published negative**, reported as such. Full numbers + reproduce
commands in ADR-156 §11.
## Open questions
- **Does `BinaryQuantized` need a randomized rotation pre-pass for
RuView's embedding distributions?** Pure sign quantization assumes
zero-centered, isotropic embeddings. If AETHER / spectrogram
distributions are skewed (likely for spectrogram), add a
`randomized_rotation` pre-pass following the original RaBitQ paper
(Gao & Long, SIGMOD 2024). Decided after pass-1 benchmark.
RuView's embedding distributions?** **ANSWERED (ADR-156 §10):** rotation
is built and measured — it helps (+10pp at strict K) but is not
sufficient alone for strict-K 90% on the tested anisotropic
distribution; the over-fetch candidate-set pattern meets the bar.
Pure sign quantization assumes zero-centered, isotropic embeddings; the
rotation decorrelates anisotropic coords as the RaBitQ paper
(Gao & Long, SIGMOD 2024) prescribes.
- **Sketch dimension target.** Default to the embedding's native
dimension (128 for AETHER, 256 for spectrogram). Higher-dimensional
sketches (Johnson-Lindenstrauss-projected to 512) trade compute for
+20 -16
View File
@@ -7,7 +7,7 @@
| **Deciders** | ruv |
| **Codebase target** | `wifi-densepose-signal` (`ruvsense/`, `features.rs`, `csi_processor.rs`, `spectrogram.rs`, `bvp.rs`), benches, docs |
| **Relates to** | ADR-134 (CIR sparse recovery), ADR-135 (Empty-Room Baseline), ADR-029/030/032 (Multistatic mesh + security), ADR-152 (WiFi-Pose SOTA 2026 intake), ADR-153 (802.11bf forward-compat) |
| **Scope** | Milestone 0 of the beyond-SOTA signal/DSP sweep: high-leverage **correctness/security fixes**, two **measured** perf wins, the per-module SOTA landscape with evidence grades, and a prioritized roadmap. **45 review findings are explicitly deferred** (§7 backlog) — nothing is silently dropped. |
| **Scope** | Milestone 0 of the beyond-SOTA signal/DSP sweep: high-leverage **correctness/security fixes**, two **measured** perf wins, the per-module SOTA landscape with evidence grades, and a prioritized roadmap. **45 review findings were explicitly deferred** (§7 backlog) — **now all addressed across Milestones 03** (§7.4 backlog cleared 2026-06-13); nothing was silently dropped. |
---
@@ -199,33 +199,37 @@ The §2–§5 fixes are **ACCEPTED and committed**: dead CIR gate fixed, NaN byp
Catalogued so nothing is silently dropped. Priority: **P1** correctness-adjacent, **P2** perf, **P3** clarity/style.
**Milestone-1 update (2026-06-13):** the **four P1 backlog items** (#1, #9, #10, #13) are now cleared — #1 and #10 **RESOLVED (MEASURED)**, #9 and #13 **RESOLVED-PARTIAL (DATA-GATED:** de-magicked + boundary-tested, operating values unchanged**)**. ~41 P2/P3 items remain deferred. Each fix is pinned by a regression test that fails on the old behaviour (commits `fd32f094a`, `4a9f2bcf4`, `d672fa602`, `5193f6369`); workspace `--no-default-features` green, Python proof unchanged (bit-exact).
**Milestone-1 update (2026-06-13):** the **four P1 backlog items** (#1, #9, #10, #13) are now cleared — #1 and #10 **RESOLVED (MEASURED)**, #9 and #13 **RESOLVED-PARTIAL (DATA-GATED:** de-magicked + boundary-tested, operating values unchanged**)**. Each fix is pinned by a regression test that fails on the old behaviour (commits `fd32f094a`, `4a9f2bcf4`, `d672fa602`, `5193f6369`); workspace `--no-default-features` green, Python proof unchanged (bit-exact).
**Milestone-2 update (2026-06-13):** the **bench-first P2 perf subset** (#5, #6, #7, #8, #20) and the **three missing boundary tests** (#14, #16, #19) are now cleared — ~36 P2/P3 items remained deferred *(now cleared — see the Milestone-3 update)*. PROOF discipline (§0): every perf item was **benched before being touched** — committed in `benches/dsp_perf_bench.rs` (criterion, this Windows box). Only **#20** proved hot and was optimized; **#5/#6/#7** are committed **MEASURED-NULLs** (benched, not hot, left as-is for clarity — exactly the §5.1 "already amortized" pattern); **#8** is **MEASUREMENT-ONLY** but its `eigenvalue`/BLAS backend won't build on this Windows host, so its µs cost must come from a Linux/BLAS box (recorded, not fabricated). Commits `e839fa8f1` (#20 fix), `02e5dd13a` (#14/#16/#19 tests), `aad9464f0` (benches). Workspace `--no-default-features` green; Python proof unchanged (#20 is bit-identical, off the proof path).
**Milestone-3 update (2026-06-13):** the lumped **row #2145** P3 backlog — *"remaining clarity/doc/magic-constant/missing-boundary-test findings across `ruvsense/*`, `features.rs`, `motion.rs`"* — is now **cleared, and with it the residual P3 items #2/#12/#17/#18.** Honest enumeration first (`grep`, not the ADR's "2145" estimate — that was a count, not 25 distinct findings): after M0M2 the genuinely-bare in-function literals resolved to **22 de-magicked constants across 11 modules** (each → a named, documented **EMPIRICAL-DEFAULT** const that **equals the prior literal exactly**), **6 added boundary/characterization tests**, **~4 doc-only fixes** (no-behaviour-change), and **a handful of agent-flagged "findings" that were NOT real** and are reported as skipped (below). **No operating value or behaviour changed** — every module carries a `*_consts_unchanged_from_literals` pin test and every boundary test pins *current* behaviour, so a future retune is a visible, tested change. Resolution by module: `motion.rs` (**#18** — fusion weights / Doppler+variance+phase scales / confidence weights / adaptive-threshold clamp; 5 tests), `gesture.rs` (**#12** — `euclidean_distance` length-mismatch `debug_assert` documenting the silent-`zip`-truncation caller contract, behaviour-preserving in release; + confidence epsilon; + DTW n=0/m=0 boundary), `longitudinal.rs` (7-day/2σ/3-day/7-day drift thresholds + EMA-α + cosine epsilon; day-6/7 + zero-vector boundaries; the duplicated `>=7` deduped), `cross_room.rs`/`multiband.rs`/`intention.rs`/`hampel.rs` (**#17** — division-guard epsilons `1e-9`/`1e-12`/`1e-10`/`1e-15` + zero-norm/zero-variance/zero-MAD boundaries + the previously-untested `hampel half_window==0` error path + `# Errors` doc), `rf_slam.rs` (`NS_PER_DAY` + `MIGRATION_MIN_SPAN_DAYS` + fixed-map defaults; single-sighting zero-span guard), `attractor_drift.rs` (`METRIC_BUFFER_CAPACITY`/`STABLE_CENTER_WINDOW`; **documented** the implicit `recent.len()>=1` divide-safety; `min_observations` off-by-one boundary), `coherence.rs` (**#9 completion** — the residual bare `1e-6` variance-floor ×4 + default `0.95` decay; floor-effect test), `calibration.rs` (**#2 completion** — `DEFAULT_MIN_FRAMES` deduped across all 4 tier constructors + `AMP_STD_FLOOR`/`MOTION_AMP_Z_THRESHOLD`/`MOTION_PHASE_DRIFT_THRESHOLD`/`SUBTRACT_MIN_NORM`), `fusion_quality.rs` (`CONTRADICTION_PENALTY` 0.8 / bound-halfwidth 0.1; n=0 identity boundary), `temporal_gesture.rs` (confidence epsilon + L2-norm quantization scale). **NOT-REAL / skipped (reported honestly, no churn manufactured):** an agent-flagged `attractor_drift.rs:301` "divide-by-zero" is **unreachable** — the `count < min_observations` guard guarantees `recent.len()>=1` before the `PointAttractor` branch (documented + boundary-tested, **not** guarded, per the no-behaviour-change rule); agent-flagged `gesture.rs` `2.0`/`π·6` motion thresholds **do not exist** in that file (a confusion with `calibration.rs::deviation`); **`features.rs` was deliberately left untouched** (it is on the deterministic Python-proof PSD/Doppler path — its `1e-10` guards already exist and are already correct; doc-only-skipped to protect the bit-exact hash). Commits `c794d1a0c` (motion #18), `adf9ed8e4` (gesture #12), `19f5b6335` (longitudinal), `19e0373c8` (epsilon helpers #17), `c6a09b69a` (rf_slam + attractor_drift), `5a1839f33` (coherence #9 completion), `df25a303e` (calibration #2 completion), `0f931ff2f` (fusion_quality + temporal_gesture). Signal crate lib `--no-default-features` **476 passed / 0 failed / 1 ignored**; `--no-default-features --features cir` **476 / 0**; workspace `--no-default-features` **3,275 / 0 failed** (single clean run); Python proof **VERDICT: PASS**, hash `f8e76f21…46f7a` **UNCHANGED (bit-exact)**. **§7.4 backlog is now fully cleared — ADR-154's deferred findings are addressed across M0M3 with nothing silently dropped.**
| # | Module | Finding | Pri | Why deferred |
|---|--------|---------|-----|--------------|
| 1 | cir.rs ~937 | `phase_variance` uses **linear** variance on **wrapped** angles (doc says "variance of phase angles") — spuriously inflates near ±π | P1 | **RESOLVED (`fd32f094a`) — metric MEASURED, threshold DATA-GATED.** Replaced with Mardia's circular variance V = 1 R̄ ∈ **[0,1]**, invariant to the cluster's position on the circle (branch-cut artefact gone). Guard re-derived against the bounded metric via named const `GHOST_TAP_CIRCULAR_VARIANCE_MAX = 0.99` (fires only when R̄ ≤ 0.01 — essentially uniform phase). The **threshold value is DATA-GATED**: a clean single-path ramp also sweeps the circle, so V alone can't separate clean from unsanitized without labelled frames — the default is deliberately conservative (strictly more permissive at the wrap boundary than the buggy linear guard). Fails-on-old: `phase_variance_circular_not_fooled_by_branch_cut` (old linear variance > TAU on wrap-straddling phases while circular V≈0, guard no longer trips), `phase_variance_circular_is_bounded_and_extremal`. |
| 2 | calibration.rs ~311 | `subtract_in_place` had a vacuous `if active_input {ki} else {ki}` branch implying a full-FFT→bin remap that didn't exist | P3 | **Resolved here** (branch removed, sequential-convention documented to match the sibling `extract_first_stream`). Listed for visibility — behavior unchanged. |
| 2 | calibration.rs ~311 | `subtract_in_place` had a vacuous `if active_input {ki} else {ki}` branch implying a full-FFT→bin remap that didn't exist | P3 | **Resolved (M0 + M3 `df25a303e`).** Branch removed in M0 (sequential-convention documented). M3 completed the de-magic: `DEFAULT_MIN_FRAMES=600` deduped across all four tier constructors, plus `AMP_STD_FLOOR`/`MOTION_AMP_Z_THRESHOLD`/`MOTION_PHASE_DRIFT_THRESHOLD`/`SUBTRACT_MIN_NORM` named + `calibration_consts_unchanged_from_literals`. Behaviour unchanged. |
| 3 | spectrogram.rs / bvp.rs | FFT planner built once-per-call (already amortized across frames) | P2 | Marginal vs the per-frame PSD site; cache if these become hot. |
| 4 | features.rs ~347 | Doppler FFT planner planned once per call, reused across subcarriers | P2 | Already amortized within the call. |
| 5 | multistatic.rs | `node_attention_weights` recomputes consensus/softmax each call; no SIMD | P2 | Needs a bench before touching; not obviously hot. |
| 6 | tomography.rs | ISTA L1 solver re-allocates voxel buffers per solve | P2 | Bench first. |
| 7 | pose_tracker.rs | Kalman gain matrices reallocated per update | P2 | Bench first. |
| 8 | field_model.rs | SVD recomputed on every perturbation extract | P2 | Incremental SVD is a real project, not a micro-fix. |
| 5 | multistatic.rs | `node_attention_weights` recomputes consensus/softmax each call; no SIMD | P2 | **MEASURED-NULL (`aad9464f0`) — benched, not hot, left as-is.** `multistatic_attention/weights`: **181 ns** (2 nodes) … **848 ns** (8 nodes) @ 56 subcarriers — sub-µs, no hot-path allocation. A precompute/SIMD rewrite buys nothing measurable at the realistic 28 node fan-in; the cosine/softmax cost is dwarfed by the surrounding fusion + per-frame FFT. Bench `multistatic_attention` in `dsp_perf_bench.rs`. |
| 6 | tomography.rs | ISTA L1 solver re-allocates voxel buffers per solve | P2 | **MEASURED-NULL (`aad9464f0`) — benched, not hot, left as-is.** A full 50-iteration `reconstruct` (256 voxels): **47.5 µs** (16 links) / **60.4 µs** (32 links). The two voxel buffers (`x`, `gradient`; ~4 KB) are already allocated *once* per `reconstruct()` and `.fill`-reused across iterations — the per-solve alloc is a negligible fraction of the O(iters·links·voxels) inner product. Reusing scratch across *calls* would force `reconstruct(&self)`→`&mut self` (API break) for no measurable gain. Bench `tomography_reconstruct`. |
| 7 | pose_tracker.rs | Kalman gain matrices reallocated per update | P2 | **MEASURED-NULL (`aad9464f0`) — benched, not hot, left as-is.** A Kalman predict+update cycle: **150 ns** (17 keypoints) / **2.82 µs** (170). The "gain matrices" (`s:[f32;3]`, `k:[[f32;3];6]`) are fixed-size **stack** arrays, *not* heap — there is no per-update allocation to reuse; the compiler keeps them in registers/stack. Bench `pose_kalman_update`. |
| 8 | field_model.rs | SVD recomputed on every perturbation extract | P2 | **MEASUREMENT-ONLY (`aad9464f0`) — BLAS-gated, not measurable on this host.** Correction: `extract_perturbation` does **not** recompute the SVD — it projects against the cached `modes` from `finalize_calibration`. The real per-call eigendecomposition is in the `eigenvalue`-feature `estimate_occupancy` (`cov.eigh()` on a 56×56 covariance, an O(n³)≈175k-flop symmetric eigensolve + O(n²·frames) covariance build, run per call). The bench (`dsp_perf_bench`'s `eig` module) is committed, but `openblas-src` **fails to build on this Windows box** ("Non-vcpkg builds are not supported on Windows" — the very reason the project gate runs `--no-default-features`), so a measured µs number must come from a Linux/BLAS host; **not estimated/fabricated here.** Incremental SVD remains a sized future project, not a micro-fix. |
| 9 | coherence.rs / coherence_gate.rs | Z-score thresholds are magic constants, untested at boundaries | P1 | **RESOLVED-PARTIAL (`5193f6369`) — DATA-GATED.** De-magicked `classify_drift` (`DRIFT_STABLE_SCORE=0.85`, `DRIFT_STEP_CHANGE_MAX_STALE=10`) and the `coherence_gate.rs` defaults (`DEFAULT_ACCEPT_THRESHOLD`/`…REJECT…`/`…MAX_STALE_FRAMES`/`…PREDICT_ONLY_NOISE`) into named, documented consts marked EMPIRICAL DEFAULT; added at/just-below/just-above boundary tests (`classify_drift_*_boundary`) + `*_consts_unchanged_from_literals`. **Operating values explicitly NOT changed** — defensible values still need labelled stable/drifting traces. The gate already exposed these via `GatePolicyConfig` (config seam). |
| 10 | longitudinal.rs | Welford update not numerically guarded for n=0 | P1 | **RESOLVED (`4a9f2bcf4`) — MEASURED.** The shared `WelfordStats` (`field_model.rs`, consumed by longitudinal.rs) `count < 2` guards already prevent the n=0 NaN / n=1 div0 / `(count1)` underflow, but the boundary was untested. Added `welford_finite_at_n0_and_n1` (finite + documented 0.0 sentinel at n=0/n=1). Fails-on-old proof: removing the `sample_variance` guard makes the test panic with "attempt to subtract with overflow" at the `(count 1)` underflow. |
| 11 | cross_room.rs | Fingerprint hash collisions unhandled | P2 | Low collision prob; needs design. |
| 12 | gesture.rs | `euclidean_distance` no length-mismatch guard | P3 | Caller-enforced; add `debug_assert`. |
| 12 | gesture.rs | `euclidean_distance` no length-mismatch guard | P3 | **RESOLVED (M3 `adf9ed8e4`).** Added a `debug_assert_eq!` on the two slice lengths + a doc block stating the same-`feature_dim` caller contract and that `zip()` silently truncates on a mismatch. Behaviour-preserving (no-op in release, the operating path). Also de-magicked the confidence `1e-10` epsilon and pinned the DTW `n=0`/`m=0` boundary (`dtw_empty_sequence_is_infinite`). |
| 13 | adversarial.rs | Gini/consistency thresholds are magic constants | P1 | **RESOLVED-PARTIAL (`d672fa602`) — DATA-GATED.** Lifted the bare literals in `check`/`check_consistency` (`FIELD_MODEL_GINI_VIOLATION=0.8`, `ENERGY_RATIO_HIGH_VIOLATION=2.0`, `ENERGY_RATIO_LOW_VIOLATION=0.1`, `CONSISTENCY_ACTIVE_FRACTION_OF_MEAN=0.1`, `SCORE_W_*`) into named, documented consts marked EMPIRICAL DEFAULT; added at/just-below/just-above boundary tests (`energy_ratio_high_boundary`, `energy_ratio_low_boundary`, `field_model_gini_boundary`, `consistency_active_fraction_boundary`) + `tuning_consts_unchanged_from_literals`. **Operating values explicitly NOT changed** — defensible values still need labelled spoofed/clean CSI (Wi-Spoof, §6.2/§7.3). Bumping a const fails a boundary test (verified). |
| 14 | cir.rs | `fft_operator` path changes the witness hash (documented) — no test that it's *numerically close* to dense | P2 | Add a tolerance test. |
| 14 | cir.rs | `fft_operator` path changes the witness hash (documented) — no test that it's *numerically close* to dense | P2 | **RESOLVED (`02e5dd13a`) — tolerance test added.** `fft_operator_within_tolerance_of_dense_canonical56` pins the **full `Cir` output** of the FFT path within a *documented* relative tolerance of the dense path on the production **canonical-56** config across τ ∈ {20,50,90} ns: every tap within `1e-2·|dominant|`, identical `dominant_tap_idx`, `active_tap_count`, `ranging_valid`, `dominant_tap_ratio` within `1e-2`, `rms_delay_spread` within `1e-2` rel. A regression that lets the FFT path drift (scaling/Φ-column bug) now fails here instead of silently corrupting a downstream witness. Extends the existing HT20/single-τ `fft_estimate_matches_dense_dominant_tap`. |
| 15 | multistatic.rs | `cir_gate_coherence` only estimates the **first** node/channel; multi-node CIR consensus unused | P2 | Design item (which node's CIR is authoritative?). |
| 16 | phase_align.rs | Iterative LO offset estimation has no convergence cap test | P2 | Add iteration-cap test. |
| 17 | hampel.rs | Window edge handling at series boundaries | P3 | Cosmetic. |
| 18 | motion.rs | Threshold constants undocumented | P3 | Doc-only. |
| 19 | csi_ratio.rs | Division guard relies on `1e-12` epsilon; no test | P2 | Add boundary test. |
| 20 | spectrogram.rs | `compute_multi_subcarrier_spectrogram` re-plans per subcarrier via `compute_spectrogram` | P2 | Hoist the planner (relates to #3). |
| 2145 | (assorted) | Remaining clarity/doc/magic-constant/missing-boundary-test findings across `ruvsense/*`, `features.rs`, `motion.rs` | P3 | Bulk-addressable in a dedicated "test-the-boundaries + de-magic-constant" follow-up; not high-leverage individually. |
| 16 | phase_align.rs | Iterative LO offset estimation has no convergence cap test | P2 | **RESOLVED (`02e5dd13a`) — cap test added.** `refinement_terminates_at_iteration_cap_when_not_converging` forces non-convergence (`tolerance = 0.0`, unreachable since `max_update ≥ 0`) and asserts the loop runs **exactly `max_iterations`** then returns — proving the cap (not convergence) bounds the loop, so a non-converging input can never spin forever. Companion `refinement_converges_before_cap_on_easy_input` proves the cap is an upper bound, not the only exit. Internal-only refactor: `estimate_phase_offsets` still returns the identical offset vector; a `…_counted` core surfaces the iteration count for the test. |
| 17 | hampel.rs | Window edge handling at series boundaries | P3 | **RESOLVED (M3 `19e0373c8`).** De-magicked the zero-MAD `1e-15` epsilon (`ZERO_MAD_EPSILON`), documented `hampel_filter`'s `# Errors`, and added the previously-untested `half_window == 0` error-path boundary (`test_zero_half_window_error`) + a zero-MAD constant-window characterization (`test_zero_mad_constant_window`). Window-edge handling itself is correct (`saturating_sub`/`.min(n)`); it is now pinned. |
| 18 | motion.rs | Threshold constants undocumented | P3 | **RESOLVED (M3 `c794d1a0c`).** Lifted the fusion weights, Doppler/variance/phase full-scale divisors, confidence-indicator weights, and adaptive-threshold clamp into named, documented EMPIRICAL-DEFAULT consts (`motion_tuning_consts_unchanged_from_literals` pins them) + small-`n` boundary tests (correlation `n<2`, temporal-variance `len<2`, adaptive-threshold history 9-vs-10, Doppler full-scale saturation). Doc-only-plus: values unchanged. |
| 19 | csi_ratio.rs | Division guard relies on `1e-12` epsilon; no test | P2 | **RESOLVED (`02e5dd13a`) — boundary test added.** Finding clarification: `csi_ratio.rs` implements the CSI *ratio model* as the **conjugate product** `H_i·conj(H_j)` (SpotFi/IndoTrack) — there is **no division**, hence no literal `1e-12` epsilon; the classic `H_i/H_j` ratio (which a `1e-12` guard protects) is deliberately avoided. `ratio_finite_at_and_below_1e_12_epsilon` pins the property the finding cares about: at and below the `1e-12` target magnitude (and at exact zero — where a division ratio is ±inf/NaN) the conjugate-product output is **finite**, exactly the conjugate product (bit-exact), collapses toward zero (the physically correct "no path" answer), and stays finite through `ratio_to_amplitude_phase`. |
| 20 | spectrogram.rs | `compute_multi_subcarrier_spectrogram` re-plans per subcarrier via `compute_spectrogram` | P2 | **MEASURED-HOT (`e839fa8f1`) — optimized, bit-identical.** Hoisted the FFT plan + window out of the per-subcarrier loop (new `compute_spectrogram_with_plan` core). **56-subcarrier** multi-spectrogram: **467.88 µs → 254.75 µs = 1.84×** (window 128); **627.27 µs → 448.39 µs = 1.40×** (window 256). The removed cost is the per-subcarrier `FftPlanner` re-plan (~1.86 µs/plan @ w128 × 56). Bit-identical (`multi_subcarrier_hoisted_plan_bit_identical`, `f64::to_bits` across all 4 windows × {power,magnitude}). The most likely real win predicted by the §7.4 intro — confirmed. (Relates to #3, which stays deferred: `spectrogram.rs`/`bvp.rs` single-signal callers already plan once-per-call.) |
| 2145 | (assorted) | Remaining clarity/doc/magic-constant/missing-boundary-test findings across `ruvsense/*`, `features.rs`, `motion.rs` | P3 | **RESOLVED (Milestone-3, 2026-06-13).** Enumerated honestly (the "2145" was an estimate, not 25 distinct findings): **22 bare in-function literals de-magicked → named EMPIRICAL-DEFAULT consts (each == prior literal, pinned)**, **6 boundary/characterization tests added**, **~4 doc-only fixes**, across 11 modules (`motion`, `gesture`, `longitudinal`, `cross_room`, `multiband`, `intention`, `hampel`, `rf_slam`, `attractor_drift`, `coherence`, `calibration`, `fusion_quality`, `temporal_gesture`). **No operating value changed.** **Skipped-as-not-real (reported, no churn):** `attractor_drift.rs:301` "divide-by-zero" is unreachable (guarded by `count < min_observations`) → documented + boundary-tested, not guarded; agent-flagged `gesture.rs` `2.0`/`π·6` motion thresholds don't exist there (confusion with `calibration::deviation`); **`features.rs` left untouched** (on the deterministic Python-proof path; its `1e-10` guards already exist & are correct — doc-only-skipped to keep the `f8e76f21…` hash bit-exact). See the Milestone-3 update note above and the per-row #2/#12/#17/#18 entries. |
> **Horizon-ledger one-liner.** Milestone-0 DONE: dead CIR gate (FIXED+proved), NaN/inf adversarial bypass (FIXED+proved), divide-by-(n1) window trio (FIXED+proved), calibration dead-branch (FIXED), PSD FFT-planner cache (MEASURED), DTW band (MEASURED). **Milestone-1 DONE (2026-06-13): all four P1 backlog items cleared — circular phase variance #1 (RESOLVED/MEASURED metric, DATA-GATED threshold), Welford n=0 guard #10 (RESOLVED/MEASURED), threshold magic-constants #9 & #13 (RESOLVED-PARTIAL/DATA-GATED — de-magicked + boundary-tested, values unchanged).** DEFERRED to follow-up: the ~41 remaining P2/P3 findings in §7.4 — none silently dropped.
> **Horizon-ledger one-liner.** Milestone-0 DONE: dead CIR gate (FIXED+proved), NaN/inf adversarial bypass (FIXED+proved), divide-by-(n1) window trio (FIXED+proved), calibration dead-branch (FIXED), PSD FFT-planner cache (MEASURED), DTW band (MEASURED). **Milestone-1 DONE (2026-06-13): all four P1 backlog items cleared — circular phase variance #1 (RESOLVED/MEASURED metric, DATA-GATED threshold), Welford n=0 guard #10 (RESOLVED/MEASURED), threshold magic-constants #9 & #13 (RESOLVED-PARTIAL/DATA-GATED — de-magicked + boundary-tested, values unchanged).** **Milestone-2 DONE (2026-06-13): bench-first P2 perf subset + missing boundary tests cleared — spectrogram per-subcarrier FFT re-plan #20 (MEASURED-HOT, 1.401.84×, bit-identical); attention/tomography/Kalman #5/#6/#7 (MEASURED-NULL — benched, not hot, left as-is); field_model eigendecompose #8 (MEASUREMENT-ONLY, BLAS un-buildable on this Windows host, number deferred to a BLAS box, NOT fabricated); fft_operator tolerance #14, phase-align convergence-cap #16, csi-ratio epsilon #19 (RESOLVED, tests added).** **Milestone-3 DONE (2026-06-13): the lumped §7.4 row #2145 P3 backlog cleared, and with it residual P3 items #2/#12/#17/#18 — 22 magic constants de-magicked into named EMPIRICAL-DEFAULT consts (each pinned == prior literal) + 6 boundary/characterization tests across 11 modules; ~4 doc-only; not-real findings (unreachable attractor_drift div0, non-existent gesture thresholds, proof-path features.rs) reported + skipped, no churn; no operating value changed; workspace 3,275/0, Python proof bit-exact `f8e76f21…`.** **§7.4 deferred backlog is now FULLY CLEARED across M0M3 — nothing silently dropped.**
---
+31 -2
View File
@@ -189,10 +189,37 @@ The gap review surfaced ~60 findings; this milestone scoped to the provable inte
- **ONNX read-lock concurrency win** — blocked on an `ort` release exposing `&self` `Session::run` (§4.2); harness already committed.
- **native-conv naive-loop** perf rewrite (§4).
- **`rf_encoder.rs` `assert_eq!`-on-checkpoint** and any other **tch-gated** panic-on-input sites — require a libtorch host to compile/verify (`model.rs` `amp_fc1` unbounded alloc is *indirectly* guarded by the new `config.validate()` upper bounds, but a direct guard + test is deferred).
- **`sensing-server/training_api.rs` PCK** — unify the live-server torso-height PCK with `pck_canonical` (crosses the service + tch boundary).
- **`test_metrics.rs` reference kernels** — the integration test's local `compute_pck`/`compute_oks` are independent reference impls (not production); fold them onto the canonical definition.
- ~~**`sensing-server/training_api.rs` PCK**~~**RESOLVED in Milestone-1b (see §8.1, Goal C).** Relabelled (not unified) — and the audit found the *real* live divergence is in `trainer.rs`, not the orphaned `training_api.rs`.
- ~~**`test_metrics.rs` reference kernels**~~**RESOLVED in Milestone-1b (see §8.1, Goal B).** Canonical core hoisted to an un-gated module; the integration test now validates the production functions against hand-computed fixtures + a differential cross-check.
- **`metrics.rs` `compute_pck_v2`/`compute_oks_v2`/`MetricsAccumulatorV2`/`evaluate_dataset_v2`/`hungarian_assignment_v2`** — confirmed to have **zero external callers** (only `evaluate_dataset_v2``MetricsAccumulatorV2` internally). They are already `#[deprecated]` and route through canonical, so they are not a *divergent-definition* risk, only dead weight. Left in place this pass (public API in a tch-gated module; deleting needs a deprecation-cycle + tch host to verify) — flagged here for a future cleanup, NOT deleted silently.
- **`sensing-server/trainer.rs` `pck_at_threshold` (raw) + `oks_map(area=1.0)` and the `training_bench.rs` raw kernel** — relabelled in Milestone-1b (§8.1); true unification onto `pck_canonical`/`oks_canonical` (needs a torso scale + the train crate as a sensing-server dep) remains deferred.
- The remaining ~40 lower-severity review findings (style, micro-opt, doc) from the NN/training gap review.
### 8.1 Milestone-1b — metric-definition unification (the §8 metric subset) — RESOLVED
This milestone closed the two metric-integrity items above. The work is pinned by tests, graded MEASURED, and surfaced findings the §1 table missed.
**The complete, honest PCK / OKS audit map (every definition in `v2/`):**
| Definition (file:line) | Normalization basis | Threshold convention | Status |
|---|---|---|---|
| `metrics_core.rs` `pck_canonical` (was `metrics.rs`) | **hip↔hip torso WIDTH** (bbox-diag fallback), `[0,1]` coords | `k·torso` | **CANONICAL** |
| `metrics_core.rs` `oks_canonical` | `s=sqrt(area)` from GT pose extent | COCO kernel | **CANONICAL** |
| `metrics.rs` `compute_pck` / `compute_per_joint_pck` / `compute_oks` | — (thin wrappers) | — | route to canonical |
| `metrics.rs` `aggregate_metrics` / `MetricsAccumulator` | — | — | route to canonical |
| `metrics.rs` `compute_pck_v2` / `compute_oks_v2` / `MetricsAccumulatorV2` | hip↔hip (folded) | — | **legacy-redundant, deprecated, NO callers** — route to canonical |
| `tests/test_metrics.rs` local `compute_pck`/`compute_oks` (removed) | raw-threshold reimpl | raw | **was independent reimpl** → now validate canonical + 1 differential kernel |
| `benches/training_bench.rs` `compute_pck` | raw-threshold | raw | distinct-by-design (bench-only), annotated DO-NOT-REPORT |
| `sensing-server/training_api.rs` `compute_pck` | **torso-HEIGHT** (nose→hip), **pixel-space** | `ratio·torso_h`, 50px floor | **distinct-by-design** — and **ORPHAN file (not `mod`-declared, does not compile)**; relabelled `compute_pck_torso_height` |
| `sensing-server/trainer.rs` `pck_at_threshold` | **RAW (no normalization)** | raw `thr` | **distinct, LIVE** (drives `best_pck`); **MISSED by §1 table**; relabelled `pck_raw@0.2` |
| `sensing-server/trainer.rs` `oks_map``oks_single(area=1.0)` | `area=1.0` | COCO kernel | **fake-Gold, LIVE** (drives `best_oks`); **MISSED by §1 table**; relabelled `oks_map(area=1.0 proxy)` |
**Findings the §1 seven-definition table under-counted (honest correction):** the live sensing-server claim surface is `trainer.rs` (in `lib.rs`), **not** the named `training_api.rs` — which is an **orphan file, never `mod`-declared, so it does not compile into the crate**. The live `best_pck` is a **raw, unnormalized** PCK and the live `best_oks` still uses the **`area=1.0` fake-Gold** path ADR-155 §2.1 reported as closed elsewhere. So the true metric landscape is **messier than §1 documented**: ≥3 PCK and ≥1 OKS live in `sensing-server`, two of them on the inflating side, and the file the ADR named for the fix was dead code. This is a finding, not a failure — recorded here rather than hidden.
**Goal B (`test_metrics.rs`) — RESOLVED, MEASURED.** The canonical core (`pck_canonical`/`oks_canonical`/`canonical_torso_size`/sigmas/`bounding_box_diagonal`) was hoisted into a new **un-gated** `metrics_core` module (the full `metrics` module is `tch-backend`-gated, so the canonical definition was previously unreachable from the workspace test gate; `metrics` now re-exports it → still ONE implementation). `tests/test_metrics.rs` now asserts the **production** functions against hand-computed fixtures — `canonical_pck_matches_hand_computed_fixture` (3/4 correct ⇒ 0.75, hand-derived), zero-visible⇒0.0, hip↔hip normalizer pin, OKS perfect⇒1.0, the fake-Gold pin — plus `test_kernel_agrees_with_canonical`, a differential test where an independent raw-threshold reference must AGREE with canonical in the torso=1.0 regime. (10→12 tests.)
**Goal C (`training_api.rs` PCK) — RESOLVED by RELABEL, MEASURED.** Torso-height is **load-bearing** (pixel-space, vertical nose→hip scale, `[17×3]` layout, no `ndarray`/train dep), so unifying would silently change the live numbers' meaning — exactly what to avoid. Resolution: relabel everywhere the metric surfaces so it is never read as canonical, in both the named `training_api.rs` (now `compute_pck_torso_height`, struct/JSON-field docs, `pck_torso_h@0.2` logs) **and** — the real fix — the LIVE `trainer.rs` path (`pck_at_threshold` documented raw-unnormalized; `oks_map` `area=1.0` flagged fake-Gold; `main.rs` prints `pck_raw@0.2` / `oks_map(area=1.0 proxy)`). No wire-format field or `pub`-fn renames (no silent API break). Pinned by `torso_pck_is_labelled_distinctly_from_canonical` (training_api) and `pck_at_threshold_is_raw_unnormalized_not_canonical` (the live kernel). True unification (route the live server through `pck_canonical`/`oks_canonical`) remains a deferred §8 item — it needs a torso scale on the live data and the train crate as a dep.
---
## 9. Consequences
@@ -200,3 +227,5 @@ The gap review surfaced ~60 findings; this milestone scoped to the provable inte
**Positive.** The training/metrics subsystem can now substantiate a clean accuracy claim: one documented metric used everywhere, a leak-free split, an honest TTA path, a proof that fails on noise and refuses to bless an unbaselined run, and two of the most claim-inflating bugs (false-perfect PCK, fake-Gold OKS) closed and pinned by regression tests. The unmeasured/unprovable parts are **disclosed**, not hidden.
**Negative / honest.** The reportable-metric tch-gated code cannot be compiled on the dev host (libtorch absent), so its validation rests on routing through the workspace-tested canonical functions plus review; the Rust deterministic proof is in SKIP until a baseline is committed on a tch host; the ONNX concurrency win is blocked upstream; and ~45 findings are deferred. None of these is presented as done.
**Picture changed by Milestone-1b (§8.1) — corrected, not hidden.** The §1 "seven divergent metrics" count was an **under-count**. The metric-unification audit (Goal A) found the live `wifi-densepose-sensing-server` carries additional, divergent definitions the §1 table omitted: a **raw, unnormalized** `pck_at_threshold` and an **`area=1.0` fake-Gold** `oks_map` in `trainer.rs` — and these, not the orphaned `training_api.rs` the backlog named, are what actually drive the live-reported `best_pck`/`best_oks`. Milestone-1b **relabelled** them (load-bearing math on different data; relabel beats false unification) and pinned the divergence with tests; full unification onto the canonical definition stays deferred. So the canonical *train/nn* metric is unified and test-validated end-to-end, but the *sensing-server* still computes (now clearly-labelled, non-canonical) progress proxies — disclosed here as the honest current state.
+114 -2
View File
@@ -103,7 +103,7 @@ The double-clone elimination is also correctness-neutral: all 100 `viewpoint`/`m
| # | Candidate | What | Grade | Verdict |
|---|-----------|------|-------|---------|
| **1** | **SymphonyQG** (SIGMOD 2025, public code) | Unified quantization + graph ANN; source reports **3.517× QPS over HNSW at equal recall**, pure-CPU / edge-portable. | **CLAIMED** (author-measured; **not reproduced on our hardware** — reproduction is future work) | **Lead beyond-SOTA candidate for the ruvector ANN path.** Propose as ACCEPTED-future; cite honestly as "claimed by source, reproduction pending." Best fit because the ruvector retrieval path (AETHER re-ID, sketch prefilter) is exactly an ANN problem and SymphonyQG is CPU/edge-portable like our deployment. |
| **2** | **Multi-bit / Extended RaBitQ** | Extends our existing **1-bit** `sketch.rs` (ADR-084) to multiple bits per dimension — precisely the "Pass 2" our own `sketch.rs` doc deferred (1-bit sign quantization ships first; rotation/more-bits "later if benchmark-measured top-K coverage drops below the ADR-084 90% threshold"). | **CLAIMED** (RaBitQ family well-characterised; our 1-bit baseline is MEASURED in `sketch_bench`) | **Accepted near-term.** Concrete, in-scope, incremental — extends a MEASURED capability rather than importing a new system. #2 priority. |
| **2** | **Multi-bit / Extended RaBitQ + unbiased estimator** | Extends our existing **1-bit** `sketch.rs` (ADR-084): Pass-2 rotation, multi-bit Pass-3, and the **real RaBitQ unbiased distance estimator** (Gao & Long SIGMOD 2024) reranking the candidate set from the 1-bit code + 8 B/vec side info (§11). | **MEASURED-on-our-hardware** (was CLAIMED) — rotation (§10), multi-bit (§10), and the estimator (§11) all implemented + benchmarked. Rotation lifts strict-K 36%→46%; multi-bit (≤4-bit) reaches 74% strict; **the estimator reaches 49.71% strict (cosine rerank), still short of 90%.** All clear 90% only with over-fetch (estimator improves the factor: 95% at candidate_k=24 vs sign 91.6%). | **DONE — RESOLVED-PARTIAL / NEGATIVE.** Rotation (§10) + estimator (§11) built and MEASURED. The honest negative (no strict-bar 90% from rotation, ≤4-bit, **or the unbiased estimator**) is recorded, not hidden. Over-fetch + Pass-2 is the path that meets the bar (ADR-084's "candidate set" pattern); the estimator lowers the over-fetch factor needed. |
| **3** | **GraphPose-Fi-style learned antenna-attention + ChebGConv fusion head** | Would replace the current **untrained identity-projection + mean-pool** "attention" (the `CrossViewpointAttention` default is `ProjectionWeights::identity` — not a *learned* attention) with a learned graph fusion head. | **DATA-GATED** (per ADR-152 measurement (b): architecture is **NOT** the current bottleneck — **data is**) | **ACCEPTED-future, data-gated. Do NOT build now.** ADR-152's measured lesson was that swapping architecture without more/better paired data does not move PCK. Building a learned fusion head before the data exists would repeat the mistake ADR-155 §5 also flagged for GraphPose-Fi. |
| — | **Cramér-Rao / sensor-placement** (`geometry.rs` CRB) | Investigated for a 2026 advance beating the textbook Fisher-information CRB already implemented. | **Investigated — NO ACTION** | **Cleared honestly.** No 2026 method beats the closed-form Fisher-information CRB for this 2-D bearing problem; our implementation is already correct SOTA. (Recording a negative result is a deliberate anti-slop signal.) The only CRB change this milestone is the §2.3 *GDOP* honesty fix, which is a labelling/quantity correction, not an algorithmic one. |
@@ -139,7 +139,7 @@ The double-clone elimination is also correctness-neutral: all 100 `viewpoint`/`m
The review surfaced more than this milestone scoped. Tracked here for a future ADR-156 milestone:
- **SymphonyQG reproduction** (§5 #1) — reproduce the 3.517× QPS-over-HNSW claim on our hardware before integrating into the ruvector ANN path. Currently CLAIMED-only.
- **Multi-bit / Extended RaBitQ** (§5 #2) — implement the `sketch.rs` "Pass 2" (more bits per dimension and/or the randomized rotation) and re-measure top-K coverage against the ADR-084 ≥90% acceptance bar in `sketch_bench`.
- **Multi-bit / Extended RaBitQ** (§5 #2) — **RESOLVED-PARTIAL** (see §10). Pass-2 randomized rotation (FHT + seeded ±1 sign flips, `src/rotation.rs`) and a multi-bit Pass-3 experiment landed and were MEASURED against the ADR-084 ≥90% bar. **Honest result: rotation helps (+10pp at the strict bar) and Pass-2 reaches 90% with ~3× over-fetch, but NEITHER rotation nor multi-bit (up to 4-bit) clears the strict candidate_k==K 90% bar on the tested anisotropic distribution.** The original `1-bit sign quantization ships first; rotation/more-bits later if benchmark-measured top-K coverage drops below 90%` deferral is therefore retired: the rotation is built, the bar is characterised, and the residual gap is documented rather than deferred.
- **Learned cross-viewpoint fusion head** (§5 #3, GraphPose-Fi-style) — **data-gated**: blocked on the paired multi-room data ADR-152 measurement (b) identified as the real bottleneck; do not build the architecture first.
- **`CrossViewpointAttention` learned projections** — the default `ProjectionWeights::identity` + mean-pool is honest but unlearned; wiring real learned Q/K/V projections is part of the data-gated item above (no learned weights ⇒ the "attention" is currently a geometric-bias-weighted average, which the code/docs should keep stating plainly).
- **`coherence.rs` / `fusion.rs` micro-opts and the remaining lower-severity review findings** (style, doc, further hot-path tuning) from the fusion gap review.
@@ -151,3 +151,115 @@ The review surfaced more than this milestone scoped. Tracked here for a future A
**Positive.** The fusion path now: uses one canonical wrapped angular-distance helper; reports a **real** dimensionless GDOP instead of a mislabeled RMSE; cannot be panicked by crafted multistatic indices or a zero-bin spectrogram (DoS closed); and does one embedding clone per viewpoint instead of two (measured). Every fix is pinned by a test that fails on the old code, and the ANN/fusion SOTA landscape is graded so the near-term (multi-bit RaBitQ) and the data-gated (learned fusion) are not confused.
**Negative / honest.** The headline angular-wrap fix is a **numeric no-op** under the current cos kernel — we land it for contract/maintainability, not because it changes an output, and we say so. The two strongest external candidates (SymphonyQG, learned fusion) are **not built here** — one is CLAIMED-pending-reproduction, the other is data-gated by a prior measurement. The perf win is a **local hot-path** improvement, modest in the end-to-end pipeline (attention dominates). None of these is presented as more than it is.
---
## 10. RaBitQ Pass-2 / multi-bit — IMPLEMENTED & MEASURED (§8 backlog item #2)
Milestone-1 of the §8 backlog. Status: **RESOLVED-PARTIAL** — built, measured, honest negative on the strict bar.
### 10.1 What landed
- **`crates/wifi-densepose-ruvector/src/rotation.rs`** (new) — `Rotation`, a deterministic randomized orthogonal rotation `R = H·D`: a **Fast Hadamard Transform** (`O(d log d)`, in-place butterfly, `1/√m` normalized so it is norm-preserving) composed with a diagonal of **seeded ±1 sign flips** (SplitMix64 from a stored `u64` seed). Chosen over a dense `d×d` matrix because that is `O(d²)` memory/time and infeasible at the 65,535-d the wire format provisions for; FHT is the standard fast-orthogonal (randomized-Hadamard / fast-JL) construction. Non-power-of-two `d` zero-pads to `next_pow2(d)` and reads back the first `d` coords.
- **`sketch.rs`** — additive Pass-2 API: `Sketch::from_embedding_rotated`, `SketchBank::with_rotation` + `insert_embedding` / `topk_embedding` / `novelty_embedding`. **Pass 1 (`from_embedding`) is byte-for-byte unchanged**; a Pass-2 sketch has identical `embedding_dim` / packed-byte length / wire shape, so `WireSketch` and existing callers (`event_log.rs`, `signal/longitudinal.rs`) are untouched. Default behaviour preserved.
- **`coverage.rs`** (new) — single-source-of-truth top-K coverage harness on a deterministic **anisotropic planted-cluster** fixture (cosine ground truth, the metric a sign sketch approximates). Backs both the `pass2_coverage_report` unit test and the `sketch_bench` coverage table.
- **Multi-bit Pass-3 experiment** — `coverage::measure_multibit`: rotate, then `b`-bit uniform scalar-quantize each coord, rank by L1 over codes. Measures the bit/coverage tradeoff.
### 10.2 Pre-existing bug found and fixed (disclosed)
Building the coverage harness surfaced a **pre-existing correctness bug in `SketchBank::topk`** (shipped in ADR-084): the `n > k` heap path used `BinaryHeap<Reverse<(dist,id)>>` (a *min*-heap) but its comment/logic treated the peek as the max, so it evicted the *nearest* and returned the **k farthest** sketches as "nearest." The shipped unit tests only exercised the `n ≤ k` fast path (≤ 3 entries), so it was never caught. Fixed to a plain max-heap. Pinned by **`topk_heap_path_returns_nearest`** (fails on the old heap when entries are inserted farthest-first) and **`tight_clusters_give_high_coverage_with_overfetch`** (measured **0.072** coverage on the old code — random — vs **>0.99** fixed). This is a real, measured behaviour fix, not a no-op.
### 10.3 MEASURED top-K coverage
Test machine: Windows 11, `cargo bench --release` / `cargo test`. Fixture: **dim=128, N=2048, K=8, 64 planted clusters, intra-cluster noise=0.35, 128 queries, master_seed=0xAD000084, rotation_seed=0x5EEDC0DE12345678**, ground-truth metric = cosine. Reproduce: `cargo test -p wifi-densepose-ruvector --no-default-features pass2_coverage_report -- --nocapture` or `cargo bench -p wifi-densepose-ruvector --bench sketch_bench -- pass2_coverage`.
**Coverage vs over-fetch (`coverage = |sketch_topK ∩ float_cosine_topK| / K`):**
| candidate_k | Pass-1 (1-bit, no rot) | Pass-2 (1-bit, rot) | vs 90% bar |
|---|---|---|---|
| **8 (= K, strict bar)** | **36.13%** | **46.39%** | both **BELOW** |
| 16 | 62.79% | 75.59% | below |
| 24 | 83.89% | **91.60%** | **Pass-2 clears** |
| 32 | 100.00% | 100.00% | clears |
| 64 | 100.00% | 100.00% | clears |
**Multi-bit Pass-3 at the strict bar (candidate_k = K = 8):**
| Variant | Coverage | Memory |
|---|---|---|
| Pass-1 (1-bit, no rot) | 36.13% | 16 B/vec |
| Pass-2 (1-bit, rot) | 46.39% | 16 B/vec |
| Pass-3 (rot, 2-bit) | 54.39% | 32 B/vec |
| Pass-3 (rot, 3-bit) | 66.70% | 48 B/vec |
| Pass-3 (rot, 4-bit) | 74.22% | 64 B/vec |
### 10.4 Honest verdict
- **Rotation consistently helps** — +10.3 pp at the strict bar (36.13%→46.39%) and a uniform lift at every over-fetch level. The FHT construction is verified norm-preserving and deterministic.
- **Neither rotation nor multi-bit (≤4-bit) clears the strict candidate_k==K 90% bar** on this anisotropic distribution. 1-bit sign quantization simply cannot resolve 8-of-2048 from sign bits alone; even 4× memory (4-bit) reaches only 74%.
- **Pass-2 reaches the 90% bar at candidate_k=24 (~3× over-fetch)** — i.e. fetch ≥24 sketch candidates, refine to K with full float. This is exactly the "candidate set, then full refinement" deployment pattern ADR-084 specifies, so the bar is met *in the deployment the sensor is designed for*, just not at strict K=K.
- **This is a measured, partial win, reported as such.** No benchmark was tuned to manufacture a pass. The strict-bar gap (and the multi-bit tradeoff that doesn't close it) is documented rather than spun.
### 10.5 Deferred sub-items (graded, not dropped)
- **Strict-bar 90% from a richer code** — neither rotation nor uniform multi-bit closes it here. A learned/asymmetric quantizer or the full RaBitQ residual-distance estimator (not just a uniform scalar code) might. **RESOLVED-NEGATIVE (§11): the estimator is now built and MEASURED — it lifts strict-K 46.39%→49.71% but does NOT clear the 90% strict bar.** The residual strict-bar gap is a published negative, not a deferral.
- **Distribution sensitivity** — the result is for one synthetic anisotropic distribution; on real AETHER traces the strict-bar number may differ. Re-measuring on recorded embeddings is deferred to the ADR-084 post-merge soak.
- **Promoting a `MultiBitSketch` type** — the multi-bit code lives in the measurement harness, not as a shipped sketch type. Building the production type is gated on a use site actually needing strict-K (vs over-fetch), which the measurement says is not required today.
---
## 11. RaBitQ unbiased distance estimator — IMPLEMENTED & MEASURED (Milestone-2, §8 backlog item #2 / §10.5 strict-bar item)
Milestone-2 of the §8 backlog. Status: **RESOLVED-NEGATIVE** — the estimator is built, measured, and lifts strict-K coverage, but the honest result is that it does **not** clear the ADR-084 ≥90% strict-K bar on this distribution. The negative is reported as such, exactly like the Pass-2 rotation result.
### 11.1 What landed
- **`crates/wifi-densepose-ruvector/src/estimator.rs`** (new) — the real Gao & Long (SIGMOD 2024) contribution: an **unbiased estimator of the inner product / squared distance** recovered from the 1-bit code plus per-vector side info, on top of the Pass-2 rotation. Pass-1/Pass-2 ranked candidates by raw Hamming over sign bits — a coarse proxy. This module reranks by the unbiased estimate.
- `EstimatorSketch` — Pass-2 sign code (over the **padded** FHT length `D = next_pow2(dim)`, the frame `x̄` is unit in) **plus** the side info.
- `SideInfo` = `{ residual_norm: f32, x_dot_o: f32 }` = **8 bytes/vector** (2× f32).
- `EstimatorQuery` — query rotated once, reused across all candidates.
- `DistanceEstimator``estimate_inner_product`, `estimate_sq_distance`, `ranking_key` (euclidean), `cosine_ranking_key` (the correct key vs a cosine ground truth — needs only the code + `x_dot_o`).
- `EstimatorBank``topk_estimated` (euclidean) / `topk_estimated_cosine`; optional `with_centroid` (the paper's centroid path).
- **`coverage.rs`** — `measure_estimator` (cosine rerank) + `measure_estimator_euclidean`, on the **bit-identical** fixture / cluster centres / query stream / cosine ground truth as `measure_pass1`/`measure_pass2`. Single source of truth for the §11.3 table; backs both `estimator_coverage_report` and the `sketch_bench` coverage table.
- **Additive + backward-compatible.** New types only; Pass-1 `Sketch` / Pass-2 `SketchBank` / `WireSketch` wire format are untouched. All external callers (`event_log.rs`, `signal/longitudinal.rs`, `sensing-server`) use Pass-1 `from_embedding` and are unaffected.
### 11.2 The estimator formula (and the zero-centroid simplification, stated honestly)
Let `P` be the Pass-2 orthogonal rotation (`R = H·D`), `D = next_pow2(dim)`. For data `o_raw`, query `q_raw`, centroid `c`:
1. **Centroid — SIMPLIFIED to zero/global `c = 0`.** The paper centres on a per-cluster centroid (`o_r = o_raw c`); we use `c = 0` (`o_r = o_raw`), because the current sketch path has no IVF/k-means cluster structure. This costs accuracy when the data is far off-origin. **We document it, do not hide it,** and built the paper-faithful centroid path (`from_embedding_centred` / `EstimatorBank::with_centroid`) so the simplification is a measured choice, not an assumption. (We do **not** report a centroid coverage number against the *cosine* ground truth: centroid-subtraction changes the metric — cosine-of-residual ≠ cosine-of-raw — so a centroid number vs raw-cosine truth would be a metric mismatch, itself dishonest. Zero-centroid is the correct match for this raw-cosine harness.)
2. **Unit residual + 1-bit code.** `o = o_r/‖o_r‖`, `o' = P·o`, code `x̄_i = sign(o'_i)·(1/√D)` — a unit vector at the nearest hypercube corner.
3. **Side info:** `residual_norm = ‖o_r‖` and `x_dot_o = ⟨x̄, o'⟩ ∈ (0,1]` (the paper's `⟨x̄, o⟩`).
4. **Unbiased estimator** (paper Eq.): `⟨o', q'⟩ ≈ ⟨x̄, q'⟩ / ⟨x̄, o'⟩ = ⟨x̄, q'⟩ / x_dot_o`. The random rotation makes the code's quantization error orthogonal **in expectation** to `q'`, so the rescale is unbiased (paper's `O(1/√D)` bound). Per candidate: one length-`D` signed sum (`x̄ ∈ {±1/√D}`), as cheap as Hamming + a multiply.
5. **Distance / cosine.** `⟨o_r,q_r⟩ = ‖o_r‖·(⟨x̄,q'⟩/x_dot_o)`; `‖q_ro_r‖² = ‖q_r‖²+‖o_r‖²−2⟨o_r,q_r⟩`. For a **cosine** ground truth (AETHER / this harness), rank by `−⟨o,q_r⟩ = (⟨x̄,q'⟩/x_dot_o)` (needs only the code + `x_dot_o`).
**Unbiasedness is pinned** (`estimator_unbiased_on_fixture`): averaging the estimate of `⟨o_r,q_r⟩` over 4000 random rotation seeds converges to the true inner product within ~6% of the `‖o‖‖q‖` envelope — a biased estimator (or sign-only proxy) would be systematically off.
### 11.3 MEASURED strict-K coverage
Same fixture/seeds as §10 (dim=128, N=2048, K=8, 64 clusters, noise=0.35, 128 queries, `master_seed=0xAD000084`, `rotation_seed=0x5EEDC0DE12345678`), cosine ground truth. Reproduce: `cargo test -p wifi-densepose-ruvector --no-default-features estimator_coverage_report -- --nocapture` or `cargo bench -p wifi-densepose-ruvector --bench sketch_bench -- pass2_coverage`.
| candidate_k | Pass-1 (sign) | Pass-2 (sign) | **Pass-2 + estimator (cosine)** | Pass-2 + estimator (euclid) | vs 90% bar |
|---|---|---|---|---|---|
| **8 (= K, strict bar)** | 36.13% | 46.39% | **49.71%** | 49.02% | **all BELOW** |
| 16 | 62.79% | 75.59% | 79.20% | 77.93% | below |
| 24 | 83.89% | 91.60% | **95.12%** | 93.65% | estimator clears |
| 32 | 100.00% | 100.00% | 100.00% | 100.00% | clears |
| 64 | 100.00% | 100.00% | 100.00% | 100.00% | clears |
Side-info memory overhead: **8 bytes/vector** (2× f32) on top of the 16 B/vec 1-bit sketch.
### 11.4 Honest verdict
- **The estimator helps, and the cosine key beats the euclidean key** (49.71% vs 49.02% at strict-K; cosine is the apples-to-apples match for the cosine ground truth — both it and sign-Hamming are angular). The unbiased rescale is a real, consistent lift at every over-fetch level (e.g. 24: 91.60%→95.12%).
- **It does NOT clear the strict candidate_k==K 90% bar.** Strict-K goes 36.13% (Pass-1) → 46.39% (Pass-2-sign) → **49.71% (Pass-2 + estimator)** — a **+3.3 pp** improvement over sign-only, **still ~40 pp short of 90%**. This is a **published negative**, the same class of honest result as the Pass-2 rotation (§10).
- **Why the strict-K gain is modest:** the binding constraint at strict K is the **1-bit code's information ceiling** (resolving 8-of-2048 from a single sign bit per coordinate), not the *estimator's variance* — the estimator sharpens the ranking but cannot add information the 1-bit code never captured. The estimator's larger wins are at over-fetch, where there is room to re-rank a wider candidate pool.
- **The bar is still met the way ADR-084 deploys the sensor:** at candidate_k=24 (~3× over-fetch) the estimator reaches **95.12%** (vs Pass-2-sign 91.60%) — the "candidate set, then full refinement" pattern. The estimator **improves the over-fetch factor needed** but does not eliminate it.
- **No benchmark was tuned to manufacture a pass.** The strict-bar gap is documented, not spun.
### 11.5 Pinning tests
- `estimator::estimator_is_deterministic` — fixed seed ⇒ identical estimate + identical bank top-K.
- `estimator::estimator_unbiased_on_fixture` — Monte-Carlo mean over 4000 seeds converges to the true inner product within tolerance (the unbiasedness claim).
- `coverage::estimator_rerank_not_worse_than_sign` — estimator-reranked coverage ≥ sign-only Pass-2 on a fixed fixture (must not regress).
- Plus: `estimator_self_distance_is_small`, `x_dot_o_in_unit_range`, `zero_input_does_not_panic`, `bank_self_query_ranks_self_first`, `centroid_path_self_query_ranks_self_first`, `centroid_zero_matches_default`, `estimator_coverage_is_deterministic`.
@@ -85,9 +85,11 @@ A new criterion bench (`harness = false`, registered in `Cargo.toml`) drives eac
`OpportunisticCsiBridge::ingest` built `CsiReportPayload { n_subcarriers: self.amp_accum.len() as u16, … }`. The `as u16` would silently wrap a count above 65 535. **This is unreachable in practice**: `ingest` gates `frame.subcarrier_count() > MAX_REPORT_SUBCARRIERS` (484) at entry and returns `None`, and `report.validate()` independently rejects oversized counts downstream. We replaced the cast with `u16::try_from(self.amp_accum.len()).ok()?` (drop-instead-of-truncate) so the construction is **correct-by-construction** rather than relying on the upstream gate. We disclose this as **defense-in-depth on an unreachable path, not a live bug** — no behavior change, no new test (the gate already prevents the input that would exercise it).
### 2.6 §B4 — constant-time HMAC tag compare: **DEFERRED, not landed** (disclosed)
### 2.6 §B4 — constant-time HMAC tag compare: **RESOLVED — no-dependency hand-rolled constant-time compare (Milestone-1)**
`secure_tdm.rs:284` compares the 8-byte HMAC tag with `self.hmac_tag == expected` (data-dependent, non-constant-time). The research authorized adding `subtle::ConstantTimeEq` **only if `subtle` were already a direct dependency** — it is not (only transitive, via a crypto crate). Per that guidance, and because this is an **8-byte tag on a LAN multistatic sync beacon** (not a remote attacker-controlled timing-oracle surface), we **do not add a direct dependency** for it. Tracked in §8 as a deferred item, not silently dropped.
`secure_tdm.rs` compared the 8-byte HMAC tag with `self.hmac_tag == expected` (data-dependent, non-constant-time: short-circuits on the first differing byte, leaking through verification latency how many leading bytes a forged tag matched — a byte-by-byte tag-recovery oracle). Milestone-3 deferred this **only** to avoid adding the `subtle` crate as a direct dependency. Milestone-1 resolves it **without any dependency**: a hand-rolled `constant_time_tag_eq(a, b)` that XOR-accumulates every byte difference into a single `u8` with **no early exit**, then compares the accumulator to zero exactly once. `#[inline(never)]` + `core::hint::black_box(diff)` stop the optimizer from reintroducing a short-circuit or lowering the loop into a non-constant-time `memcmp`; a length mismatch returns `false` without inspecting contents. The former `==` verify site now calls this helper.
**Test (fails on old code, the hard gate):** `tag_compare_is_constant_time_shape` — asserts correct accept/reject for equal, first-byte-differ, last-byte-differ, all-byte-differ, and length-mismatch tags, plus an end-to-end `verify()` last-byte-only tamper. Verified to **bite**: introducing a classic constant-time bug (loop `take(LEN-1)`, skipping the last byte) makes it fail on `last-byte-differ must reject`. A coarse timing-invariance smoke check `tag_compare_timing_invariance_smoke` exists but is `#[ignore]`d (noisy host — not a CI gate). **Grade MEASURED** (constant-time *construction*; micro-timing on a noisy host is only a smoke check, disclosed honestly). Tracked RESOLVED in §8.
---
@@ -143,7 +145,7 @@ Grades: **MEASURED** (source measured it, ideally public method/code), **CLAIMED
| 1 | **CSI vital signs (HR/BR)** | Deep-CSI vital-sign models report **MAE ~23 BPM** vs our classical IIR-bandpass + autocorrelation/zero-crossing. | **DATA-GATED + CLAIMED** | **NO ACTION on method.** A deep model needs **paired PPG/ECG ground truth** we do not have, and no public ESP32 artifact reproduces the cited MAE on commodity CSI. Our classical method is the honest commodity baseline; the real wins this milestone are the A1/A3 robustness fixes, not a new model. |
| 2 | **802.11bf-2025 conformance** | Adopt a conformance test-vector suite for the `ieee80211bf/` forward-compat model. | **CLAIMED (not public)** | **NO ACTION.** No commodity silicon ships a conformant 802.11bf interface as of 2026, and the conformance suites are **WBA / Wi-Fi Alliance pre-certification** material, **not public**. Our model's "no OTA encoding until silicon exists" posture (ADR-153) is the correct one. Tracked in §8: *add SBP conformance vectors when the WFA publishes a test plan* — we will **not invent vectors**. |
| 3 | **Per-room calibration (ADR-151)** | Bank-of-specialists + drift-veto vs a 2026 calibration SOTA. | **CLAIMED on numbers, DATA-GATED on a head-to-head** | **NO ACTION on architecture.** The bank-of-specialists + drift-veto design is SOTA-shaped, but we have **no head-to-head PCK** against a published method (no paired multi-room data). The geometry-conditioned LoRA head is **built-but-unconsumed** and data-gated → **ACCEPTED-FUTURE** (§8), not built now. |
| 4 | **Multi-BSSID throughput (wifiscan)** | The module docs assert a native `wlanapi.dll` FFI 1020 Hz path; the current `WlanApiScanner` wraps `netsh` (~2 Hz). | **CLAIMED-unmeasured** | **NO ACTION + corrected expectation.** The native FFI fast path is **asserted but NOT implemented** — the live scanner is the ~2 Hz netsh shim. The "10×" is unmeasured. → **ACCEPTED-FUTURE** (§8). **We explicitly do NOT claim a speedup that does not exist.** |
| 4 | **Multi-BSSID throughput (wifiscan)** | The module docs assert a native `wlanapi.dll` FFI 1020 Hz path; the current `WlanApiScanner` wraps `netsh` (~2 Hz). | **MEASURED (Milestone-1)** | **IMPLEMENTED + MEASURED — real positive win.** Status corrected: the native FFI is **fully implemented and wired live** (`wlanapi_native::scan_native` calls `WlanOpenHandle`/`WlanEnumInterfaces`/`WlanGetNetworkBssList`/`WlanFreeMemory`/`WlanCloseHandle`; `WlanApiScanner::scan_instrumented` runs it native-first with a netsh fallback). Milestone-1 **measured both paths on this box** (Intel Wi-Fi 7 BE201 320MHz, 2026-06-13) over an identical 10 s wall-clock window via a new `benchmark_backend`: **native 21.42 Hz vs netsh 3.84 Hz = 5.57× MEASURED** (mean 5.0 BSSIDs/scan each; native-only run 18.0 Hz). Native genuinely beats netsh — a real measured multiple, **not** a fabricated 10×; the achieved 21.4 Hz lands in the asserted >2 Hz regime though below the asserted 1020 Hz upper bound. 50 back-to-back native scans = 50/50 OK, no handle leak. → §8 MEASURED. |
---
@@ -176,10 +178,10 @@ Grades: **MEASURED** (source measured it, ideally public method/code), **CLAIMED
## 8. Deferred backlog (NOT silently dropped)
- **§B4 constant-time HMAC compare** — `secure_tdm.rs:284` uses `==` on the 8-byte tag. Add `subtle::ConstantTimeEq` **if** `subtle` becomes a direct dependency for another reason; not worth a new dependency for an 8-byte LAN sync-beacon tag (out of the current threat model). Deferred, not dropped.
- **§B4 constant-time HMAC compare** — **RESOLVED (Milestone-1).** Replaced the short-circuiting `==` on the 8-byte tag with a hand-rolled branch-free `constant_time_tag_eq` (XOR-accumulate, no early exit, `#[inline(never)]` + `black_box`). **No new dependency** — the `subtle` crate was the only reason this was deferred, and a fixed 8-byte compare needs none. Pinned by `tag_compare_is_constant_time_shape` (proven to fail on a last-byte-skipping bug). Grade MEASURED (constant-time construction). See §2.6.
- **802.11bf SBP conformance vectors** (§5 #2) — add real conformance test vectors to the `ieee80211bf/` model **when the Wi-Fi Alliance / WBA publishes a public test plan**. Do not invent vectors before then.
- **Geometry-conditioned LoRA calibration head** (§5 #3) — built-but-unconsumed and **data-gated** on paired multi-room PCK data (ADR-152 measurement (b): data, not architecture, is the bottleneck). ACCEPTED-FUTURE.
- **Native `wlanapi.dll` FFI multi-BSSID fast path** (§5 #4) — the asserted 1020 Hz path is **not implemented**; the live scanner is the ~2 Hz netsh shim. Implement and **measure** the real throughput before claiming any multiple. ACCEPTED-FUTURE, CLAIMED-unmeasured until then.
- **Native `wlanapi.dll` FFI multi-BSSID fast path** (§5 #4) — **RESOLVED + MEASURED (Milestone-1).** The native FFI is implemented and wired live (native-first, netsh fallback). Measured on this box (Intel Wi-Fi 7 BE201 320MHz, 2026-06-13): **native 21.42 Hz vs netsh 3.84 Hz = 5.57×**, mean 5.0 BSSIDs/scan, 50/50 native scans with no handle leak. Real positive result — no fabricated 10×. See §5 #4. (Note: a prior sweep recorded 9.74 Hz on a different/older adapter; the per-adapter number varies, the ratio over netsh is the claim.)
- **Deep-CSI vital-sign model** (§5 #1) — DATA-GATED on paired PPG/ECG ground truth. No public ESP32 artifact reproduces the cited ~23 BPM MAE. Not on the near-term path.
---
Generated
+3 -3
View File
@@ -10835,7 +10835,7 @@ dependencies = [
[[package]]
name = "wifi-densepose-cli"
version = "0.3.0"
version = "0.3.1"
dependencies = [
"anyhow",
"assert_cmd",
@@ -11067,7 +11067,7 @@ dependencies = [
[[package]]
name = "wifi-densepose-sensing-server"
version = "0.3.2"
version = "0.3.3"
dependencies = [
"axum",
"chrono",
@@ -11101,7 +11101,7 @@ dependencies = [
[[package]]
name = "wifi-densepose-signal"
version = "0.3.3"
version = "0.3.4"
dependencies = [
"chrono",
"criterion",
@@ -47,6 +47,42 @@ type HmacSha256 = Hmac<Sha256>;
/// Size of the HMAC-SHA256 truncated tag (manual crypto mode).
const HMAC_TAG_SIZE: usize = 8;
/// Constant-time comparison of two fixed-size HMAC/auth tags.
///
/// ADR-157 §B4: the previous `self.hmac_tag == expected` short-circuits on the
/// first differing byte, leaking how many leading bytes matched through its
/// execution time. For an authentication tag that is a timing oracle: an
/// attacker who can submit forged beacons and measure verification latency can
/// recover the correct tag byte-by-byte (~256·N trials instead of 256^N).
///
/// This hand-rolled compare avoids adding the `subtle` crate (ADR-157 deferred
/// B4 only to dodge that dependency — a fixed 8-byte compare needs none). We
/// XOR-accumulate every byte difference into a single `u8` with **no early
/// exit**, so the work done is identical regardless of where (or whether) the
/// tags differ. The accumulator is non-zero iff any byte differed; we compare
/// it to zero exactly once at the end.
///
/// `#[inline(never)]` plus `black_box` on the accumulator stop the optimizer
/// from reintroducing a short-circuit or hoisting the loop into a `memcmp`
/// (which is itself non-constant-time). The two slices are required to be the
/// same length by construction (both `[u8; HMAC_TAG_SIZE]`); a length mismatch
/// returns `false` without inspecting contents.
#[inline(never)]
fn constant_time_tag_eq(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
let mut diff: u8 = 0;
for (x, y) in a.iter().zip(b.iter()) {
// Branch-free: accumulate the bitwise difference of every byte.
diff |= x ^ y;
}
// black_box prevents the compiler from proving `diff == 0` early and
// short-circuiting the loop above. The single equality check is the only
// data-dependent branch, and it is on the fully-accumulated value.
core::hint::black_box(diff) == 0
}
/// Size of the nonce field (manual crypto mode).
const NONCE_SIZE: usize = 4;
@@ -281,7 +317,10 @@ impl AuthenticatedBeacon {
msg[..16].copy_from_slice(&self.beacon.to_bytes());
msg[16..20].copy_from_slice(&self.nonce.to_le_bytes());
let expected = Self::compute_tag(&msg, key);
if self.hmac_tag == expected {
// ADR-157 §B4: constant-time compare — `==` on the tag would leak,
// via short-circuit timing, how many leading bytes an attacker's
// forged tag matched, enabling byte-by-byte tag recovery.
if constant_time_tag_eq(&self.hmac_tag, &expected) {
Ok(())
} else {
Err(SecureTdmError::BeaconAuthFailed)
@@ -752,6 +791,124 @@ mod tests {
));
}
// ---- ADR-157 §B4: constant-time tag compare ----
/// Functional pin proving the new constant-time helper is wired and correct
/// for the four tag-shape cases. This is the *hard gate* for §B4 — it fails
/// on the old `==` path only if the helper is removed/unwired, and it
/// guarantees accept/reject semantics are byte-exact. Grade: MEASURED
/// (constant-time *construction*); micro-timing on a noisy host is only a
/// smoke check (see `tag_compare_timing_invariance_smoke`, #[ignore]).
#[test]
fn tag_compare_is_constant_time_shape() {
let base = [0xA5u8; HMAC_TAG_SIZE];
// Equal tags accept.
assert!(constant_time_tag_eq(&base, &base), "equal tags must accept");
// First byte differs → reject.
let mut first = base;
first[0] ^= 0xFF;
assert!(
!constant_time_tag_eq(&base, &first),
"first-byte-differ must reject"
);
// Last byte differs → reject.
let mut last = base;
last[HMAC_TAG_SIZE - 1] ^= 0x01;
assert!(
!constant_time_tag_eq(&base, &last),
"last-byte-differ must reject"
);
// Every byte differs → reject.
let all = [0x5Au8; HMAC_TAG_SIZE]; // bitwise-inverse of 0xA5
assert!(
!constant_time_tag_eq(&base, &all),
"all-bytes-differ must reject"
);
// Length mismatch → reject without inspecting contents.
assert!(
!constant_time_tag_eq(&base, &base[..HMAC_TAG_SIZE - 1]),
"length mismatch must reject"
);
// End-to-end through verify(): a tag whose only difference is the
// *last* byte must still be rejected exactly like a first-byte diff.
let beacon = SyncBeacon {
cycle_id: 7,
cycle_period: Duration::from_millis(50),
drift_correction_us: 0,
generated_at: std::time::Instant::now(),
};
let key = DEFAULT_TEST_KEY;
let nonce = 1u32;
let mut msg = [0u8; 20];
msg[..16].copy_from_slice(&beacon.to_bytes());
msg[16..20].copy_from_slice(&nonce.to_le_bytes());
let mut tag = AuthenticatedBeacon::compute_tag(&msg, &key);
tag[HMAC_TAG_SIZE - 1] ^= 0x01; // tamper the LAST byte only
let auth = AuthenticatedBeacon {
beacon,
nonce,
hmac_tag: tag,
};
assert!(
matches!(auth.verify(&key), Err(SecureTdmError::BeaconAuthFailed)),
"last-byte tamper must fail verify()"
);
}
/// Coarse timing-invariance smoke check. #[ignore]d so it never flakes CI —
/// the host is noisy and a hard timing bound is unreliable. Run manually
/// with `cargo test -p wifi-densepose-hardware -- --ignored
/// tag_compare_timing_invariance_smoke --nocapture`. The assertion is a
/// deliberately *generous* ratio bound (4×): a short-circuit `==` would show
/// last-byte-differ ≫ first-byte-differ; the constant-time helper should not.
#[test]
#[ignore = "timing smoke check — noisy host, run manually with --ignored"]
fn tag_compare_timing_invariance_smoke() {
use std::time::Instant;
const ITERS: u32 = 2_000_000;
let base = [0xA5u8; HMAC_TAG_SIZE];
let mut first = base;
first[0] ^= 0xFF;
let mut last = base;
last[HMAC_TAG_SIZE - 1] ^= 0x01;
// Warm up.
for _ in 0..ITERS / 10 {
core::hint::black_box(constant_time_tag_eq(&base, &first));
}
let t0 = Instant::now();
let mut acc = false;
for _ in 0..ITERS {
acc ^= constant_time_tag_eq(&base, &first);
}
core::hint::black_box(acc);
let dt_first = t0.elapsed().as_nanos() as f64;
let t1 = Instant::now();
let mut acc2 = false;
for _ in 0..ITERS {
acc2 ^= constant_time_tag_eq(&base, &last);
}
core::hint::black_box(acc2);
let dt_last = t1.elapsed().as_nanos() as f64;
let ratio = dt_last.max(dt_first) / dt_last.min(dt_first).max(1.0);
println!(
"first-differ {dt_first:.0}ns, last-differ {dt_last:.0}ns, ratio {ratio:.3}"
);
assert!(
ratio < 4.0,
"timing ratio {ratio:.3} too large — possible short-circuit leak"
);
}
#[test]
fn test_auth_beacon_too_short() {
let result = AuthenticatedBeacon::from_bytes(&[0u8; 10]);
@@ -174,5 +174,76 @@ fn bench_topk(c: &mut Criterion) {
group.finish();
}
criterion_group!(benches, bench_compare_cost, bench_topk);
/// ADR-156 §8 RaBitQ Pass-2 coverage measurement.
///
/// Not a timing bench — it prints the **measured top-K coverage** (Pass-1 vs
/// Pass-2 rotation) on the deterministic anisotropic planted-cluster fixture
/// from `wifi_densepose_ruvector::coverage`, so `cargo bench` surfaces the
/// numbers quoted in ADR-156 §8 / ADR-084. The same harness backs the
/// `pass2_coverage_report` unit test (single source of truth). Each criterion
/// "benchmark" body computes the coverage once (cached) and the bench loop just
/// reads it back, so the criterion timing is meaningless here on purpose — the
/// value is the `println!` summary.
fn bench_pass2_coverage(c: &mut Criterion) {
use wifi_densepose_ruvector::coverage::{
measure_estimator, measure_estimator_euclidean, measure_pass1, measure_pass2,
CoverageParams,
};
let base = CoverageParams::aether_default(0xAD00_0084);
let rot_seed = 0x5EED_C0DE_1234_5678u64;
println!("\n=== ADR-156 §8/§11 RaBitQ coverage (anisotropic planted clusters) ===");
println!(
"dim={} N={} K={} clusters={} noise={} queries={} master_seed=0x{:X} rot_seed=0x{:X}",
base.dim, base.n, base.k, base.n_clusters, base.noise, base.n_queries, base.seed, rot_seed
);
println!("(coverage = |sketch_topK ∩ float_cosine_topK| / K, ADR-084 bar = 90%)");
println!("estimator side info = 8 B/vec (residual_norm + x_dot_o, 2x f32)");
println!(
" {:<12} {:>8} {:>8} {:>11} {:>11}",
"candidate_k", "P1-sign", "P2-sign", "Est-cosine", "Est-euclid"
);
for &cand in &[8usize, 16, 24, 32, 64] {
let p = CoverageParams {
candidate_k: cand,
..base
};
let p1 = measure_pass1(p).coverage;
let p2 = measure_pass2(p, rot_seed).coverage;
let est_cos = measure_estimator(p, rot_seed).coverage;
let est_euc = measure_estimator_euclidean(p, rot_seed).coverage;
let flag = if est_cos >= 0.90 { "EST≥90%" } else { "" };
let strict = if cand == base.k { " STRICT" } else { "" };
println!(
" {:<12} {:>7.2}% {:>7.2}% {:>10.2}% {:>10.2}% {flag}{strict}",
cand,
p1 * 100.0,
p2 * 100.0,
est_cos * 100.0,
est_euc * 100.0
);
}
println!("========================================================================\n");
// A minimal criterion group so `cargo bench` exercises the path under the
// harness (timing is not the point; the printed table above is).
let mut group = c.benchmark_group("pass2_coverage");
group.sample_size(10);
let p = CoverageParams {
n: 256,
n_queries: 16,
n_clusters: 16,
..base
};
group.bench_function("measure_pass2_small", |b| {
b.iter(|| {
let r = measure_pass2(black_box(p), black_box(rot_seed));
hint::black_box(r.coverage)
});
});
group.finish();
}
criterion_group!(benches, bench_compare_cost, bench_topk, bench_pass2_coverage);
criterion_main!(benches);
@@ -0,0 +1,602 @@
//! Deterministic top-K **coverage** harness for the RaBitQ sketch
//! (ADR-084 acceptance bar / ADR-156 §8 Pass-2 measurement).
//!
//! Single source of truth for the coverage number quoted in ADR-084 and
//! ADR-156: both the in-crate regression test (`pass2_coverage_not_worse_…`)
//! and the criterion bench (`benches/sketch_bench.rs`) call into here, so they
//! can never silently measure different things.
//!
//! **Coverage** is defined exactly as in ADR-084:
//!
//! > the Top-K candidate set chosen by the sketch must contain **≥ 90%** of the
//! > candidates the full-float pass would have picked.
//!
//! i.e. `coverage = |sketch_topK ∩ float_topK| / K`, averaged over a set of
//! queries. The float top-K (squared-euclidean — AETHER's actual metric) is the
//! ground truth; the sketch top-K is a *candidate* set, so in practice a system
//! over-fetches `C ≥ K` sketch candidates and refines. We measure at
//! `candidate_k == K` (the strict bar) by default; the bench also reports an
//! over-fetch curve.
//!
//! # The synthetic distribution — and why it is *anisotropic*
//!
//! Pure 1-bit sign quantization (Pass 1) is near-optimal on **isotropic,
//! zero-centred** embeddings — on such data a rotation barely moves the number,
//! so testing rotation there proves nothing. ADR-084's "Open questions" and
//! ADR-156 §8 both flag the *anisotropic / correlated* case (skewed CSI
//! spectrogram embeddings) as exactly where the rotation is supposed to earn
//! its keep. So [`make_anisotropic_embedding`] deliberately builds **correlated,
//! axis-aligned, non-isotropic** vectors: a few dominant low-frequency factors
//! shared across many coordinates (heavy coordinate correlation) plus a small
//! per-dim offset that biases signs — the structure that defeats raw
//! sign-quantization and that a randomized rotation is designed to fix. Every
//! value derives from a seed via SplitMix64, so the whole harness is
//! reproducible bit-for-bit.
use crate::estimator::EstimatorBank;
use crate::{Rotation, SketchBank};
/// SplitMix64 step — reproducible PRNG for fixture generation (dependency-free).
#[inline]
fn split_mix64(state: &mut u64) -> u64 {
*state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = *state;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
}
/// A uniform `f32` in `[0, 1)` from the PRNG state.
#[inline]
fn unif01(state: &mut u64) -> f32 {
let r = split_mix64(state);
// top 24 bits → [0,1)
((r >> 40) as f32) / ((1u64 << 24) as f32)
}
/// A standard-normal-ish `f32` via BoxMuller from two uniforms. Deterministic.
#[inline]
fn gauss(state: &mut u64) -> f32 {
let u1 = unif01(state).max(1e-7); // avoid log(0)
let u2 = unif01(state);
(-2.0 * u1.ln()).sqrt() * (std::f32::consts::TAU * u2).cos()
}
/// Fixed **anisotropic axis scale** for coordinate `i` of `dim`.
///
/// A learned embedding space is not isotropic: a handful of axes carry most of
/// the variance and the rest are near-flat. We model that with a smoothly
/// decaying per-axis scale (≈10× spread between the most- and least-energetic
/// axes). This axis-aligned imbalance is exactly what a 1-bit sign sketch
/// handles poorly (the low-variance axes' sign bits are noise) and exactly what
/// a randomized rotation re-balances (it spreads the variance across all axes so
/// every sign bit carries comparable information). The scale depends only on the
/// coordinate index, so it is the *same fixed geometry* for every vector.
#[inline]
fn axis_scale(i: usize, dim: usize) -> f32 {
let t = i as f32 / dim.max(1) as f32;
// exp decay from ~3.0 down to ~0.3 → ~10× anisotropy.
3.0 * (-2.3 * t).exp() + 0.3
}
/// Build the **planted-cluster** fixture: `n_clusters` random centres in the
/// anisotropic space. Returned as raw centres (pre-scale); callers add scale +
/// intra-cluster noise. Deterministic from `seed`.
fn cluster_centres(dim: usize, n_clusters: usize, seed: u64) -> Vec<Vec<f32>> {
(0..n_clusters)
.map(|c| {
let mut s = seed ^ 0xC0FFEE_u64.wrapping_mul(c as u64 + 1);
(0..dim).map(|_| gauss(&mut s)).collect()
})
.collect()
}
/// One embedding = its cluster centre + small intra-cluster noise, then the
/// fixed anisotropic axis scale, then a small off-centre bias. This makes the
/// **cosine top-K meaningful** (same-cluster members are genuine near-neighbours,
/// not random-noise ties), while keeping the space anisotropic so the rotation
/// has something real to fix.
fn realize(centre: &[f32], dim: usize, noise: f32, vec_seed: u64) -> Vec<f32> {
let mut s = vec_seed ^ 0x5151_5151_5151_5151;
(0..dim)
.map(|i| {
let jitter = gauss(&mut s) * noise;
let bias = ((i % 11) as f32 - 5.0) * 0.05;
axis_scale(i, dim) * (centre[i] + jitter) + bias
})
.collect()
}
/// Cosine distance `1 - cos(a,b)` — the metric a sign sketch approximates
/// (hamming over sign bits is a monotone estimate of the angle between vectors).
/// This is the correct full-float ground truth for top-K *coverage*: the sketch
/// is an angular sensor, so we grade it against the angular full-float ranking,
/// per ADR-084's `float_cosine` baseline.
#[inline]
fn cosine_distance(a: &[f32], b: &[f32]) -> f32 {
let mut dot = 0.0f32;
let mut na = 0.0f32;
let mut nb = 0.0f32;
for (&x, &y) in a.iter().zip(b.iter()) {
dot += x * y;
na += x * x;
nb += y * y;
}
let denom = (na * nb).sqrt();
if denom < f32::EPSILON {
1.0
} else {
1.0 - dot / denom
}
}
/// Full-float cosine top-K ids (ground truth), ascending by cosine distance.
fn float_topk(bank: &[Vec<f32>], query: &[f32], k: usize) -> Vec<u32> {
let mut scored: Vec<(u32, f32)> = bank
.iter()
.enumerate()
.map(|(i, v)| (i as u32, cosine_distance(query, v)))
.collect();
scored.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
scored.truncate(k);
scored.into_iter().map(|(id, _)| id).collect()
}
/// Parameters for a coverage measurement, documented in the report.
#[derive(Debug, Clone, Copy)]
pub struct CoverageParams {
/// Embedding dimension.
pub dim: usize,
/// Number of stored vectors in the bank (N).
pub n: usize,
/// Number of distinct query vectors averaged over.
pub n_queries: usize,
/// True top-K size (the bar's K).
pub k: usize,
/// Sketch candidate-set size to compare against the float top-K. Equal to
/// `k` for the strict ADR-084 bar; `> k` models over-fetch + refine.
pub candidate_k: usize,
/// Number of planted clusters. Same-cluster vectors are genuine near
/// neighbours, so the cosine top-K is *meaningful* (not random-noise ties).
pub n_clusters: usize,
/// Intra-cluster Gaussian jitter (relative to unit-variance centres). Small
/// jitter → tight, easily-recovered clusters; larger → harder top-K.
pub noise: f32,
/// Master seed (the whole fixture derives from this).
pub seed: u64,
}
impl CoverageParams {
/// The canonical AETHER-shape fixture used for the ADR-quoted numbers:
/// 128-d, planted clusters, modest intra-cluster jitter. Override fields
/// with struct-update syntax (`CoverageParams { candidate_k: 32, ..base }`).
pub fn aether_default(seed: u64) -> Self {
Self {
dim: 128,
n: 2048,
n_queries: 128,
k: 8,
candidate_k: 8,
n_clusters: 64,
noise: 0.35,
seed,
}
}
}
/// Result of a coverage measurement.
#[derive(Debug, Clone, Copy)]
pub struct CoverageResult {
/// Mean coverage in `[0, 1]` (fraction of float top-K found in the sketch
/// candidate set), averaged over queries.
pub coverage: f64,
}
/// Measure mean top-K coverage of the **Pass-1** (no rotation) sketch against
/// the full-float top-K, on the anisotropic synthetic distribution.
pub fn measure_pass1(p: CoverageParams) -> CoverageResult {
measure_inner(p, None)
}
/// Measure mean top-K coverage of the **Pass-2** (rotated) sketch against the
/// full-float top-K, on the anisotropic synthetic distribution. `rotation_seed`
/// fixes the rotation (index and query share it — that is the contract).
pub fn measure_pass2(p: CoverageParams, rotation_seed: u64) -> CoverageResult {
let rot = Rotation::new(rotation_seed, p.dim);
measure_inner(p, Some(rot))
}
/// Measure mean top-K coverage of the **RaBitQ unbiased estimator** rerank
/// (ADR-156 Milestone-2) against the full-float top-K, on the **same**
/// anisotropic synthetic fixture and query stream as [`measure_pass1`] /
/// [`measure_pass2`].
///
/// This is the whole point of Milestone-2: instead of ranking candidates by
/// raw Hamming over sign bits ([`measure_pass2`]), rank them by the RaBitQ
/// *unbiased distance estimate* recovered from the 1-bit code + per-vector side
/// info ([`crate::estimator`]). `rotation_seed` fixes the rotation (index and
/// query share it). The fixture, cluster centres, query draws, and ground-truth
/// cosine top-K are **bit-identical** to `measure_pass2`, so the only variable
/// is sign-Hamming vs estimator-rerank — an honest apples-to-apples coverage
/// comparison.
pub fn measure_estimator(p: CoverageParams, rotation_seed: u64) -> CoverageResult {
// Cosine ground truth ⇒ rerank by the estimated COSINE key (the angular
// sensor's natural metric). See `measure_estimator_euclidean` for the
// squared-euclidean key, reported alongside for honesty.
measure_estimator_inner(p, rotation_seed, EstimatorRank::Cosine)
}
/// Same as [`measure_estimator`] but reranks by the estimated **squared
/// euclidean** distance key instead of cosine. Reported alongside the cosine
/// rerank so the ADR shows both honestly: against a *cosine* ground truth, the
/// cosine key is the apples-to-apples comparison to sign-Hamming (also angular),
/// while the euclidean key mixes in residual-norm and generally ranks worse here.
pub fn measure_estimator_euclidean(p: CoverageParams, rotation_seed: u64) -> CoverageResult {
measure_estimator_inner(p, rotation_seed, EstimatorRank::Euclidean)
}
#[derive(Clone, Copy)]
enum EstimatorRank {
Cosine,
Euclidean,
}
fn measure_estimator_inner(
p: CoverageParams,
rotation_seed: u64,
rank: EstimatorRank,
) -> CoverageResult {
let rot = Rotation::new(rotation_seed, p.dim);
let float_bank = make_fixture(p);
let centres = cluster_centres(p.dim, p.n_clusters.max(1), p.seed);
// Estimator bank over the SAME fixture vectors.
let mut bank = EstimatorBank::new(rot);
for (i, v) in float_bank.iter().enumerate() {
bank.insert_embedding(i as u32, v);
}
let mut total = 0.0f64;
for q in 0..p.n_queries {
// IDENTICAL query draw to measure_inner (same seed expression).
let c = q % p.n_clusters.max(1);
let qv = realize(
&centres[c],
p.dim,
p.noise,
p.seed ^ 0xDEAD_0000_0000 ^ (q as u64).wrapping_mul(0x2545_F491),
);
let truth = float_topk(&float_bank, &qv, p.k);
let cand = match rank {
EstimatorRank::Cosine => bank.topk_estimated_cosine(&qv, p.candidate_k),
EstimatorRank::Euclidean => bank.topk_estimated(&qv, p.candidate_k),
};
let cand_ids: std::collections::HashSet<u32> = cand.into_iter().map(|(id, _)| id).collect();
let hit = truth.iter().filter(|id| cand_ids.contains(id)).count();
total += hit as f64 / p.k as f64;
}
CoverageResult {
coverage: total / p.n_queries as f64,
}
}
/// Measure mean top-K coverage of a **multi-bit (Pass-3)** rotated sketch:
/// `bits` bits per dimension instead of 1, ranked by L1 distance over the
/// per-dim codes (the natural multi-bit generalization of hamming). This is the
/// "Multi-bit / Extended RaBitQ" half of ADR-156 §8 — measured here as an
/// experiment to decide whether a full `MultiBitSketch` type is worth building.
///
/// Quantization: rotate (Pass-2 frame), then map each rotated coordinate through
/// a uniform mid-rise scalar quantizer with `2^bits` levels over a fixed
/// symmetric range `[-RANGE, RANGE]` (RANGE chosen from the rotated-coord scale).
/// `bits == 1` reduces to sign-quantization (sanity: should match Pass-2 within
/// quantizer-boundary noise). Memory cost is `bits×` the 1-bit sketch.
///
/// Returns the measured coverage; the caller reports the bit/coverage tradeoff.
pub fn measure_multibit(p: CoverageParams, rotation_seed: u64, bits: u32) -> CoverageResult {
assert!((1..=8).contains(&bits), "bits must be in 1..=8");
let rot = Rotation::new(rotation_seed, p.dim);
let levels = 1u32 << bits; // 2^bits codes per dim
// Rotated AETHER-shape coords after the normalized FHT sit roughly in
// [-RANGE, RANGE]; clamp out-of-range to the end codes. RANGE picked to
// cover ~99% of the rotated-coord magnitude on this fixture (empirically
// ~3.0 after the 1/√m normalization).
const RANGE: f32 = 3.0;
let quantize = move |v: &[f32]| -> Vec<u16> {
rot.apply(v)
.iter()
.map(|&x| {
let t = ((x + RANGE) / (2.0 * RANGE)).clamp(0.0, 1.0); // → [0,1]
let code = (t * (levels - 1) as f32).round() as u32;
code.min(levels - 1) as u16
})
.collect()
};
// L1 distance over per-dim codes.
let l1 = |a: &[u16], b: &[u16]| -> u32 {
a.iter()
.zip(b)
.map(|(&x, &y)| (x as i32 - y as i32).unsigned_abs())
.sum()
};
let float_bank = make_fixture(p);
let centres = cluster_centres(p.dim, p.n_clusters.max(1), p.seed);
let coded_bank: Vec<Vec<u16>> = float_bank.iter().map(|v| quantize(v)).collect();
let mut total = 0.0f64;
for q in 0..p.n_queries {
let c = q % p.n_clusters.max(1);
let qv = realize(
&centres[c],
p.dim,
p.noise,
p.seed ^ 0xDEAD_0000_0000 ^ (q as u64).wrapping_mul(0x2545_F491),
);
let truth = float_topk(&float_bank, &qv, p.k);
let qc = quantize(&qv);
// top candidate_k by L1 over codes.
let mut scored: Vec<(u32, u32)> = coded_bank
.iter()
.enumerate()
.map(|(i, code)| (i as u32, l1(&qc, code)))
.collect();
scored.sort_by_key(|&(_, d)| d);
scored.truncate(p.candidate_k);
let cand_ids: std::collections::HashSet<u32> =
scored.into_iter().map(|(id, _)| id).collect();
let hit = truth.iter().filter(|id| cand_ids.contains(id)).count();
total += hit as f64 / p.k as f64;
}
CoverageResult {
coverage: total / p.n_queries as f64,
}
}
/// Build the deterministic float bank for `p`: `p.n` vectors, each assigned to
/// one of `p.n_clusters` planted clusters (round-robin), realized as
/// `centre + jitter` under the fixed anisotropic axis scale. Returned with the
/// cluster id of each vector so queries can be drawn from the same clusters.
pub fn make_fixture(p: CoverageParams) -> Vec<Vec<f32>> {
let centres = cluster_centres(p.dim, p.n_clusters.max(1), p.seed);
(0..p.n)
.map(|i| {
let c = i % p.n_clusters.max(1);
realize(&centres[c], p.dim, p.noise, p.seed ^ (i as u64).wrapping_mul(0x9E37))
})
.collect()
}
fn measure_inner(p: CoverageParams, rotation: Option<Rotation>) -> CoverageResult {
const SV: u16 = 1;
// Float bank (ground truth) + sketch bank from the SAME vectors, so the
// only variable is float-vs-sketch (and Pass-1-vs-Pass-2).
let float_bank = make_fixture(p);
let centres = cluster_centres(p.dim, p.n_clusters.max(1), p.seed);
let mut bank = match &rotation {
Some(r) => SketchBank::with_rotation(r.clone()),
None => SketchBank::new(),
};
for (i, v) in float_bank.iter().enumerate() {
// Use the bank's rotation policy for both Pass-1 and Pass-2 uniformly.
bank.insert_embedding(i as u32, v, SV)
.expect("schema-locked insert");
}
let mut total = 0.0f64;
for q in 0..p.n_queries {
// Each query is a fresh draw from a planted cluster (disjoint seed
// range from the bank), so it HAS genuine same-cluster neighbours in
// the bank — a meaningful top-K, not random-noise ties.
let c = q % p.n_clusters.max(1);
let qv = realize(
&centres[c],
p.dim,
p.noise,
p.seed ^ 0xDEAD_0000_0000 ^ (q as u64).wrapping_mul(0x2545_F491),
);
let truth = float_topk(&float_bank, &qv, p.k);
let cand = bank
.topk_embedding(&qv, SV, p.candidate_k)
.expect("schema match");
let cand_ids: std::collections::HashSet<u32> = cand.into_iter().map(|(id, _)| id).collect();
let hit = truth.iter().filter(|id| cand_ids.contains(id)).count();
total += hit as f64 / p.k as f64;
}
CoverageResult {
coverage: total / p.n_queries as f64,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tight_clusters_give_high_coverage_with_overfetch() {
// Sanity / regression: on tight clusters with enough over-fetch the
// sketch MUST recover essentially all of the float cosine top-K — this
// both proves the harness is correct (a broken topk gives ~random here)
// and pins the cluster structure as meaningful. Catches the heap
// inversion bug found during this work (which made this ~6%).
let p = CoverageParams {
n: 1024,
n_queries: 64,
n_clusters: 64,
noise: 0.1,
candidate_k: 64,
..CoverageParams::aether_default(0x1111)
};
let cov = measure_pass1(p).coverage;
assert!(
cov > 0.95,
"tight clusters + 8× over-fetch should recover >95% of top-K, got {:.3}",
cov
);
}
#[test]
fn multibit_tradeoff_report() {
// ADR-156 §8 "Multi-bit / Extended RaBitQ" measurement: bit/coverage
// tradeoff at the STRICT bar (candidate_k == K). Reports b=1..4 bits
// per dim alongside Pass-1 / Pass-2 (1-bit) baselines. Run with
// --nocapture to see the table.
let base = CoverageParams::aether_default(0xAD00_0084);
let rot_seed = 0x5EED_C0DE_1234_5678u64;
let p1 = measure_pass1(base).coverage;
let p2 = measure_pass2(base, rot_seed).coverage;
println!("\n=== ADR-156 §8 multi-bit tradeoff (strict candidate_k=K={}) ===", base.k);
println!("dim={} N={} clusters={} noise={} bar=90%", base.dim, base.n, base.n_clusters, base.noise);
println!(" Pass1 (no rot, 1-bit) : {:6.2}%", p1 * 100.0);
println!(" Pass2 (rot, 1-bit) : {:6.2}%", p2 * 100.0);
for bits in 1..=4u32 {
let cov = measure_multibit(base, rot_seed, bits).coverage;
let bytes_per_vec = base.dim * bits as usize / 8;
println!(
" Pass3 (rot, {bits}-bit, {bytes_per_vec:>3} B/vec): {:6.2}% {}",
cov * 100.0,
if cov >= 0.90 { "≥90%" } else { "" }
);
}
println!("=================================================================\n");
assert!((0.0..=1.0).contains(&p1));
}
#[test]
fn multibit_1bit_matches_pass2_approx() {
// Sanity: 1-bit multi-bit quantization is essentially sign-quantization,
// so its coverage should track Pass-2 (rotated 1-bit) closely. (Not
// exact: the mid-rise quantizer's 0/1 boundary is at the RANGE midpoint,
// which equals the sign boundary, so they should match very closely.)
let p = CoverageParams {
n: 256,
n_queries: 16,
n_clusters: 16,
..CoverageParams::aether_default(0x55)
};
let rot_seed = 0xABCDu64;
let p2 = measure_pass2(p, rot_seed).coverage;
let mb1 = measure_multibit(p, rot_seed, 1).coverage;
assert!(
(p2 - mb1).abs() < 0.05,
"1-bit multibit {mb1:.3} should track Pass-2 {p2:.3}"
);
}
#[test]
fn estimator_rerank_not_worse_than_sign() {
// ADR-156 Milestone-2 core regression: on a fixed anisotropic fixture,
// reranking the candidate set by the RaBitQ unbiased ESTIMATE must be
// >= ranking by sign-only Hamming (Pass-2). The estimator must never
// make coverage WORSE — it strictly refines the same 1-bit codes with
// side info. (We assert >= here, not a hard 90% bar — the bar is the
// measured number reported in the ADR, not a unit invariant.)
let p = CoverageParams {
n: 512,
n_queries: 64,
n_clusters: 32,
..CoverageParams::aether_default(0x00C0_FFEE)
};
let rot_seed = 0x1234_5678_9ABC_DEF0u64;
let sign = measure_pass2(p, rot_seed).coverage;
let est = measure_estimator(p, rot_seed).coverage;
assert!(
est + 1e-9 >= sign,
"estimator rerank coverage {est:.4} regressed below sign-only Pass-2 {sign:.4}"
);
}
#[test]
fn estimator_coverage_is_deterministic() {
// Same params + rotation seed ⇒ same measured coverage, twice.
let p = CoverageParams {
n: 256,
n_queries: 16,
n_clusters: 16,
..CoverageParams::aether_default(0xE571_3A7E)
};
let a = measure_estimator(p, 0xFEED_FACE_0000_0001).coverage;
let b = measure_estimator(p, 0xFEED_FACE_0000_0001).coverage;
assert_eq!(a, b, "estimator coverage must be deterministic");
assert!((0.0..=1.0).contains(&a));
}
/// Deterministic, test-runnable coverage measurement that PRINTS the
/// Milestone-2 strict-K table: Pass-1 | Pass-2-sign | Pass-2+estimator, at
/// the strict bar (candidate_k == K) plus the over-fetch curve. Run with:
/// cargo test -p wifi-densepose-ruvector --no-default-features \
/// estimator_coverage_report -- --nocapture
#[test]
fn estimator_coverage_report() {
let base = CoverageParams::aether_default(0xAD00_0084);
let rot_seed = 0x5EED_C0DE_1234_5678u64;
println!(
"\n=== ADR-156 Milestone-2 RaBitQ estimator coverage (anisotropic synthetic) ==="
);
println!(
"dim={} N={} K={} queries={} clusters={} noise={} master_seed=0x{:X} rotation_seed=0x{:X}",
base.dim, base.n, base.k, base.n_queries, base.n_clusters, base.noise, base.seed, rot_seed
);
println!("side info = 8 B/vec (residual_norm + x_dot_o, 2x f32)");
println!(
"{:<12} {:>9} {:>9} {:>11} {:>11} {:>9}",
"candidate_k", "P1-sign", "P2-sign", "Est-cosine", "Est-euclid", "vs 90%"
);
for &c in &[base.k, 16usize, 24, 32, 64] {
let pc = CoverageParams {
candidate_k: c,
..base
};
let p1 = measure_pass1(pc).coverage;
let p2 = measure_pass2(pc, rot_seed).coverage;
let est_cos = measure_estimator(pc, rot_seed).coverage;
let est_euc = measure_estimator_euclidean(pc, rot_seed).coverage;
let bar = if est_cos >= 0.90 { "EST≥90%" } else { "below" };
let strict = if c == base.k { " (STRICT)" } else { "" };
println!(
"{:<12} {:>8.2}% {:>8.2}% {:>10.2}% {:>10.2}% {:>9}{}",
c,
p1 * 100.0,
p2 * 100.0,
est_cos * 100.0,
est_euc * 100.0,
bar,
strict
);
}
println!("============================================================================\n");
let strict = measure_estimator(base, rot_seed).coverage;
assert!((0.0..=1.0).contains(&strict));
}
#[test]
fn fixture_is_deterministic() {
let p = CoverageParams::aether_default(12345);
let a = make_fixture(p);
let b = make_fixture(p);
assert_eq!(a, b);
assert_eq!(a.len(), p.n);
assert_eq!(a[0].len(), p.dim);
let c = make_fixture(CoverageParams::aether_default(12346));
assert_ne!(a[0], c[0]);
}
#[test]
fn coverage_harness_runs_and_is_in_range() {
// Small fixed fixture — fast, deterministic, in [0,1].
let p = CoverageParams {
n: 256,
n_queries: 16,
n_clusters: 16,
..CoverageParams::aether_default(0xABCD)
};
let c1 = measure_pass1(p);
let c2 = measure_pass2(p, 0x1234_5678);
assert!((0.0..=1.0).contains(&c1.coverage));
assert!((0.0..=1.0).contains(&c2.coverage));
// Determinism: same params → same number.
assert_eq!(measure_pass1(p).coverage, c1.coverage);
assert_eq!(measure_pass2(p, 0x1234_5678).coverage, c2.coverage);
}
}
@@ -0,0 +1,685 @@
//! RaBitQ **unbiased distance estimator** — the real Gao & Long (SIGMOD 2024)
//! contribution, on top of the Pass-2 rotation ([`crate::rotation`]).
//!
//! ## Why this exists (ADR-156 Milestone-2)
//!
//! Pass-1 ([`crate::sketch`]) and Pass-2 ([`crate::rotation`]) use only the
//! **sign** of each rotated coordinate and rank candidates by **Hamming /
//! bit distance** — a coarse, monotone-but-lossy proxy for the true angle.
//! ADR-156 §10 measured that sign-only Pass-2 leaves strict-K
//! (`candidate_k == K`) top-K coverage at **~46%**, well below the ADR-084
//! **≥90%** bar, and only clears 90% with ~3× over-fetch.
//!
//! RaBitQ's *actual* algorithmic contribution is not the sign bits — it is an
//! **unbiased estimator of the inner product / squared distance** recovered
//! from the 1-bit code **plus a few bytes of per-vector side information**.
//! That estimate is far sharper than the raw Hamming proxy, so it can
//! **rerank** the candidate set and (the question this module measures) close
//! the strict-K coverage gap.
//!
//! ## The estimator (paper formula + our simplification, stated honestly)
//!
//! Notation follows the paper. Let `P` be the Pass-2 orthogonal rotation
//! ([`crate::Rotation`], `R = H·D`). For a data vector `o_raw` and a query
//! `q_raw`:
//!
//! 1. **Centroid.** The paper centres each vector on its (per-cluster)
//! centroid `c`: residual `o_r = o_raw c`. **We use a zero / global
//! centroid `c = 0`** (`o_r = o_raw`). This is an explicit simplification
//! (no IVF/k-means cluster structure in the current sketch path) — it costs
//! accuracy when the data is far off-origin, and we document it rather than
//! hide it. With `c = 0`, the residual *is* the raw vector.
//!
//! 2. **Unit residual + 1-bit code.** `o = o_r / ‖o_r‖`. Rotate:
//! `o' = P·o`. The 1-bit code is `x̄_i = sign(o'_i) · (1/√D)`, so `x̄`
//! is a **unit vector** in `{±1/√D}^D` (the corner of the hypercube nearest
//! `o'`). `D` is the rotation's padded dimension (`next_pow2(dim)`), because
//! the FHT operates on the padded length and `x̄` is unit over that length.
//!
//! 3. **Per-vector side information** (the "few bytes"): we store, per sketch,
//! - `residual_norm = ‖o_r‖` (an `f32`), and
//! - `x_dot_o = ⟨x̄, o'⟩` (an `f32`), the cosine between the code and the
//! rotated unit residual. This is the quantity the paper calls `⟨x̄, o⟩`
//! (after rotation); it lies in `(0, 1]` and is `1` only when `o'`
//! already sits exactly on a hypercube corner.
//!
//! That is **8 bytes/vector** of side info (2× `f32`).
//!
//! 4. **Query-time estimate.** Rotate the query residual: `q' = P·q_r`. The
//! **unbiased estimator of `⟨o', q'⟩`** (equivalently `⟨o, q_r⟩`, since `P`
//! is orthogonal) is
//!
//! ```text
//! ⟨o', q'⟩ ≈ ⟨x̄, q'⟩ / ⟨x̄, o'⟩ = ⟨x̄, q'⟩ / x_dot_o
//! ```
//!
//! This is RaBitQ Eq. (in the paper, the estimator `<q, o> ≈ <q̄, ...>`):
//! the random rotation makes the quantization error of `x̄` (relative to
//! `o'`) orthogonal **in expectation** to `q'`, so dividing the measured
//! `⟨x̄, q'⟩` by `x_dot_o` is **unbiased** for `⟨o', q'⟩`, with the paper's
//! `O(1/√D)` error bound. The only per-candidate cost is one length-`D`
//! dot product `⟨x̄, q'⟩` — which, because `x̄ ∈ {±1/√D}`, is just a signed
//! sum of the query coordinates (`±` chosen by the stored sign bits),
//! i.e. as cheap as the Hamming proxy plus one multiply.
//!
//! 5. **Inner product and squared distance.** Un-normalize:
//! `⟨o_r, q_r⟩ = ‖o_r‖ · ⟨o, q_r⟩`. Then
//!
//! ```text
//! ‖q_r o_r‖² = ‖q_r‖² + ‖o_r‖² 2·⟨o_r, q_r⟩
//! ```
//!
//! For **ranking** a candidate set against one fixed query, `‖q_r‖²` is a
//! per-query constant and can be dropped; we keep it in
//! [`DistanceEstimator::estimate_sq_distance`] so the value is a genuine
//! distance estimate (used by the unbiasedness test), and expose the
//! cheaper ranking key separately.
//!
//! ## What is unbiased, and what we measure
//!
//! The estimator of `⟨o', q'⟩` is unbiased over the random rotation. We pin
//! that on a small hand-checkable fixture (`estimator_unbiased_on_fixture`):
//! averaging the estimate over many random rotation seeds converges to the true
//! inner product within tolerance. We then measure whether **reranking the
//! candidate set by this estimate** closes the strict-K coverage gap that the
//! sign-only Pass-2 left at ~46% — reported honestly in ADR-156 §10 / §11
//! whether it clears 90% or not.
//!
//! ## Backward compatibility
//!
//! This module is **purely additive**. It introduces an *extended* sketch type
//! ([`EstimatorSketch`]) and bank ([`EstimatorBank`]) that carry the side info;
//! the Pass-1 [`crate::Sketch`] / Pass-2 [`crate::SketchBank`] paths and the
//! [`crate::WireSketch`] wire format are **untouched**. Nothing on the existing
//! surface changes.
use crate::rotation::{next_pow2, Rotation};
/// The per-vector side information RaBitQ needs to turn a 1-bit code into an
/// **unbiased** distance estimate (§ module docs step 3).
///
/// Two `f32`s = **8 bytes/vector** on top of the packed sign bits.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SideInfo {
/// `‖o_r‖` — L2 norm of the (zero-centroid) residual = the raw vector norm.
pub residual_norm: f32,
/// `⟨x̄, o'⟩` — dot product of the unit 1-bit code with the rotated unit
/// residual. In `(0, 1]`; the paper's `⟨x̄, o⟩`. Drives the unbiased
/// rescaling `⟨x̄, q'⟩ / x_dot_o`.
pub x_dot_o: f32,
}
/// A Pass-2 sketch **plus** the RaBitQ side information, sufficient to compute
/// the unbiased distance estimate at query time.
///
/// Stores the packed sign bits over the **padded** rotation length `D`
/// (`next_pow2(dim)`) — the frame `x̄` actually lives in — together with the
/// [`SideInfo`]. Construct via [`EstimatorSketch::from_embedding`]; the index
/// and the query **must** use the same [`Rotation`] (same seed + dim), exactly
/// as for a Pass-2 sketch.
#[derive(Debug, Clone)]
pub struct EstimatorSketch {
/// Sign bits of the rotated *padded* unit residual, MSB-first per byte.
/// Length is `ceil(D / 8)` where `D = next_pow2(dim)`. Bit set ⇒ `o'_i ≥ 0`
/// ⇒ code coordinate `+1/√D`; clear ⇒ `1/√D`.
bits: Vec<u8>,
/// Padded rotation dimension `D = next_pow2(dim)`; the code is unit over `D`.
padded_dim: usize,
/// Source embedding dimension (for compatibility checks / reporting).
embedding_dim: usize,
/// The RaBitQ side info for the unbiased estimate.
side: SideInfo,
}
impl EstimatorSketch {
/// Build an estimator sketch from a dense embedding and a [`Rotation`].
///
/// Zero-centroid (`c = 0`): the residual is the raw embedding. The vector is
/// rotated through `rotation` over its padded length `D = next_pow2(dim)`,
/// the sign of each rotated coordinate is packed, and the side info
/// (`‖o_r‖`, `⟨x̄, o'⟩`) is computed in the same pass.
///
/// A zero (or all-equal-to-its-own-mean) input yields `residual_norm = 0`;
/// its estimate degenerates to `0` (handled in
/// [`EstimatorBank`]) rather than dividing by zero.
pub fn from_embedding(embedding: &[f32], rotation: &Rotation) -> Self {
Self::from_embedding_centred(embedding, rotation, None)
}
/// Build an estimator sketch with an **explicit centroid** `c` subtracted
/// before rotation (the paper's per-cluster centroid; `o_r = o_raw c`).
///
/// Pass `None` for the zero-centroid simplification (`c = 0`, identical to
/// [`EstimatorSketch::from_embedding`]). Pass `Some(centroid)` (length `dim`)
/// to centre on a shared global / cluster centroid — the index and the query
/// **must** use the *same* centroid, exactly as they must share the rotation.
/// This path exists so ADR-156 can **measure the cost of the zero-centroid
/// simplification** honestly rather than assert it.
pub fn from_embedding_centred(
embedding: &[f32],
rotation: &Rotation,
centroid: Option<&[f32]>,
) -> Self {
let dim = rotation.dim();
let padded = next_pow2(dim);
// Residual o_r = o_raw c (c = 0 when centroid is None). Build it once.
let residual: Vec<f32> = (0..dim)
.map(|i| {
let v = embedding.get(i).copied().unwrap_or(0.0);
let c = centroid.and_then(|c| c.get(i)).copied().unwrap_or(0.0);
v - c
})
.collect();
let residual_norm = {
let mut acc = 0.0f64;
for &v in &residual {
acc += (v as f64) * (v as f64);
}
acc.sqrt() as f32
};
// Rotate the RESIDUAL over the PADDED length so the code frame matches
// what `x_dot_o` and the query dot product use.
let rotated_padded = rotation.apply_padded(&residual);
debug_assert_eq!(rotated_padded.len(), padded);
// 1-bit code over the padded length: x̄_i = sign(o'_i)/√D on the *unit*
// residual. Since o' = P·o = P·(o_r/‖o_r‖) = (P·o_r)/‖o_r‖, and sign is
// scale-invariant, sign(o'_i) == sign((P·o_r)_i) == sign(rotated_padded_i).
// ⟨x̄, o'⟩ = (1/√D)·Σ sign(o'_i)·o'_i = (1/√D)·Σ |o'_i|
// = (1/√D)·(Σ|(P·o_r)_i|) / ‖o_r‖.
let inv_sqrt_d = 1.0f32 / (padded as f32).sqrt();
let mut bits = vec![0u8; padded.div_ceil(8)];
let mut sum_abs = 0.0f64; // Σ |(P·o_r)_i|
for (i, &c) in rotated_padded.iter().enumerate() {
if c >= 0.0 {
bits[i / 8] |= 1 << (7 - (i % 8));
}
sum_abs += (c as f64).abs();
}
// ⟨x̄, o'⟩ with o' the rotated *unit* residual.
let x_dot_o = if residual_norm > 0.0 {
(inv_sqrt_d as f64 * sum_abs / residual_norm as f64) as f32
} else {
0.0
};
Self {
bits,
padded_dim: padded,
embedding_dim: dim,
side: SideInfo {
residual_norm,
x_dot_o,
},
}
}
/// The padded rotation dimension `D` the code lives in.
#[inline]
pub fn padded_dim(&self) -> usize {
self.padded_dim
}
/// Source embedding dimension.
#[inline]
pub fn embedding_dim(&self) -> usize {
self.embedding_dim
}
/// The RaBitQ side information.
#[inline]
pub fn side_info(&self) -> SideInfo {
self.side
}
/// `‖o_r‖` of the residual (zero-centroid ⇒ raw vector norm).
#[inline]
pub fn residual_norm(&self) -> f32 {
self.side.residual_norm
}
/// Side-information byte cost (excluding the packed sign bits): 8 bytes.
pub const SIDE_INFO_BYTES: usize = 2 * std::mem::size_of::<f32>();
/// `⟨x̄, q'⟩` — the dot product of this sketch's unit 1-bit code with a
/// rotated query `q'` (length `padded_dim`). Because `x̄_i = ±1/√D`, this is
/// `(1/√D)·Σ ±q'_i` with the sign taken from the stored bit. The single
/// per-candidate cost of the estimator.
#[inline]
fn code_dot(&self, q_rotated_padded: &[f32]) -> f32 {
debug_assert_eq!(q_rotated_padded.len(), self.padded_dim);
let inv_sqrt_d = 1.0f32 / (self.padded_dim as f32).sqrt();
let mut acc = 0.0f32;
for (i, &q) in q_rotated_padded.iter().enumerate() {
let bit = (self.bits[i / 8] >> (7 - (i % 8))) & 1;
if bit == 1 {
acc += q;
} else {
acc -= q;
}
}
acc * inv_sqrt_d
}
}
/// A pre-rotated query, computed **once** per query and reused across all
/// candidates. Carries `q' = P·q_r` (over the padded length) and `‖q_r‖²`.
#[derive(Debug, Clone)]
pub struct EstimatorQuery {
/// `q' = P·q_r` over the padded rotation length.
q_rotated_padded: Vec<f32>,
/// `‖q_r‖²` — per-query constant in the squared-distance expansion.
q_norm_sq: f32,
}
impl EstimatorQuery {
/// Pre-rotate a query embedding through `rotation` (zero-centroid).
pub fn new(query: &[f32], rotation: &Rotation) -> Self {
Self::new_centred(query, rotation, None)
}
/// Pre-rotate a query residual `q_r = q c` through `rotation`. The
/// centroid **must** match the one used to build the bank's sketches.
pub fn new_centred(query: &[f32], rotation: &Rotation, centroid: Option<&[f32]>) -> Self {
let dim = rotation.dim();
let residual: Vec<f32> = (0..dim)
.map(|i| {
let v = query.get(i).copied().unwrap_or(0.0);
let c = centroid.and_then(|c| c.get(i)).copied().unwrap_or(0.0);
v - c
})
.collect();
let mut q_norm_sq = 0.0f64;
for &v in &residual {
q_norm_sq += (v as f64) * (v as f64);
}
Self {
q_rotated_padded: rotation.apply_padded(&residual),
q_norm_sq: q_norm_sq as f32,
}
}
}
/// Computes RaBitQ unbiased estimates from an [`EstimatorSketch`] + a
/// pre-rotated [`EstimatorQuery`].
///
/// Stateless — the methods are associated functions. Kept as a type for
/// discoverability and to group the estimator formula in one place.
pub struct DistanceEstimator;
impl DistanceEstimator {
/// Unbiased estimate of `⟨o_r, q_r⟩` (the inner product of the residuals).
///
/// `⟨o_r, q_r⟩ = ‖o_r‖ · (⟨x̄, q'⟩ / ⟨x̄, o'⟩)`. Returns `0.0` when the
/// stored `x_dot_o` is non-positive (degenerate / zero residual), which
/// cannot happen for a non-zero input but keeps the call total.
pub fn estimate_inner_product(sketch: &EstimatorSketch, query: &EstimatorQuery) -> f32 {
let x_dot_o = sketch.side.x_dot_o;
if x_dot_o <= 0.0 {
return 0.0;
}
let code_dot_q = sketch.code_dot(&query.q_rotated_padded);
// ⟨o, q_r⟩ ≈ ⟨x̄, q'⟩ / x_dot_o (unit residual o)
let inner_unit = code_dot_q / x_dot_o;
sketch.side.residual_norm * inner_unit
}
/// Unbiased estimate of the **squared euclidean distance** `‖q_r o_r‖²`.
///
/// `= ‖q_r‖² + ‖o_r‖² 2·⟨o_r, q_r⟩`, using the estimated inner product.
/// This is the value the unbiasedness test checks.
pub fn estimate_sq_distance(sketch: &EstimatorSketch, query: &EstimatorQuery) -> f32 {
let ip = Self::estimate_inner_product(sketch, query);
let o_norm = sketch.side.residual_norm;
query.q_norm_sq + o_norm * o_norm - 2.0 * ip
}
/// The cheap **euclidean ranking key** for nearest-neighbour reranking:
/// monotone in the estimated squared distance with the per-query constant
/// `‖q_r‖²` dropped. Smaller = nearer. Equals `‖o_r‖² 2·⟨o_r, q_r⟩`.
///
/// Use this (not [`Self::estimate_sq_distance`]) for top-K reranking under a
/// **euclidean** ground truth — it avoids adding the same `q_norm_sq` to
/// every candidate. For a **cosine** ground truth (AETHER / the coverage
/// harness), use [`Self::cosine_ranking_key`] instead.
#[inline]
pub fn ranking_key(sketch: &EstimatorSketch, query: &EstimatorQuery) -> f32 {
let ip = Self::estimate_inner_product(sketch, query);
let o_norm = sketch.side.residual_norm;
o_norm * o_norm - 2.0 * ip
}
/// The cheap **cosine ranking key**: smaller = nearer in cosine distance.
///
/// Cosine distance is `1 ⟨o_r,q_r⟩ / (‖o_r‖·‖q_r‖)`. `‖q_r‖` is a
/// per-query constant, so ranking by cosine distance ascending is ranking by
/// `⟨o_r,q_r⟩ / ‖o_r‖` **descending**, i.e. by `−⟨o, q_r⟩` ascending. And
/// `⟨o, q_r⟩ = ⟨x̄, q'⟩ / x_dot_o` — the unit-residual inner product, which
/// needs **only the code and `x_dot_o`**, not even `residual_norm`. We
/// return `−⟨o, q_r⟩` so "smaller = nearer" matches the euclidean key's
/// convention.
///
/// This is the correct key when the sketch is used (as in ADR-084) as an
/// **angular** sensor graded against a cosine top-K: the 1-bit code is a
/// rotated-angle estimator, and dividing by `x_dot_o` is the RaBitQ unbiased
/// rescale of that angle's inner product.
#[inline]
pub fn cosine_ranking_key(sketch: &EstimatorSketch, query: &EstimatorQuery) -> f32 {
let x_dot_o = sketch.side.x_dot_o;
if x_dot_o <= 0.0 {
return 0.0;
}
// ⟨o, q_r⟩ = ⟨x̄, q'⟩ / x_dot_o ; nearer in cosine ⇒ larger ⇒ negate.
-(sketch.code_dot(&query.q_rotated_padded) / x_dot_o)
}
}
/// A bank of [`EstimatorSketch`]es with stable IDs, reranked by the RaBitQ
/// **unbiased distance estimate** instead of raw Hamming.
///
/// All sketches share one [`Rotation`] (the index/query frame). The bank rotates
/// every inserted embedding and every query through it, so the estimator is
/// always computed in a consistent frame.
///
/// # Invariants
/// - All sketches share the bank's `embedding_dim` and `Rotation`.
/// - IDs are caller-assigned and stable.
#[derive(Debug, Clone)]
pub struct EstimatorBank {
rotation: Rotation,
entries: Vec<(u32, EstimatorSketch)>,
embedding_dim: usize,
/// Optional shared centroid subtracted from every embedding/query before
/// rotation. `None` = zero-centroid (the default simplification).
centroid: Option<Vec<f32>>,
}
impl EstimatorBank {
/// Create an empty bank over `rotation`'s dimension and frame (zero-centroid).
pub fn new(rotation: Rotation) -> Self {
let embedding_dim = rotation.dim();
Self {
rotation,
entries: Vec::new(),
embedding_dim,
centroid: None,
}
}
/// Create an empty bank that subtracts `centroid` from every embedding and
/// query before rotation (the paper's centroid path). Used by ADR-156 to
/// measure the cost of the zero-centroid simplification.
pub fn with_centroid(rotation: Rotation, centroid: Vec<f32>) -> Self {
let embedding_dim = rotation.dim();
Self {
rotation,
entries: Vec::new(),
embedding_dim,
centroid: Some(centroid),
}
}
/// The rotation (index/query frame) this bank uses.
#[inline]
pub fn rotation(&self) -> &Rotation {
&self.rotation
}
/// Number of stored sketches.
#[inline]
pub fn len(&self) -> usize {
self.entries.len()
}
/// True iff empty.
#[inline]
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
/// Source embedding dimension.
#[inline]
pub fn embedding_dim(&self) -> usize {
self.embedding_dim
}
/// Insert a raw embedding, sketching it (with side info) through the bank's
/// rotation. The stored code and the queries share one rotated frame.
pub fn insert_embedding(&mut self, id: u32, embedding: &[f32]) {
let sketch = EstimatorSketch::from_embedding_centred(
embedding,
&self.rotation,
self.centroid.as_deref(),
);
self.entries.push((id, sketch));
}
/// Insert a pre-built [`EstimatorSketch`] (must have been built with this
/// bank's rotation; the caller is responsible for that).
pub fn insert(&mut self, id: u32, sketch: EstimatorSketch) {
self.entries.push((id, sketch));
}
/// Top-K nearest neighbours by the **RaBitQ unbiased estimate**, ascending
/// by [`DistanceEstimator::ranking_key`]. Returns up to `k` `(id, key)`
/// pairs. If `k == 0` or the bank is empty, returns empty. If the bank has
/// fewer than `k`, returns all of them.
///
/// The query is rotated **once**; every candidate then costs one
/// length-`D` signed-sum dot product — the estimator is as cheap per
/// candidate as Hamming plus a multiply.
pub fn topk_estimated(&self, query: &[f32], k: usize) -> Vec<(u32, f32)> {
self.topk_by(query, k, DistanceEstimator::ranking_key)
}
/// Top-K by the estimated **cosine** distance
/// ([`DistanceEstimator::cosine_ranking_key`]) — the correct rerank when the
/// sketch is graded against a cosine top-K (AETHER / the coverage harness).
pub fn topk_estimated_cosine(&self, query: &[f32], k: usize) -> Vec<(u32, f32)> {
self.topk_by(query, k, DistanceEstimator::cosine_ranking_key)
}
/// Shared top-K driver parameterised on the ranking-key function. Rotates
/// the query once, scores every candidate with `key`, returns the `k`
/// smallest keys ascending.
fn topk_by(
&self,
query: &[f32],
k: usize,
key: fn(&EstimatorSketch, &EstimatorQuery) -> f32,
) -> Vec<(u32, f32)> {
if k == 0 || self.entries.is_empty() {
return Vec::new();
}
let q = EstimatorQuery::new_centred(query, &self.rotation, self.centroid.as_deref());
let mut scored: Vec<(u32, f32)> = self
.entries
.iter()
.map(|(id, sk)| (*id, key(sk, &q)))
.collect();
// Ascending by ranking key. Total ordering via partial_cmp with a
// NaN-safe fallback (estimates are finite for finite input).
scored.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
scored.truncate(k);
scored
}
}
#[cfg(test)]
mod tests {
use super::*;
fn l2(v: &[f32]) -> f32 {
v.iter().map(|&x| x * x).sum::<f32>().sqrt()
}
/// Brute-force true inner product of two residuals (zero-centroid).
fn true_inner(a: &[f32], b: &[f32]) -> f32 {
a.iter().zip(b).map(|(&x, &y)| x * y).sum()
}
#[test]
fn estimator_is_deterministic() {
// Same (seed, dim) rotation + same vectors ⇒ identical estimate, twice.
let dim = 64;
let rot = Rotation::new(0xC0DE_1234_5678_9ABC, dim);
let o: Vec<f32> = (0..dim).map(|i| (i as f32 * 0.21).sin() + 0.3).collect();
let qv: Vec<f32> = (0..dim).map(|i| (i as f32 * 0.11).cos() - 0.2).collect();
let s1 = EstimatorSketch::from_embedding(&o, &rot);
let s2 = EstimatorSketch::from_embedding(&o, &rot);
let q1 = EstimatorQuery::new(&qv, &rot);
let q2 = EstimatorQuery::new(&qv, &Rotation::new(0xC0DE_1234_5678_9ABC, dim));
let e1 = DistanceEstimator::estimate_inner_product(&s1, &q1);
let e2 = DistanceEstimator::estimate_inner_product(&s2, &q2);
assert_eq!(e1, e2, "estimator must be deterministic for a fixed seed");
// Bank topk is deterministic too.
let mut bank = EstimatorBank::new(Rotation::new(7, dim));
for id in 0..16u32 {
let v: Vec<f32> = (0..dim).map(|i| ((i + id as usize) as f32 * 0.07).sin()).collect();
bank.insert_embedding(id, &v);
}
let a = bank.topk_estimated(&qv, 5);
let b = bank.topk_estimated(&qv, 5);
assert_eq!(a, b, "topk_estimated must be deterministic");
}
#[test]
fn estimator_unbiased_on_fixture() {
// The core unbiasedness claim: averaging the estimate of ⟨o_r, q_r⟩ over
// MANY random rotation seeds converges to the true inner product.
//
// Hand-checkable small case: two fixed vectors, known true inner
// product, average the estimator over many seeds and assert it lands
// within a tolerance that a BIASED estimator would miss.
let dim = 32;
let o: Vec<f32> = (0..dim).map(|i| ((i % 7) as f32 - 3.0) * 0.4 + 0.5).collect();
let qv: Vec<f32> = (0..dim).map(|i| ((i % 5) as f32 - 2.0) * 0.3 - 0.1).collect();
let truth = true_inner(&o, &qv);
let n_seeds = 4000u64;
let mut acc = 0.0f64;
for seed in 0..n_seeds {
let rot = Rotation::new(seed.wrapping_mul(0x9E37_79B9_7F4A_7C15) ^ 0xABCD, dim);
let sk = EstimatorSketch::from_embedding(&o, &rot);
let q = EstimatorQuery::new(&qv, &rot);
acc += DistanceEstimator::estimate_inner_product(&sk, &q) as f64;
}
let mean = (acc / n_seeds as f64) as f32;
// Tolerance scaled to the magnitudes involved. The estimator is
// unbiased, so the Monte-Carlo mean must be CLOSE to truth; a sign-only
// Hamming proxy (or a biased rescale) would be systematically off.
let scale = l2(&o) * l2(&qv);
let tol = 0.06 * scale; // ~6% of the ‖o‖‖q‖ envelope over 4000 seeds
assert!(
(mean - truth).abs() < tol,
"estimator biased: mean={mean:.4} truth={truth:.4} tol={tol:.4} (scale={scale:.4})"
);
}
#[test]
fn estimator_self_distance_is_small() {
// Estimating the distance of a vector to itself should be ~0 (the
// estimate of ⟨o,o⟩ ≈ ‖o‖², so ‖q-o‖² ≈ 0). Not exactly 0 (1-bit code),
// but small relative to ‖o‖².
let dim = 128;
let rot = Rotation::new(0xBEEF_CAFE, dim);
let o: Vec<f32> = (0..dim).map(|i| (i as f32 * 0.37).cos() + 0.2).collect();
let sk = EstimatorSketch::from_embedding(&o, &rot);
let q = EstimatorQuery::new(&o, &rot);
let sq = DistanceEstimator::estimate_sq_distance(&sk, &q);
let o_norm_sq = l2(&o) * l2(&o);
assert!(
sq.abs() < 0.25 * o_norm_sq,
"self sq-distance estimate {sq:.3} too large vs ‖o‖²={o_norm_sq:.3}"
);
}
#[test]
fn side_info_is_eight_bytes() {
assert_eq!(EstimatorSketch::SIDE_INFO_BYTES, 8);
}
#[test]
fn x_dot_o_in_unit_range() {
// ⟨x̄, o'⟩ ∈ (0, 1] for any non-zero input (it's the cosine between the
// rotated residual and its nearest hypercube corner).
let dim = 96;
let rot = Rotation::new(0x1357_9BDF, dim);
for s in 0..20u32 {
let v: Vec<f32> = (0..dim).map(|i| (((i + s as usize) * 13 % 23) as f32 - 11.0) * 0.2).collect();
let sk = EstimatorSketch::from_embedding(&v, &rot);
let x = sk.side_info().x_dot_o;
assert!(x > 0.0 && x <= 1.0 + 1e-5, "x_dot_o out of (0,1]: {x}");
}
}
#[test]
fn zero_input_does_not_panic() {
let dim = 64;
let rot = Rotation::new(1, dim);
let sk = EstimatorSketch::from_embedding(&vec![0.0f32; dim], &rot);
assert_eq!(sk.residual_norm(), 0.0);
let q = EstimatorQuery::new(&vec![1.0f32; dim], &rot);
// No divide-by-zero; degenerate estimate is 0 inner product.
assert_eq!(DistanceEstimator::estimate_inner_product(&sk, &q), 0.0);
}
#[test]
fn centroid_path_self_query_ranks_self_first() {
// The paper-faithful centroid path (o_r = o c) must still rank a
// stored vector first when queried with itself, with a shared centroid.
let dim = 64;
let rot = Rotation::new(0x9999, dim);
let centroid: Vec<f32> = (0..dim).map(|i| (i as f32 * 0.05).sin()).collect();
let mut bank = EstimatorBank::with_centroid(rot, centroid.clone());
let target: Vec<f32> = (0..dim).map(|i| (i as f32 * 0.23).cos() + 1.5).collect();
bank.insert_embedding(7, &target);
for id in 0..24u32 {
let v: Vec<f32> = (0..dim)
.map(|i| ((i as f32 + id as f32) * 0.09).sin() + 1.4)
.collect();
bank.insert_embedding(id, &v);
}
let top = bank.topk_estimated_cosine(&target, 1);
assert_eq!(top.len(), 1);
assert_eq!(top[0].0, 7, "centroid-path self-query should rank self first");
}
#[test]
fn centroid_zero_matches_default() {
// from_embedding_centred(None) must be byte-identical to from_embedding.
let dim = 48;
let rot = Rotation::new(0x4242, dim);
let v: Vec<f32> = (0..dim).map(|i| (i as f32 * 0.3).sin() - 0.1).collect();
let a = EstimatorSketch::from_embedding(&v, &rot);
let b = EstimatorSketch::from_embedding_centred(&v, &rot, None);
assert_eq!(a.residual_norm(), b.residual_norm());
assert_eq!(a.side_info(), b.side_info());
}
#[test]
fn bank_self_query_ranks_self_first() {
// A bank queried with one of its own stored vectors should rank that id
// first under the estimator (its estimated distance to itself is the
// smallest).
let dim = 128;
let rot = Rotation::new(0xABCD_1234, dim);
let mut bank = EstimatorBank::new(rot);
let target: Vec<f32> = (0..dim).map(|i| (i as f32 * 0.19).sin() * 2.0).collect();
bank.insert_embedding(99, &target);
for id in 0..32u32 {
let v: Vec<f32> = (0..dim)
.map(|i| ((i as f32 + id as f32 * 3.0) * 0.05).cos())
.collect();
bank.insert_embedding(id, &v);
}
let top = bank.topk_estimated(&target, 1);
assert_eq!(top.len(), 1);
assert_eq!(top[0].0, 99, "self-query should rank the stored self first");
}
}
@@ -28,13 +28,20 @@
#[cfg(feature = "crv")]
pub mod crv;
pub mod coverage;
pub mod estimator;
pub mod event_log;
pub mod mat;
pub mod rotation;
pub mod signal;
pub mod sketch;
pub mod viewpoint;
pub use estimator::{
DistanceEstimator, EstimatorBank, EstimatorQuery, EstimatorSketch, SideInfo,
};
pub use event_log::{NoveltyEvent, PrivacyEventLog};
pub use rotation::Rotation;
pub use sketch::{
Sketch, SketchBank, SketchError, WireSketch, WireSketchError, WIRE_SKETCH_FORMAT_VERSION,
WIRE_SKETCH_MAGIC, WIRE_SKETCH_MAX_BYTES,
@@ -0,0 +1,373 @@
//! RaBitQ **Pass 2** — deterministic randomized orthogonal rotation.
//!
//! Implements the "Pass 2" deferred in [`crate::sketch`]'s Pass-1 doc and in
//! [ADR-156 §8](../../../../../docs/adr/ADR-156-ruvector-fusion-beyond-sota.md)
//! (Multi-bit / Extended RaBitQ). The published *RaBitQ* algorithm
//! (Gao & Long, SIGMOD 2024) wraps the 1-bit sign-quantization of Pass 1 with
//! a **randomized orthogonal rotation** `R` applied to every embedding *before*
//! sign-quantization. The rotation decorrelates coordinates so the per-bit sign
//! carries more independent information, which gives both the paper's
//! theoretical error bound and better top-K recall on anisotropic / correlated
//! embedding distributions (exactly the case ADR-084's "Open questions" flagged
//! for skewed spectrogram embeddings).
//!
//! # Why a Fast Hadamard Transform, not a dense d×d matrix
//!
//! A full dense orthogonal matrix `R ∈ ^{d×d}` is **O(d²) memory and O(d²)
//! time per vector**. ADR-084's wire format already provisions for embeddings
//! up to `u16::MAX = 65,535` dimensions; a dense rotation there is ~4.3 G
//! floats (17 GiB) — completely infeasible on the cluster-Pi / edge targets
//! this sketch is built for.
//!
//! Instead we use the **randomized Hadamard transform** (the "HD" construction,
//! a.k.a. a structured JohnsonLindenstrauss / fast-JL rotation):
//!
//! ```text
//! R · x = H · D · x
//! ```
//!
//! where `D` is a diagonal matrix of random ±1 sign flips and `H` is the
//! (normalized) WalshHadamard matrix applied via the **Fast Hadamard
//! Transform (FHT)**. The FHT is `O(d log d)` time and `O(1)` extra memory
//! (in-place butterfly); `D` is `O(d)` memory (one sign per dimension, packed).
//! `H` and `D` are each orthogonal, so `R = H·D` is orthogonal and therefore
//! **norm-preserving** — a hard requirement for a rotation that must not distort
//! relative distances. This is the same fast-orthogonal trick used by Fast-JL,
//! Structured Orthogonal Random Features, and the RaBitQ reference rotation.
//!
//! # Determinism (index-time == query-time)
//!
//! The rotation **must** be identical when the bank is built and when it is
//! queried, or the two sign-quantizations live in different rotated frames and
//! hamming distance becomes meaningless. We therefore derive the ±1 sign flips
//! deterministically from a stored `u64` seed via a SplitMix64 PRNG — **never**
//! an unseeded / OS RNG. Two [`Rotation`]s built from the same `(seed, dim)`
//! produce bit-identical output for the same input (pinned by
//! `rotation_is_deterministic_for_seed`).
//!
//! # Power-of-two padding
//!
//! The FHT is defined on lengths that are powers of two. For a `d` that is not
//! a power of two we pad the (sign-flipped) input with zeros up to the next
//! power of two `m = next_pow2(d)`, run the length-`m` FHT, and then **read back
//! the first `d` coordinates**. Zero-padding + orthogonal `H` keeps the
//! transform norm-preserving on the padded vector; we sign-quantize the first
//! `d` rotated coordinates so the sketch dimension is unchanged from Pass 1
//! (API-compatible: same `embedding_dim`, same packed-byte length, same
//! `SketchBank` schema).
/// A deterministic randomized orthogonal rotation (FHT-based) applied to an
/// embedding before sign-quantization — RaBitQ Pass 2.
///
/// Construct once per `(seed, dim)` and reuse for **every** embedding that goes
/// into the same [`crate::SketchBank`] (and for every query against it). The
/// seed is stored so the rotation is reproducible across processes and runs.
///
/// # Invariants
///
/// - `dim` is the source-embedding dimension (the sketch keeps this dimension).
/// - `padded` is `next_pow2(dim)` — the FHT working length.
/// - `signs` has exactly `padded` entries (`+1.0` / `-1.0`), derived from
/// `seed` via SplitMix64. Padding positions get signs too; they only ever
/// multiply zeros, so their value is irrelevant to the result but they keep
/// the construction uniform.
#[derive(Debug, Clone)]
pub struct Rotation {
/// Source-embedding dimension; the rotated sketch keeps this dimension.
dim: usize,
/// FHT working length = `next_pow2(dim)`.
padded: usize,
/// Random ±1 sign flips (the diagonal `D`), length `padded`.
signs: Vec<f32>,
/// The seed the sign flips were derived from (stored for reproducibility).
seed: u64,
}
impl Rotation {
/// Build a rotation for `dim`-dimensional embeddings from a fixed `seed`.
///
/// The same `(seed, dim)` always yields a bit-identical rotation, so an
/// index built with `Rotation::new(seed, d)` and a query rotated with a
/// freshly-constructed `Rotation::new(seed, d)` agree exactly.
///
/// `dim == 0` yields an identity (empty) rotation — `apply` returns an
/// empty vector — which keeps the constructor total (no panic on a
/// degenerate dimension).
pub fn new(seed: u64, dim: usize) -> Self {
let padded = next_pow2(dim);
let mut signs = Vec::with_capacity(padded);
// SplitMix64: a tiny, well-distributed, fully deterministic PRNG. We
// only need a reproducible stream of bits to pick ±1 per dimension;
// SplitMix64 is the standard seeding generator and is more than
// adequate (and far better-mixed than the LCG used for bench fixtures).
let mut state = seed;
for _ in 0..padded {
state = split_mix64(&mut state);
// Use the top bit of the mixed word to choose the sign.
signs.push(if state >> 63 == 1 { 1.0 } else { -1.0 });
}
Self {
dim,
padded,
signs,
seed,
}
}
/// The seed this rotation was derived from (for serialization / audit).
#[inline]
pub fn seed(&self) -> u64 {
self.seed
}
/// Source-embedding dimension this rotation expects.
#[inline]
pub fn dim(&self) -> usize {
self.dim
}
/// FHT working length (`next_pow2(dim)`).
#[inline]
pub fn padded_dim(&self) -> usize {
self.padded
}
/// Apply the rotation `R = H·D` to `embedding`, returning the first `dim`
/// rotated coordinates.
///
/// If `embedding.len() != dim` the input is treated charitably: it is
/// truncated or zero-extended to `dim` before rotation. This mirrors
/// Pass 1's saturating tolerance and keeps the call total.
///
/// The returned vector has length `self.dim`. Its L2 norm equals the L2
/// norm of the (dim-truncated / zero-extended) input up to floating-point
/// rounding — see [`Rotation::apply`] tests and
/// `rotation_preserves_norm`.
pub fn apply(&self, embedding: &[f32]) -> Vec<f32> {
if self.dim == 0 {
return Vec::new();
}
let mut buf = self.apply_padded(embedding);
// Read back the first `dim` rotated coordinates as the sketch input.
buf.truncate(self.dim);
buf
}
/// Apply the rotation `R = H·D` and return **all `padded_dim` rotated
/// coordinates** (not truncated to `dim`).
///
/// This is the frame the RaBitQ estimator ([`crate::estimator`]) works in:
/// the 1-bit code `x̄ ∈ {±1/√D}^D` is unit over the **padded** length `D`,
/// and the query dot product `⟨x̄, q'⟩` must be taken over that same `D`. For
/// a power-of-two `dim`, `padded_dim == dim` and this equals
/// [`Rotation::apply`]; for a non-power-of-two `dim` the tail coordinates
/// (the zero-padded energy redistributed by the FHT) are retained here but
/// dropped by `apply`.
///
/// `dim == 0` yields an empty vector. Ragged input is handled charitably
/// (truncate / zero-extend to `dim`), as in [`Rotation::apply`].
pub fn apply_padded(&self, embedding: &[f32]) -> Vec<f32> {
if self.dim == 0 {
return Vec::new();
}
// Build the padded, sign-flipped working buffer: buf = D · x, then 0-pad.
let mut buf = vec![0.0f32; self.padded];
let n = embedding.len().min(self.dim);
for i in 0..n {
buf[i] = embedding[i] * self.signs[i];
}
// (positions n..dim and dim..padded stay zero — zero-extend + pad)
// In-place normalized Fast Hadamard Transform.
fht_normalized(&mut buf);
buf
}
}
/// Smallest power of two `>= n` (with `next_pow2(0) == 1`, `next_pow2(1) == 1`).
///
/// Pulled out (and `pub(crate)`) so the sketch layer and tests can reason about
/// the FHT working length without duplicating the rule.
#[inline]
pub(crate) fn next_pow2(n: usize) -> usize {
if n <= 1 {
return 1;
}
// `n` here is small relative to usize::MAX in every realistic embedding
// (<= 65_535), so `next_power_of_two` cannot overflow.
n.next_power_of_two()
}
/// SplitMix64 step: advance `state` and return a well-mixed 64-bit word.
///
/// Reference algorithm (public domain, by Sebastiano Vigna). Deterministic and
/// dependency-free — exactly what we need for a reproducible sign stream.
#[inline]
fn split_mix64(state: &mut u64) -> u64 {
*state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = *state;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
}
/// In-place **normalized** Fast Hadamard Transform on a power-of-two slice.
///
/// Computes `y = (1/√m) · H_m · x` in place, where `H_m` is the `m × m`
/// WalshHadamard matrix and `m = buf.len()` is a power of two. The `1/√m`
/// normalization makes `H` orthogonal (`HᵀH = I`), so the transform preserves
/// the L2 norm. Runs in `O(m log m)` with `O(1)` extra memory (the standard
/// iterative butterfly).
///
/// # Panics
///
/// Debug-asserts that `buf.len()` is a power of two. Callers in this module
/// always pass `next_pow2(dim)`, so this never fires in practice; it documents
/// the precondition.
fn fht_normalized(buf: &mut [f32]) {
let m = buf.len();
debug_assert!(m.is_power_of_two(), "FHT length must be a power of two");
if m <= 1 {
return;
}
// Unnormalized in-place WalshHadamard butterfly.
let mut h = 1usize;
while h < m {
let mut i = 0usize;
while i < m {
for j in i..i + h {
let x = buf[j];
let y = buf[j + h];
buf[j] = x + y;
buf[j + h] = x - y;
}
i += h * 2;
}
h *= 2;
}
// Normalize by 1/√m so H is orthogonal (norm-preserving).
let inv_sqrt_m = 1.0f32 / (m as f32).sqrt();
for v in buf.iter_mut() {
*v *= inv_sqrt_m;
}
}
#[cfg(test)]
mod tests {
use super::*;
fn l2(v: &[f32]) -> f32 {
v.iter().map(|&x| x * x).sum::<f32>().sqrt()
}
#[test]
fn next_pow2_rounds_up() {
assert_eq!(next_pow2(0), 1);
assert_eq!(next_pow2(1), 1);
assert_eq!(next_pow2(2), 2);
assert_eq!(next_pow2(3), 4);
assert_eq!(next_pow2(128), 128);
assert_eq!(next_pow2(129), 256);
assert_eq!(next_pow2(200), 256);
assert_eq!(next_pow2(65_535), 65_536);
}
#[test]
fn fht_is_norm_preserving_on_power_of_two() {
// Pure FHT (no sign flips) must preserve L2 norm to fp tolerance.
let mut v: Vec<f32> = (0..8).map(|i| (i as f32 - 3.5) * 0.7).collect();
let before = l2(&v);
fht_normalized(&mut v);
let after = l2(&v);
assert!(
(before - after).abs() < 1e-5,
"FHT changed norm: {before} -> {after}"
);
}
#[test]
fn fht_self_inverse_normalized() {
// Normalized H is symmetric and orthogonal, so H·H·x == x.
let original: Vec<f32> = vec![1.0, -2.0, 3.0, 0.5];
let mut v = original.clone();
fht_normalized(&mut v);
fht_normalized(&mut v);
for (a, b) in original.iter().zip(v.iter()) {
assert!((a - b).abs() < 1e-5, "H·H·x != x: {a} vs {b}");
}
}
#[test]
fn rotation_is_deterministic_for_seed() {
// Two rotations from the same (seed, dim) must produce identical
// output for the same input — the index-time == query-time contract.
let r1 = Rotation::new(0xDEAD_BEEF_CAFE_1234, 130);
let r2 = Rotation::new(0xDEAD_BEEF_CAFE_1234, 130);
let x: Vec<f32> = (0..130).map(|i| (i as f32 * 0.31).sin()).collect();
let a = r1.apply(&x);
let b = r2.apply(&x);
assert_eq!(a.len(), 130);
assert_eq!(a, b, "same seed must give identical rotation");
// A different seed must (almost surely) differ.
let r3 = Rotation::new(0x0000_0000_0000_0001, 130);
let c = r3.apply(&x);
assert_ne!(a, c, "different seed must give different rotation");
}
#[test]
fn rotation_preserves_norm() {
// R = H·D is orthogonal; on a power-of-two dim the first `dim`
// coordinates ARE the whole transform, so norm is preserved exactly
// (to fp tolerance). We test a power-of-two dim for the exact claim.
let r = Rotation::new(42, 128);
let x: Vec<f32> = (0..128).map(|i| ((i * 7 % 13) as f32 - 6.0) * 0.5).collect();
let y = r.apply(&x);
let before = l2(&x);
let after = l2(&y);
assert!(
(before - after).abs() < 1e-3 * before.max(1.0),
"rotation changed norm: {before} -> {after}"
);
}
#[test]
fn rotation_non_power_of_two_preserves_norm_via_padding() {
// For a non-power-of-two dim, reading back the first `dim` coords of a
// padded FHT only preserves norm if the padded tail carries ~no energy.
// We assert the rotated norm does not EXCEED the input norm (the padded
// transform is non-expansive on the truncated read-back) and stays
// within a loose band — enough to confirm padding is sane, not a hard
// exact-norm claim.
let r = Rotation::new(7, 130); // pads 130 -> 256
assert_eq!(r.padded_dim(), 256);
let x: Vec<f32> = (0..130).map(|i| (i as f32 * 0.13).cos()).collect();
let y = r.apply(&x);
assert_eq!(y.len(), 130);
let before = l2(&x);
let after = l2(&y);
// Truncated read-back is non-expansive: ||y|| <= ||Hx|| == ||x||.
assert!(
after <= before + 1e-4,
"truncated rotation expanded norm: {before} -> {after}"
);
}
#[test]
fn rotation_dim_zero_is_empty() {
let r = Rotation::new(1, 0);
assert!(r.apply(&[]).is_empty());
assert!(r.apply(&[1.0, 2.0]).is_empty());
}
#[test]
fn rotation_handles_ragged_input() {
// Charitable length handling: short input zero-extends, long truncates.
let r = Rotation::new(99, 64);
let short = r.apply(&[1.0, 2.0, 3.0]); // zero-extended to 64
assert_eq!(short.len(), 64);
let long: Vec<f32> = (0..200).map(|i| i as f32).collect();
let truncated = r.apply(&long); // truncated to 64
assert_eq!(truncated.len(), 64);
}
}
+306 -22
View File
@@ -40,8 +40,8 @@
//! All sites take a `&Sketch` instead of an `&[f32]`; the bridge to dense
//! embeddings is `Sketch::from_embedding`.
use crate::rotation::Rotation;
use ruvector_core::quantization::{BinaryQuantized, QuantizedVector};
use std::cmp::Reverse;
use std::collections::BinaryHeap;
/// Errors raised by the sketch API.
@@ -151,6 +151,42 @@ impl Sketch {
Ok(Self::from_embedding(embedding, sketch_version))
}
/// Construct a sketch from a dense f32 embedding **with RaBitQ Pass 2
/// rotation** ([ADR-156 §8](../../../../../docs/adr/ADR-156-ruvector-fusion-beyond-sota.md)).
///
/// Applies the deterministic randomized orthogonal rotation `R = H·D`
/// (Fast Hadamard Transform + seeded ±1 sign flips, see [`Rotation`]) to
/// the embedding *before* sign-quantization. The rotation decorrelates
/// coordinates so each sign bit carries more independent information,
/// improving top-K recall on anisotropic / correlated embedding
/// distributions — the published RaBitQ construction.
///
/// The resulting sketch has the **same `embedding_dim`, packed-byte
/// length, and `sketch_version`** as a Pass-1 sketch of the same input, so
/// it is fully interchangeable in [`SketchBank`] and [`WireSketch`]. The
/// *only* requirement is that the index and the query use the **same
/// [`Rotation`]** (same seed + dim) — otherwise their sign bits live in
/// different rotated frames and the hamming distance is meaningless.
///
/// Pass-1 (`from_embedding`) and Pass-2 sketches must **not** be mixed in
/// one bank. Use [`SketchBank::with_rotation`] to make a bank that rotates
/// every insert and query consistently.
pub fn from_embedding_rotated(
embedding: &[f32],
sketch_version: u16,
rotation: &Rotation,
) -> Self {
let rotated = rotation.apply(embedding);
// Preserve the *source* embedding_dim semantics of Pass 1 (saturating
// to u16::MAX) so banks/wire framing are byte-identical to Pass 1.
let embedding_dim = embedding.len().min(u16::MAX as usize) as u16;
Self {
inner: BinaryQuantized::quantize(&rotated),
embedding_dim,
sketch_version,
}
}
/// Hamming distance to another sketch in `[0, embedding_dim]`.
///
/// Returns `None` if the two sketches have different `embedding_dim` or
@@ -417,29 +453,113 @@ pub struct SketchBank {
embedding_dim: Option<u16>,
/// Locked at first insertion; all subsequent inserts must match.
sketch_version: Option<u16>,
/// Optional RaBitQ Pass-2 rotation ([ADR-156 §8]). When `Some`, the
/// embedding-taking helpers ([`SketchBank::insert_embedding`],
/// [`SketchBank::topk_embedding`], [`SketchBank::novelty_embedding`])
/// rotate every embedding through this exact rotation before sketching, so
/// index-time and query-time sketches always share one rotated frame. The
/// raw [`SketchBank::insert`] / [`SketchBank::topk`] paths are unchanged —
/// callers using pre-built sketches are responsible for having rotated them
/// with the same `Rotation`.
rotation: Option<Rotation>,
}
impl SketchBank {
/// Create an empty bank. Dimension and version are locked at the first
/// `insert` call.
/// `insert` call. No Pass-2 rotation (pure Pass-1, default behaviour).
pub fn new() -> Self {
Self {
entries: Vec::new(),
embedding_dim: None,
sketch_version: None,
rotation: None,
}
}
/// Create a bank with a pre-locked `embedding_dim` and `sketch_version`.
/// Use when the bank's expected schema is known at construction.
/// No Pass-2 rotation (pure Pass-1).
pub fn with_schema(embedding_dim: u16, sketch_version: u16) -> Self {
Self {
entries: Vec::new(),
embedding_dim: Some(embedding_dim),
sketch_version: Some(sketch_version),
rotation: None,
}
}
/// Create a **RaBitQ Pass-2** bank that rotates every embedding through
/// `rotation` before sketching ([ADR-156 §8]).
///
/// Use the embedding-taking helpers ([`SketchBank::insert_embedding`],
/// [`SketchBank::topk_embedding`], [`SketchBank::novelty_embedding`]) with
/// this bank so the index and queries share the same rotated frame. The
/// `embedding_dim` / `sketch_version` schema is still locked at first
/// insert exactly as for a Pass-1 bank — a Pass-2 sketch is byte-identical
/// in shape to a Pass-1 sketch, only its bits differ.
pub fn with_rotation(rotation: Rotation) -> Self {
Self {
entries: Vec::new(),
embedding_dim: None,
sketch_version: None,
rotation: Some(rotation),
}
}
/// The Pass-2 rotation this bank applies to embeddings, if any.
#[inline]
pub fn rotation(&self) -> Option<&Rotation> {
self.rotation.as_ref()
}
/// Sketch a raw embedding using this bank's rotation policy: Pass-2
/// (`from_embedding_rotated`) if the bank has a rotation, else Pass-1
/// (`from_embedding`). The single place index-time and query-time sketching
/// agree on the rotated frame.
fn sketch_embedding(&self, embedding: &[f32], sketch_version: u16) -> Sketch {
match &self.rotation {
Some(r) => Sketch::from_embedding_rotated(embedding, sketch_version, r),
None => Sketch::from_embedding(embedding, sketch_version),
}
}
/// Insert a raw embedding, sketching it through the bank's rotation policy.
/// Convenience wrapper over [`SketchBank::insert`] that guarantees the
/// stored sketch used the same (Pass-1 or Pass-2) frame the queries will.
pub fn insert_embedding(
&mut self,
id: u32,
embedding: &[f32],
sketch_version: u16,
) -> Result<(), SketchError> {
let sketch = self.sketch_embedding(embedding, sketch_version);
self.insert(id, sketch)
}
/// Top-K over a raw query embedding, sketched through the bank's rotation
/// policy. Equivalent to `bank.topk(&bank.sketch(query), k)` but cannot get
/// the rotation frame wrong.
pub fn topk_embedding(
&self,
query: &[f32],
sketch_version: u16,
k: usize,
) -> Result<Vec<(u32, u32)>, SketchError> {
let q = self.sketch_embedding(query, sketch_version);
self.topk(&q, k)
}
/// Novelty of a raw query embedding, sketched through the bank's rotation
/// policy. See [`SketchBank::novelty`].
pub fn novelty_embedding(
&self,
query: &[f32],
sketch_version: u16,
) -> Result<f32, SketchError> {
let q = self.sketch_embedding(query, sketch_version);
self.novelty(&q)
}
/// Number of sketches in the bank.
#[inline]
pub fn len(&self) -> usize {
@@ -523,12 +643,22 @@ impl SketchBank {
});
}
}
// Pass-1.5 optimisation: O(n log k) partial sort via a fixed-size
// max-heap of `Reverse((distance, id))`. The heap's `peek()`
// returns the *largest* of the current best-k. Each candidate is
// compared against the heap top in O(1); only better candidates
// trigger an O(log k) push/pop. Avoids touching the long tail of
// large-distance entries that the truncate would have discarded.
// Partial top-K via a fixed-size **max-heap** of `(distance, id)`.
// `BinaryHeap` is a max-heap, so `peek()` is the *largest* distance
// currently held — the worst of the running best-k. Each candidate is
// O(1)-compared against that worst; only a *smaller* distance triggers
// an O(log k) pop+push, evicting the current worst. The heap therefore
// retains the k *smallest* distances. Total O(n log k), touching the
// long tail only with a single comparison each.
//
// BUG FIX (ADR-156 §8 Pass-2 work): this loop previously used
// `BinaryHeap<Reverse<(d, id)>>` and called the peek "the largest".
// `Reverse` turns the max-heap into a **min-heap**, so `peek()` was the
// *smallest* distance; evicting on `d < worst` then kept the k
// *farthest* neighbours and returned them as "nearest". The pre-existing
// unit tests only exercised the `n <= k` fast path (≤ 3 entries), so the
// inversion went unnoticed until the Pass-2 coverage harness measured
// near-random top-K on n > k. Pinned by `topk_heap_path_returns_nearest`.
//
// Fast path: when n ≤ k there is nothing to discard, so a plain
// collect + sort is faster than building a heap.
@@ -543,28 +673,25 @@ impl SketchBank {
return Ok(scored);
}
let mut heap: BinaryHeap<Reverse<(u32, u32)>> = BinaryHeap::with_capacity(k + 1);
let mut heap: BinaryHeap<(u32, u32)> = BinaryHeap::with_capacity(k + 1);
for (id, sk) in &self.entries {
let d = sk.distance_unchecked(query);
if heap.len() < k {
heap.push(Reverse((d, *id)));
} else if let Some(&Reverse((worst, _))) = heap.peek() {
// L1 hardening (PR #435 review): structural `if let` rather
// than `.expect("heap len == k > 0")`. The branch is
// mathematically unreachable when `heap.len() >= k > 0`,
// but a defensive pattern makes the impossibility a type
// property rather than a runtime invariant. Same hot-path
// cost (one bounds check); zero panic risk.
heap.push((d, *id));
} else if let Some(&(worst, _)) = heap.peek() {
// `peek()` is the largest distance in the best-k (max-heap).
// The `if let` is defensive: when `heap.len() == k > 0` the
// heap is non-empty, so this never takes the `else`. Same
// hot-path cost (one bounds check), zero panic risk.
if d < worst {
heap.pop();
heap.push(Reverse((d, *id)));
heap.push((d, *id));
}
}
}
// Drain heap into a Vec — already in (Reverse) descending order;
// sort to expose ascending-by-distance per the public contract.
let mut scored: Vec<(u32, u32)> =
heap.into_iter().map(|Reverse((d, id))| (id, d)).collect();
// Drain the max-heap and sort ascending-by-distance per the public
// contract (heap drain order is unspecified beyond the root).
let mut scored: Vec<(u32, u32)> = heap.into_iter().map(|(d, id)| (id, d)).collect();
scored.sort_by_key(|&(_, d)| d);
Ok(scored)
}
@@ -653,6 +780,45 @@ mod tests {
assert!(topk[1].1 <= topk[2].1);
}
#[test]
fn topk_heap_path_returns_nearest() {
// Regression for the heap-inversion bug found during ADR-156 §8 Pass-2
// work: with n > k the topk used a min-heap (`Reverse`) but treated its
// peek as the max, so it returned the k *farthest* sketches. Build a
// bank where the answer is unambiguous and assert the genuine nearest
// come back. The OLD code returns the farthest here and fails.
let dim = 64;
let k = 4;
// Query is all-positive (every bit 1).
let query = Sketch::from_embedding(&vec![1.0f32; dim], 1);
let mut bank = SketchBank::new();
// id j has its first `j` dims flipped negative → hamming j to the
// all-positive query. So nearest-4 are ids 0,1,2,3 (hamming 0,1,2,3);
// farthest are 5..8. n = 9 > k = 4 → exercises the heap path.
//
// CRITICAL ordering: insert FARTHEST-FIRST (id 8 down to 0). This fills
// the heap's first k slots with far entries, so the nearest entries
// arrive only after the heap is full and MUST trigger eviction of the
// current worst. The old `Reverse` (min-heap-as-max) bug peeked the
// smallest distance and never evicted, so it kept the first-seen
// (farthest) k and this assertion fails on the old code. Inserting
// nearest-first would mask the bug (the heap fills with the right
// answer by luck), so the order here is load-bearing.
for j in (0..=8u32).rev() {
let mut v = vec![1.0f32; dim];
for d in v.iter_mut().take(j as usize) {
*d = -1.0;
}
bank.insert(j, Sketch::from_embedding(&v, 1)).unwrap();
}
let top = bank.topk(&query, k).unwrap();
assert_eq!(top.len(), k);
let ids: Vec<u32> = top.iter().map(|&(id, _)| id).collect();
let dists: Vec<u32> = top.iter().map(|&(_, d)| d).collect();
assert_eq!(ids, vec![0, 1, 2, 3], "topk must return the NEAREST k, got {ids:?}");
assert_eq!(dists, vec![0, 1, 2, 3], "distances must be the smallest k");
}
#[test]
fn bank_topk_zero_returns_empty() {
let mut bank = SketchBank::new();
@@ -852,4 +1018,122 @@ mod tests {
SketchError::SketchVersionMismatch { .. }
));
}
// ─── ADR-156 §8 / ADR-084 Pass 2 — randomized rotation ───────────────────
#[test]
fn rotated_sketch_has_same_shape_as_pass1() {
// A Pass-2 sketch must be byte-shape-identical to a Pass-1 sketch of
// the same input: same embedding_dim, same packed-byte length, same
// sketch_version. Only the bits differ. This is what lets Pass-2
// sketches travel through the unchanged WireSketch / SketchBank schema.
let v: Vec<f32> = (0..128).map(|i| (i as f32 * 0.21).sin()).collect();
let rot = Rotation::new(0xA5A5_A5A5, 128);
let p1 = Sketch::from_embedding(&v, 3);
let p2 = Sketch::from_embedding_rotated(&v, 3, &rot);
assert_eq!(p1.embedding_dim(), p2.embedding_dim());
assert_eq!(p1.sketch_version(), p2.sketch_version());
assert_eq!(p1.packed_bytes().len(), p2.packed_bytes().len());
// The rotation actually changed the bits (else it would be a no-op on
// this correlated input).
assert_ne!(
p1.packed_bytes(),
p2.packed_bytes(),
"rotation should change the sign bits on correlated input"
);
}
#[test]
fn rotated_sketch_is_deterministic_for_seed() {
// Same (seed, dim) rotation → identical sketch bits across constructions
// (the index-time == query-time contract, at the sketch layer).
let v: Vec<f32> = (0..96).map(|i| ((i * 5 % 11) as f32 - 5.0) * 0.3).collect();
let s1 = Sketch::from_embedding_rotated(&v, 1, &Rotation::new(7, 96));
let s2 = Sketch::from_embedding_rotated(&v, 1, &Rotation::new(7, 96));
assert_eq!(s1.distance_unchecked(&s2), 0, "same seed must agree exactly");
}
#[test]
fn rotated_bank_self_match_is_zero_distance() {
// A rotated bank queried with the same embedding it stored must return
// that id at distance 0 — proves the bank rotates index and query in
// the same frame.
let rot = Rotation::new(0xBEEF, 64);
let mut bank = SketchBank::with_rotation(rot);
let v: Vec<f32> = (0..64).map(|i| (i as f32 * 0.37).cos()).collect();
bank.insert_embedding(42, &v, 1).unwrap();
let top = bank.topk_embedding(&v, 1, 1).unwrap();
assert_eq!(top.len(), 1);
assert_eq!(top[0].0, 42);
assert_eq!(top[0].1, 0, "self-query in a rotated bank must be distance 0");
}
#[test]
fn pass2_coverage_not_worse_than_pass1() {
// The core regression: on a small fixed anisotropic fixture, Pass-2
// (rotation) coverage must be >= Pass-1 coverage. Rotation must not
// *hurt* recall. (We do not assert a hard >= 90% here — that is the
// measurement reported in the ADR, not a unit-test invariant — but we
// do pin that rotation is not a regression.)
use crate::coverage::{measure_pass1, measure_pass2, CoverageParams};
let p = CoverageParams {
n: 512,
n_queries: 32,
n_clusters: 32,
..CoverageParams::aether_default(0x00C0_FFEE)
};
let c1 = measure_pass1(p).coverage;
let c2 = measure_pass2(p, 0x1234_5678_9ABC_DEF0).coverage;
assert!(
c2 + 1e-9 >= c1,
"Pass-2 coverage {c2:.4} regressed below Pass-1 {c1:.4}"
);
}
/// Deterministic, test-runnable coverage measurement that PRINTS the
/// numbers quoted in ADR-084 / ADR-156 §8. Run with `--nocapture` to see:
/// cargo test -p wifi-densepose-ruvector --no-default-features \
/// pass2_coverage_report -- --nocapture
#[test]
fn pass2_coverage_report() {
use crate::coverage::{measure_pass1, measure_pass2, CoverageParams};
let base = CoverageParams::aether_default(0xAD00_0084);
let rot_seed = 0x5EED_C0DE_1234_5678u64;
println!(
"\n=== ADR-156 §8 RaBitQ Pass-2 coverage report (anisotropic synthetic) ==="
);
println!(
"dim={} N={} K={} queries={} master_seed=0x{:X} rotation_seed=0x{:X}",
base.dim, base.n, base.k, base.n_queries, base.seed, rot_seed
);
// Strict bar: candidate_k == K.
let p1 = measure_pass1(base).coverage;
let p2 = measure_pass2(base, rot_seed).coverage;
println!(
"candidate_k=K={:<2} Pass1={:6.2}% Pass2={:6.2}% bar=90% {}",
base.k,
p1 * 100.0,
p2 * 100.0,
if p2 >= 0.90 { "PASS" } else { "BELOW-BAR" }
);
// Over-fetch curve (models fetch C >= K candidates, refine to K).
for &c in &[16usize, 24, 32, 64] {
let pc = CoverageParams {
candidate_k: c,
..base
};
let cp1 = measure_pass1(pc).coverage;
let cp2 = measure_pass2(pc, rot_seed).coverage;
println!(
"candidate_k={:<3} Pass1={:6.2}% Pass2={:6.2}%",
c,
cp1 * 100.0,
cp2 * 100.0
);
}
println!("========================================================================\n");
// Always-true sanity so the test asserts something.
assert!((0.0..=1.0).contains(&p1));
assert!((0.0..=1.0).contains(&p2));
}
}
@@ -6944,8 +6944,12 @@ async fn main() {
eprintln!("Starting training for {} epochs...", args.epochs);
let result = t.run_training(train_data, val_data);
eprintln!("Training complete in {:.1}s", result.total_time_secs);
// ADR-155 §2.1: `best_pck` is RAW-threshold PCK (no torso norm) and
// `best_oks` uses the fake-Gold area=1.0 proxy — NOT the canonical
// hip↔hip `pck_canonical` / COCO OKS. Label them distinctly so the
// printed numbers are never read as claim-grade canonical metrics.
eprintln!(
" Best epoch: {}, PCK@0.2: {:.4}, OKS mAP: {:.4}",
" Best epoch: {}, pck_raw@0.2: {:.4}, oks_map(area=1.0 proxy): {:.4}",
result.best_epoch, result.best_pck, result.best_oks
);
@@ -285,7 +285,24 @@ impl WarmupCosineScheduler {
// ── Validation metrics ─────────────────────────────────────────────────────
/// Percentage of Correct Keypoints at a distance threshold.
/// **RAW-threshold** Percentage of Correct Keypoints — a keypoint is correct
/// iff its raw L2 distance to the target is `≤ thr`, with **NO torso/bbox
/// normalization**.
///
/// # ADR-155 §2.1 / §8 — DIVERGENT from canonical (relabel, do NOT conflate)
///
/// This is **not** the canonical hip↔hip torso-normalized
/// `wifi_densepose_train::pck_canonical`. It is the most divergent PCK in the
/// workspace: an unnormalized raw-distance count (the ADR-155 §1 "PCK-4
/// raw-threshold" class). It drives the live sensing-server CLI's reported
/// `best_pck` (see `Trainer::compute_validation_metrics`, `main.rs` training
/// path), which prints/serializes as `PCK@0.2` — that label is **raw-threshold
/// PCK**, NOT canonical PCK@0.2. ADR-155 Milestone-1 resolves the collision by
/// relabelling the *reported* number (`pck_raw@0.2` in logs/JSON) rather than
/// silently changing this `pub` API's math; unifying onto `pck_canonical`
/// (requires a torso scale + the train crate as a dep) is a tracked §8 backlog
/// item. The ADR-155 §1 table did not enumerate this live `trainer.rs` kernel —
/// flagged here as a missed divergence.
pub fn pck_at_threshold(pred: &[(f32, f32, f32)], target: &[(f32, f32, f32)], thr: f32) -> f32 {
let n = pred.len().min(target.len());
if n == 0 {
@@ -340,6 +357,20 @@ pub fn oks_single(
}
/// Mean OKS over multiple predictions (simplified mAP).
///
/// # ADR-155 §2.1 / §8 — FAKE-GOLD `area = 1.0` (flagged finding, not yet fixed)
///
/// This passes `area = 1.0` to [`oks_single`] — the **exact "fake Gold tier"
/// pattern** ADR-155 §2.1 said it had closed in `ruview_metrics` / the train
/// crate's `compute_oks`. With keypoints in a small coordinate range and
/// `area = 1.0`, every squared distance is tiny relative to `2 σ² area`, so the
/// exponential kernel returns ≈1.0 and the reported OKS is inflated regardless
/// of pose quality. This live sensing-server kernel was **not** in the ADR-155
/// §1 table and is still on the inflating `area = 1.0` path; it drives the live
/// `best_oks` (`main.rs`). Until it is unified onto the canonical
/// pose-extent-derived scale (tracked as an ADR-155 §8 backlog item), the value
/// is relabelled `oks_map(area=1.0 proxy)` everywhere it surfaces and must NOT
/// be read as a claim-grade COCO OKS.
pub fn oks_map(preds: &[Vec<(f32, f32, f32)>], targets: &[Vec<(f32, f32, f32)>]) -> f32 {
let n = preds.len().min(targets.len());
if n == 0 {
@@ -349,6 +380,7 @@ pub fn oks_map(preds: &[Vec<(f32, f32, f32)>], targets: &[Vec<(f32, f32, f32)>])
.iter()
.zip(targets.iter())
.take(n)
// area = 1.0 is the fake-Gold proxy (see fn doc / ADR-155 §8).
.map(|(p, t)| oks_single(p, t, &COCO_KEYPOINT_SIGMAS, 1.0))
.sum();
s / n as f32
@@ -1271,6 +1303,34 @@ mod tests {
fn pck_all_wrong_is_0() {
assert!(pck_at_threshold(&mkp(0.0), &mkp(100.0), 0.2) < 1e-6);
}
/// ADR-155 §2.1 / §8: pin that the live `pck_at_threshold` is **raw-threshold**
/// (no torso normalization) and is therefore a genuinely different metric
/// from the canonical hip↔hip PCK — justifying RELABEL, not silent unify.
///
/// Two scenes with the **same absolute keypoint error** but **different torso
/// sizes** must get the **same** raw PCK (because raw PCK ignores scale),
/// whereas a torso-normalized PCK would score them differently. We assert the
/// raw verdict is scale-invariant: a 0.15-unit error is "correct" at thr=0.2
/// regardless of how far apart the hips are.
#[test]
fn pck_at_threshold_is_raw_unnormalized_not_canonical() {
// Target: one keypoint at origin, vis=1. (Single-joint scene.)
let target = vec![(0.0f32, 0.0f32, 1.0f32)];
// Prediction off by exactly 0.15 in x.
let pred = vec![(0.15f32, 0.0f32, 1.0f32)];
// Raw threshold 0.2: 0.15 ≤ 0.2 ⇒ correct ⇒ PCK 1.0, independent of any
// torso scale (there is none in this kernel).
let raw = pck_at_threshold(&pred, &target, 0.2);
assert!((raw - 1.0).abs() < 1e-6, "raw PCK ignores scale; expected 1.0, got {raw}");
// Same absolute error, tighter raw threshold 0.1: 0.15 > 0.1 ⇒ wrong ⇒ 0.0.
// The verdict is set purely by the absolute distance vs thr — the
// signature of a raw (un-normalized) PCK, NOT canonical torso-relative PCK.
let raw_tight = pck_at_threshold(&pred, &target, 0.1);
assert!(raw_tight < 1e-6, "raw PCK is absolute-distance only; expected 0.0, got {raw_tight}");
}
#[test]
fn oks_perfect_is_1() {
assert!((oks_single(&mkp(0.0), &mkp(0.0), &COCO_KEYPOINT_SIGMAS, 1.0) - 1.0).abs() < 1e-6);
@@ -163,15 +163,26 @@ fn default_lora_epochs() -> u32 {
}
/// Current training status (returned by `GET /api/v1/train/status`).
///
/// NOTE (ADR-155 §2.1): `val_pck` / `best_pck` carry the **torso-HEIGHT** PCK
/// proxy from [`compute_pck_torso_height`] (pixel-space, nose→hip-midpoint),
/// which is **deliberately distinct** from the canonical hip↔hip
/// `wifi_densepose_train::pck_canonical`. The wire field names are kept for
/// API/UI back-compat, but these are torso-height progress proxies, NOT the
/// canonical reported-accuracy PCK@0.2 and must not be conflated with it.
/// `val_oks` is a rough `0.88 × pck` proxy, not a COCO OKS.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TrainingStatus {
pub active: bool,
pub epoch: u32,
pub total_epochs: u32,
pub train_loss: f64,
/// Torso-HEIGHT PCK@0.2 proxy (NOT canonical hip↔hip PCK — see struct doc).
pub val_pck: f64,
/// Rough OKS proxy (`0.88 × val_pck`), NOT a COCO OKS.
pub val_oks: f64,
pub lr: f64,
/// Best torso-HEIGHT PCK@0.2 proxy seen so far (NOT canonical PCK).
pub best_pck: f64,
pub best_epoch: u32,
pub patience_remaining: u32,
@@ -199,13 +210,19 @@ impl Default for TrainingStatus {
}
/// Progress update sent over WebSocket.
///
/// NOTE (ADR-155 §2.1): `val_pck`/`val_oks` are the torso-HEIGHT PCK proxy and
/// its `0.88×` OKS proxy — NOT the canonical hip↔hip `pck_canonical`/COCO OKS.
/// See [`TrainingStatus`] and [`compute_pck_torso_height`].
#[derive(Debug, Clone, Serialize)]
pub struct TrainingProgress {
pub epoch: u32,
pub batch: u32,
pub total_batches: u32,
pub train_loss: f64,
/// Torso-HEIGHT PCK@0.2 proxy (NOT canonical hip↔hip PCK).
pub val_pck: f64,
/// Rough OKS proxy (`0.88 × val_pck`), NOT a COCO OKS.
pub val_oks: f64,
pub lr: f64,
pub phase: String,
@@ -789,19 +806,39 @@ fn compute_mse(predictions: &[Vec<f64>], targets: &[Vec<f64>]) -> f64 {
total / (n * predictions[0].len().max(1) as f64)
}
/// Compute PCK@0.2 (Percentage of Correct Keypoints at threshold 0.2 of torso height).
/// Compute **PCK_torso-height@`threshold`** — a metric DELIBERATELY DISTINCT
/// from the canonical hip↔hip PCK (`wifi_densepose_train::pck_canonical`).
///
/// Torso height is estimated as the distance between nose (kp 0) and the midpoint
/// of the two hips (kps 11, 12).
/// # Why this is `_torso_height`, not the canonical PCK (ADR-155 §2.1 / §8 — RESOLVED)
///
/// NOTE (ADR-155 §Tier-1.1, DEFERRED backlog item): this is a *separate*,
/// torso-HEIGHT-normalized implementation distinct from the canonical hip↔hip
/// `wifi_densepose_train::metrics::pck_canonical`. It drives the live server's
/// in-loop progress display and is NOT the reported-accuracy metric. Unifying
/// it with the canonical definition is tracked as a deferred ADR-155 backlog
/// item — left unchanged here to avoid destabilising the running training
/// service and to keep this milestone scoped to the train/nn subsystem.
fn compute_pck(predictions: &[Vec<f64>], targets: &[Vec<f64>], threshold_ratio: f64) -> f64 {
/// ADR-155 unified the workspace's reported-accuracy PCK to ONE definition:
/// **hip↔hip torso WIDTH**, on `[0,1]`-normalized `[17,2]` keypoints. This
/// live-server function is **not** that metric and must never be conflated
/// with it. It is genuinely different on three load-bearing axes:
///
/// 1. **Coordinate space.** It operates on **pixel-space** teacher targets on a
/// 640×480 canvas (`compute_teacher_targets`), not `[0,1]` MM-Fi coords —
/// hence the `.max(50.0)` *pixel* torso floor below.
/// 2. **Normalization axis.** It normalizes by torso **HEIGHT** (vertical
/// nose→hip-midpoint distance), not canonical torso **WIDTH** (hip↔hip).
/// Routing through `pck_canonical` would silently change which body axis
/// sets the scale, altering every live number this drives.
/// 3. **Layout.** It consumes `[17×3]`-flattened `Vec<Vec<f64>>` (x,y,z), not
/// `ndarray::Array2<f32>`; `wifi-densepose-sensing-server` does not depend on
/// `wifi-densepose-train` or `ndarray`.
///
/// Because the math is load-bearing (a running training service's progress
/// display), ADR-155 Milestone-1 resolves the label collision by **relabelling**
/// rather than forcing a false identity: the function and the metric it produces
/// are named `_torso_height` everywhere they surface (this fn, the log line),
/// and the `val_pck`/`best_pck` API fields document the divergence. The reported
/// in-loop value is a torso-HEIGHT PCK proxy on heuristic teacher targets — it is
/// NOT a claim-grade accuracy number and is NOT the canonical hip↔hip PCK@0.2.
fn compute_pck_torso_height(
predictions: &[Vec<f64>],
targets: &[Vec<f64>],
threshold_ratio: f64,
) -> f64 {
if predictions.is_empty() {
return 0.0;
}
@@ -1166,8 +1203,11 @@ async fn real_training_loop(
let val_preds = forward(val_x, &weights, &bias, n_feat, N_TARGETS);
let val_mse = compute_mse(&val_preds, val_y);
let val_pck = compute_pck(&val_preds, val_y, 0.2);
let val_oks = val_pck * 0.88; // approximate OKS from PCK
// torso-HEIGHT PCK proxy (NOT canonical hip↔hip PCK@0.2 — see
// compute_pck_torso_height / ADR-155 §2.1). Surfaced as `val_pck` for
// wire-format back-compat but is a torso-height proxy, not a claim.
let val_pck = compute_pck_torso_height(&val_preds, val_y, 0.2);
let val_oks = val_pck * 0.88; // rough OKS proxy from torso-height PCK (NOT canonical OKS)
let val_progress = TrainingProgress {
epoch,
@@ -1224,14 +1264,17 @@ async fn real_training_loop(
};
}
// Logs label this `pck_torso_h@0.2` so it is never read as the canonical
// hip↔hip PCK@0.2 (ADR-155 §2.1). It is a torso-HEIGHT proxy on heuristic
// teacher targets, not a claim-grade accuracy number.
info!(
"Epoch {epoch}/{total_epochs}: loss={train_loss:.6}, val_pck={val_pck:.4}, \
val_mse={val_mse:.4}, best_pck={best_pck:.4}@{best_epoch}, patience={patience_remaining}"
"Epoch {epoch}/{total_epochs}: loss={train_loss:.6}, pck_torso_h@0.2={val_pck:.4}, \
val_mse={val_mse:.4}, best_pck_torso_h={best_pck:.4}@{best_epoch}, patience={patience_remaining}"
);
// Early stopping.
if patience_remaining == 0 {
info!("Early stopping at epoch {epoch} (best={best_epoch}, PCK={best_pck:.4})");
info!("Early stopping at epoch {epoch} (best={best_epoch}, pck_torso_h@0.2={best_pck:.4})");
let stop_progress = TrainingProgress {
epoch,
batch: total_batches,
@@ -1368,7 +1411,7 @@ async fn real_training_loop(
error!("Failed to write trained model RVF: {e}");
} else {
info!(
"Trained model saved: {} ({} params, PCK={:.4})",
"Trained model saved: {} ({} params, pck_torso_h@0.2={:.4})",
rvf_path.display(),
total_params,
best_pck
@@ -1969,13 +2012,69 @@ mod tests {
tgt[37] = 100.0; // right hip y
let preds = vec![tgt.clone()];
let targets = vec![tgt];
let pck = compute_pck(&preds, &targets, 0.2);
let pck = compute_pck_torso_height(&preds, &targets, 0.2);
assert!(
(pck - 1.0).abs() < 1e-9,
"Perfect prediction should give PCK=1.0"
);
}
/// ADR-155 §2.1 / §8 (RESOLVED): the live-server PCK is torso-HEIGHT
/// normalized and is **labelled distinctly** from the canonical hip↔hip
/// PCK. This test pins the *divergence*: the same prediction error gives a
/// different verdict under torso-HEIGHT (nose→hip, vertical) than under an
/// independent hip↔hip-WIDTH (horizontal) computation — proving the two are
/// genuinely different metrics, so relabelling (not unifying) is correct.
///
/// Construction (pixel-space, one keypoint of interest = left_shoulder kp5):
/// * nose(0).y = 0, hips(11,12).y = 100 ⇒ torso HEIGHT = 100.
/// ⇒ torso-height threshold @0.2 = 20 px.
/// * hips x: left(11).x = 0, right(12).x = 10 ⇒ torso WIDTH = 10.
/// ⇒ a hip↔hip-WIDTH threshold @0.2 = 2 px.
/// * Predicted kp5 is 5 px off in x from its target.
/// - torso-HEIGHT verdict: 5 ≤ 20 ⇒ CORRECT.
/// - hip↔hip-WIDTH verdict: 5 > 2 ⇒ WRONG.
/// The two normalizers must disagree on this exact sample.
#[test]
fn torso_pck_is_labelled_distinctly_from_canonical() {
// Targets: hips define both axes; kp5 is the joint under test.
let mut tgt = vec![0.0; N_TARGETS];
tgt[0 * 3] = 0.0; // nose x
tgt[0 * 3 + 1] = 0.0; // nose y
tgt[5 * 3] = 0.0; // l_shoulder x (target)
tgt[5 * 3 + 1] = 50.0; // l_shoulder y
tgt[11 * 3] = 0.0; // l_hip x
tgt[11 * 3 + 1] = 100.0; // l_hip y
tgt[12 * 3] = 10.0; // r_hip x ⇒ hip↔hip WIDTH = 10
tgt[12 * 3 + 1] = 100.0; // r_hip y ⇒ torso HEIGHT (nose→hip) = 100
// Prediction: identical except kp5 x is +5 px off.
let mut pred = tgt.clone();
pred[5 * 3] = 5.0; // 5 px error in x on kp5
// Live-server torso-HEIGHT PCK: error 5 ≤ 0.2×100 = 20 ⇒ kp5 counts
// correct, so ALL 17 joints correct ⇒ PCK = 1.0.
let pck_height = compute_pck_torso_height(&[pred.clone()], &[tgt.clone()], 0.2);
assert!(
(pck_height - 1.0).abs() < 1e-9,
"torso-HEIGHT PCK should pass kp5 (5px ≤ 20px), got {pck_height}"
);
// Independent hip↔hip-WIDTH verdict on kp5: error 5 > 0.2×10 = 2 ⇒ kp5
// is WRONG. This is the canonical normalization axis (width, not height).
let hip_width = (tgt[12 * 3] - tgt[11 * 3]).abs(); // = 10
let kp5_err = (pred[5 * 3] - tgt[5 * 3]).abs(); // = 5
let width_threshold = 0.2 * hip_width; // = 2
assert!(
kp5_err > width_threshold,
"hip↔hip-WIDTH should REJECT kp5 (5px > 2px) — the two metrics must disagree"
);
// Therefore torso-HEIGHT PCK (1.0) ≠ the hip↔hip-WIDTH verdict on this
// sample: the live `val_pck` is genuinely a different metric and is
// correctly labelled `pck_torso_h`, never conflated with canonical PCK.
}
#[test]
fn infer_pose_returns_17_keypoints() {
let n_sub = 56;
@@ -71,6 +71,12 @@ harness = false
name = "features_bench"
harness = false
## ADR-154 Milestone-2: P2 "bench-first" perf items (§7.4 #5/#6/#7/#8/#20).
## #8 (field_model eigendecompose) is measured only under the eigenvalue feature.
[[bench]]
name = "dsp_perf_bench"
harness = false
## ADR-134: CIR estimator throughput benchmarks
[[bench]]
name = "cir_bench"
@@ -0,0 +1,353 @@
//! ADR-154 Milestone-2 perf benchmarks (§7.4 P2 "bench-first" items).
//!
//! PROOF discipline (ADR-154 §0): every P2 item is **benched before touched**.
//! A micro-opt is landed only if the bench proves the path hot; otherwise the
//! committed bench *is* the result — a MEASURED-NULL that proves the rewrite was
//! unnecessary (exactly the §5.x "already amortized" pattern). No speedup is
//! claimed without a before/after number from here.
//!
//! Reproduce (compile-only):
//! cargo bench -p wifi-densepose-signal --no-default-features \
//! --bench dsp_perf_bench --no-run
//!
//! Reproduce (full run, writes target/criterion/ HTML):
//! cargo bench -p wifi-densepose-signal --no-default-features --bench dsp_perf_bench
//!
//! Groups:
//! * `multistatic_attention` (#5) — `node_attention_weights` at 2..8 nodes ×
//! 56 subcarriers. Re-derives consensus/softmax each call; no scratch to
//! reuse → expected MEASURED-NULL.
//! * `tomography_reconstruct` (#6) — full ISTA solve. The two voxel buffers are
//! allocated once per `reconstruct()` (then `.fill`-reused across
//! iterations), so the per-solve alloc is 2×n_voxels vs an
//! O(iters·links·voxels) compute → expected MEASURED-NULL.
//! * `pose_kalman_update` (#7) — Kalman predict+update loop. The "gain
//! matrices" are fixed-size **stack** arrays (`[[f32;3];6]`), not heap —
//! nothing to reuse → expected MEASURED-NULL.
//! * `spectrogram_multi_subcarrier` (#20) — `compute_multi_subcarrier_spectrogram`:
//! fresh-planner-per-subcarrier (BEFORE) vs hoisted-plan (AFTER, shipped).
//! The per-subcarrier FFT re-plan is the likely real win.
//! * `field_model_occupancy` (#8, `eigenvalue` only) — per-call n×n
//! eigendecomposition in `estimate_occupancy`. MEASUREMENT-ONLY: quantifies
//! the recompute cost; incremental SVD is a sized future project, not a
//! micro-fix.
use criterion::{black_box, criterion_group, criterion_main, BenchmarkId, Criterion, Throughput};
use ndarray::Array2;
use rustfft::FftPlanner;
use std::f64::consts::PI;
use std::time::Duration;
use wifi_densepose_signal::ruvsense::multistatic::node_attention_weights;
use wifi_densepose_signal::ruvsense::pose_tracker::KeypointState;
use wifi_densepose_signal::ruvsense::tomography::{
LinkGeometry, Position3D, RfTomographer, TomographyConfig,
};
use wifi_densepose_signal::spectrogram::{
compute_multi_subcarrier_spectrogram, compute_spectrogram, Spectrogram, SpectrogramConfig,
WindowFunction,
};
// ---------------------------------------------------------------------------
// #5 multistatic node_attention_weights
// ---------------------------------------------------------------------------
fn make_node_amplitudes(n_nodes: usize, n_sub: usize) -> Vec<Vec<f32>> {
(0..n_nodes)
.map(|n| {
(0..n_sub)
.map(|s| {
let phase = (n as f32 * 0.31 + s as f32 * 0.07) % std::f32::consts::TAU;
0.5 + 0.4 * phase.sin()
})
.collect()
})
.collect()
}
fn bench_multistatic_attention(c: &mut Criterion) {
let mut group = c.benchmark_group("multistatic_attention");
group.measurement_time(Duration::from_secs(3));
let n_sub = 56; // canonical-56 grid
for &n_nodes in &[2usize, 4, 8] {
let owned = make_node_amplitudes(n_nodes, n_sub);
let refs: Vec<&[f32]> = owned.iter().map(|v| v.as_slice()).collect();
group.throughput(Throughput::Elements(1));
group.bench_with_input(
BenchmarkId::new("weights", n_nodes),
&refs,
|b, amplitudes| {
b.iter(|| black_box(node_attention_weights(black_box(amplitudes), 1.0)));
},
);
}
group.finish();
}
// ---------------------------------------------------------------------------
// #6 tomography reconstruct (ISTA L1)
// ---------------------------------------------------------------------------
fn make_tomographer(n_links: usize) -> (RfTomographer, Vec<f64>) {
// A modest 8x8x4 grid (256 voxels), n_links TX/RX pairs around the box.
let config = TomographyConfig {
nx: 8,
ny: 8,
nz: 4,
bounds: [0.0, 0.0, 0.0, 4.0, 4.0, 2.0],
lambda: 0.01,
max_iterations: 50,
tolerance: 1e-6,
min_links: 8,
};
let mut links = Vec::with_capacity(n_links);
for i in 0..n_links {
let t = i as f64 / n_links as f64;
links.push(LinkGeometry {
tx: Position3D {
x: 4.0 * (t * PI).cos().abs(),
y: 0.0,
z: 1.0,
},
rx: Position3D {
x: 4.0 * (t * PI).sin().abs(),
y: 4.0,
z: 1.0,
},
link_id: i,
});
}
let tomo = RfTomographer::new(config, &links).unwrap();
// Deterministic attenuations (one occupied region in the middle).
let attenuations: Vec<f64> = (0..n_links)
.map(|i| 0.1 + 0.05 * ((i as f64 * 0.3).sin()))
.collect();
(tomo, attenuations)
}
fn bench_tomography_reconstruct(c: &mut Criterion) {
let mut group = c.benchmark_group("tomography_reconstruct");
group.measurement_time(Duration::from_secs(4));
for &n_links in &[16usize, 32] {
let (tomo, atten) = make_tomographer(n_links);
group.throughput(Throughput::Elements(1));
group.bench_with_input(
BenchmarkId::new("solve", n_links),
&(tomo, atten),
|b, (tomo, atten)| {
b.iter(|| black_box(tomo.reconstruct(black_box(atten)).unwrap().occupied_count));
},
);
}
group.finish();
}
// ---------------------------------------------------------------------------
// #7 pose tracker Kalman update loop
// ---------------------------------------------------------------------------
fn bench_pose_kalman_update(c: &mut Criterion) {
let mut group = c.benchmark_group("pose_kalman_update");
group.measurement_time(Duration::from_secs(3));
// 17 keypoints (COCO-17), N predict+update cycles — a realistic frame batch.
for &n_updates in &[17usize, 170] {
group.throughput(Throughput::Elements(n_updates as u64));
group.bench_with_input(BenchmarkId::new("cycles", n_updates), &n_updates, |b, &n| {
b.iter(|| {
let mut acc = 0.0_f32;
for k in 0..n {
let mut state = KeypointState::new(
(k as f32 * 0.1).sin(),
(k as f32 * 0.2).cos(),
1.0 + (k as f32 * 0.05),
);
state.predict(0.05, 0.5);
let meas = [
(k as f32 * 0.1).sin() + 0.01,
(k as f32 * 0.2).cos() - 0.01,
1.0 + (k as f32 * 0.05),
];
state.update(&meas, 0.1, 1.0);
acc += state.state[0];
}
black_box(acc)
});
});
}
group.finish();
}
// ---------------------------------------------------------------------------
// #20 multi-subcarrier spectrogram: fresh-planner vs hoisted plan
// ---------------------------------------------------------------------------
fn make_csi_temporal(n_samples: usize, n_sc: usize) -> Array2<f64> {
Array2::from_shape_fn((n_samples, n_sc), |(t, sc)| {
let freq = 0.7 + sc as f64 * 0.13;
(2.0 * PI * freq * t as f64 / 100.0).sin()
+ 0.3 * (2.0 * PI * (freq * 2.1) * t as f64 / 100.0).cos()
})
}
/// BEFORE: re-plan the FFT inside `compute_spectrogram` for every subcarrier.
/// Faithful transcription of the pre-ADR-154-M2 `compute_multi_subcarrier_spectrogram`.
fn multi_fresh_planner(
csi: &Array2<f64>,
sample_rate: f64,
config: &SpectrogramConfig,
) -> Vec<Spectrogram> {
let (_, n_sc) = csi.dim();
(0..n_sc)
.map(|sc| {
let col: Vec<f64> = csi.column(sc).to_vec();
// compute_spectrogram builds a fresh FftPlanner on every call.
compute_spectrogram(&col, sample_rate, config).unwrap()
})
.collect()
}
fn bench_spectrogram_multi_subcarrier(c: &mut Criterion) {
let mut group = c.benchmark_group("spectrogram_multi_subcarrier");
group.measurement_time(Duration::from_secs(5));
let sample_rate = 100.0;
// Realistic: 600 temporal samples (~6 s @ 100 Hz) across 56 subcarriers,
// window 128. n_sc re-plans removed by the hoist.
for &(n_samples, n_sc, window) in &[(600usize, 56usize, 128usize), (600, 56, 256)] {
let csi = make_csi_temporal(n_samples, n_sc);
let config = SpectrogramConfig {
window_size: window,
hop_size: 64,
window_fn: WindowFunction::Hann,
power: true,
};
group.throughput(Throughput::Elements(n_sc as u64));
// BEFORE: fresh planner per subcarrier.
group.bench_with_input(
BenchmarkId::new("fresh_planner", format!("sc{n_sc}_w{window}")),
&config,
|b, cfg| {
b.iter(|| black_box(multi_fresh_planner(black_box(&csi), sample_rate, cfg).len()));
},
);
// AFTER: hoisted plan (the shipped `compute_multi_subcarrier_spectrogram`).
group.bench_with_input(
BenchmarkId::new("hoisted_plan", format!("sc{n_sc}_w{window}")),
&config,
|b, cfg| {
b.iter(|| {
black_box(
compute_multi_subcarrier_spectrogram(black_box(&csi), sample_rate, cfg)
.unwrap()
.len(),
)
});
},
);
}
group.finish();
}
// A standalone FftPlanner sanity micro-bench documenting the cost the hoist
// removes: building+planning a length-N forward FFT once.
fn bench_fft_plan_cost(c: &mut Criterion) {
let mut group = c.benchmark_group("fft_plan_cost");
group.measurement_time(Duration::from_secs(2));
for &n in &[128usize, 256] {
group.bench_with_input(BenchmarkId::new("plan_forward", n), &n, |b, &n| {
b.iter(|| {
let mut planner = FftPlanner::<f64>::new();
black_box(planner.plan_fft_forward(black_box(n)))
});
});
}
group.finish();
}
// ---------------------------------------------------------------------------
// #8 field_model SVD/eigendecomposition recompute (MEASUREMENT-ONLY)
// ---------------------------------------------------------------------------
// `estimate_occupancy` builds an n×n covariance and eigendecomposes it on every
// call (BLAS, `eigenvalue` feature). This bench quantifies that per-call cost so
// ADR-154 §7.4 #8 can record a number; incremental SVD is a sized future item,
// NOT attempted here.
#[cfg(feature = "eigenvalue")]
mod eig {
use super::*;
use wifi_densepose_signal::ruvsense::field_model::{FieldModel, FieldModelConfig};
fn calibrated_model(n_sub: usize, n_links: usize) -> FieldModel {
let config = FieldModelConfig {
n_subcarriers: n_sub,
n_links,
n_modes: 3,
min_calibration_frames: 20,
baseline_expiry_s: 86_400.0,
};
let mut model = FieldModel::new(config).unwrap();
// Feed deterministic calibration frames: [n_links][n_sub] per observation.
for f in 0..30 {
let obs: Vec<Vec<f64>> = (0..n_links)
.map(|l| {
(0..n_sub)
.map(|s| {
0.5 + 0.3
* ((f as f64 * 0.1 + l as f64 * 0.2 + s as f64 * 0.05).sin())
})
.collect()
})
.collect();
model.feed_calibration(&obs).unwrap();
}
model.finalize_calibration(0, 0).unwrap();
model
}
pub fn bench_field_model_occupancy(c: &mut Criterion) {
let mut group = c.benchmark_group("field_model_occupancy");
group.measurement_time(Duration::from_secs(4));
let n_sub = 56;
let model = calibrated_model(n_sub, 4);
// Sliding window of recent frames (50 ~ 2.5 s @ 20 Hz).
let frames: Vec<Vec<f64>> = (0..50)
.map(|t| {
(0..n_sub)
.map(|s| 0.5 + 0.3 * ((t as f64 * 0.15 + s as f64 * 0.07).sin()))
.collect()
})
.collect();
group.throughput(Throughput::Elements(1));
group.bench_function(BenchmarkId::new("eigh", n_sub), |b| {
b.iter(|| black_box(model.estimate_occupancy(black_box(&frames))));
});
group.finish();
}
}
#[cfg(feature = "eigenvalue")]
criterion_group!(
benches,
bench_multistatic_attention,
bench_tomography_reconstruct,
bench_pose_kalman_update,
bench_spectrogram_multi_subcarrier,
bench_fft_plan_cost,
eig::bench_field_model_occupancy,
);
#[cfg(not(feature = "eigenvalue"))]
criterion_group!(
benches,
bench_multistatic_attention,
bench_tomography_reconstruct,
bench_pose_kalman_update,
bench_spectrogram_multi_subcarrier,
bench_fft_plan_cost,
);
criterion_main!(benches);
@@ -197,4 +197,61 @@ mod tests {
Err(CsiRatioError::LengthMismatch { .. })
));
}
// ADR-154 §7.4 #19: the CSI *ratio model*. The classic ratio is
// `H_i[k] / H_j[k]`, which blows up (±inf / NaN) when `H_j[k]` approaches
// zero — the case a `1e-12` division-guard epsilon is meant to protect. This
// module deliberately implements the ratio as the **conjugate product**
// `H_i * conj(H_j)` (SpotFi/IndoTrack), which has *no division* and is
// therefore finite even at and below the `1e-12` magnitude boundary. This
// test pins that property: at the epsilon boundary the output is finite and
// exactly the conjugate product (no silent NaN/inf from a hidden divide).
#[test]
fn ratio_finite_at_and_below_1e_12_epsilon() {
let eps = 1e-12_f64;
// Reference at unit magnitude; target swept across / under the epsilon
// boundary a naive H_i/H_j division would need to guard.
let h_ref = vec![
Complex64::from_polar(1.0, 0.3),
Complex64::from_polar(1.0, 0.3),
Complex64::from_polar(1.0, 0.3),
Complex64::from_polar(1.0, 0.3),
];
let h_target = vec![
Complex64::new(eps, 0.0), // exactly at the epsilon
Complex64::new(eps * 0.5, 0.0), // below the epsilon
Complex64::new(0.0, eps), // imaginary axis, at epsilon
Complex64::new(0.0, 0.0), // exact zero — div would be inf/NaN
];
let ratio = conjugate_multiply(&h_ref, &h_target).unwrap();
assert_eq!(ratio.len(), 4);
for (k, r) in ratio.iter().enumerate() {
assert!(
r.re.is_finite() && r.im.is_finite(),
"conjugate-multiply ratio must be finite at boundary k={k}: {r:?}"
);
}
// The near-zero / zero target collapses the product toward zero (the
// physically correct "no measurable path" answer), never to inf/NaN.
assert!(
ratio[3].norm() == 0.0,
"exact-zero target → zero product, got {}",
ratio[3].norm()
);
// The at-epsilon entries equal the exact conjugate product (bit-exact).
let expected0 = h_ref[0] * h_target[0].conj();
assert_eq!(ratio[0].re.to_bits(), expected0.re.to_bits());
assert_eq!(ratio[0].im.to_bits(), expected0.im.to_bits());
// The full pipeline (amplitude/phase extraction) is also finite here.
let mut m = Array2::<Complex64>::zeros((1, 4));
for (k, &v) in ratio.iter().enumerate() {
m[[0, k]] = v;
}
let (amp, phase) = ratio_to_amplitude_phase(&m);
assert!(amp.iter().all(|a| a.is_finite()));
assert!(phase.iter().all(|p| p.is_finite()));
}
}
+57 -2
View File
@@ -43,11 +43,22 @@ pub struct HampelResult {
/// MAD = 0.6745 * σσ = MAD / 0.6745 = 1.4826 * MAD
const MAD_SCALE: f64 = 1.4826;
/// Zero-MAD epsilon (ADR-154 §7.4 — de-magicked). When the estimated σ falls
/// at/below this, the window is treated as constant (degenerate MAD): any
/// deviation larger than this same epsilon flags the sample as an outlier.
/// Empirical guard against an all-equal window, not a tuned operating point.
const ZERO_MAD_EPSILON: f64 = 1e-15;
/// Apply Hampel filter to a 1D signal.
///
/// For each sample, computes the median and MAD of the surrounding window.
/// If the sample deviates from the median by more than `threshold * σ_est`,
/// it is replaced with the median.
///
/// # Errors
/// - [`HampelError::EmptySignal`] if `signal` is empty.
/// - [`HampelError::InvalidWindow`] if `config.half_window == 0` (a window of
/// one sample has zero MAD and cannot estimate σ).
pub fn hampel_filter(signal: &[f64], config: &HampelConfig) -> Result<HampelResult, HampelError> {
if signal.is_empty() {
return Err(HampelError::EmptySignal);
@@ -75,13 +86,13 @@ pub fn hampel_filter(signal: &[f64], config: &HampelConfig) -> Result<HampelResu
sigma_estimates.push(sigma);
let deviation = (signal[i] - med).abs();
let is_outlier = if sigma > 1e-15 {
let is_outlier = if sigma > ZERO_MAD_EPSILON {
// Normal case: compare deviation to threshold * sigma
deviation > config.threshold * sigma
} else {
// Zero-MAD case: all window values identical except possibly this sample.
// Any non-zero deviation from the median is an outlier.
deviation > 1e-15
deviation > ZERO_MAD_EPSILON
};
if is_outlier {
@@ -233,4 +244,48 @@ mod tests {
Err(HampelError::EmptySignal)
));
}
// -- ADR-154 §7.4: de-magic-constant + boundary characterization tests.
/// De-magicked zero-MAD epsilon must equal the prior literal.
#[test]
fn zero_mad_epsilon_unchanged_from_literal() {
assert_eq!(ZERO_MAD_EPSILON, 1e-15);
assert_eq!(MAD_SCALE, 1.4826);
}
/// `half_window == 0` is the documented invalid-window boundary; pins the
/// previously-untested error path.
#[test]
fn test_zero_half_window_error() {
let config = HampelConfig {
half_window: 0,
threshold: 3.0,
};
assert!(matches!(
hampel_filter(&[1.0, 2.0, 3.0], &config),
Err(HampelError::InvalidWindow)
));
// half_window = 1 is the smallest valid window.
let ok = HampelConfig {
half_window: 1,
threshold: 3.0,
};
assert!(hampel_filter(&[1.0, 2.0, 3.0], &ok).is_ok());
}
/// Zero-MAD (constant) window: a single deviating sample is flagged via the
/// degenerate-MAD branch; a fully constant signal flags nothing.
#[test]
fn test_zero_mad_constant_window() {
// Fully constant -> no outliers (deviation is 0, not > epsilon).
let constant = vec![5.0; 20];
let r = hampel_filter(&constant, &HampelConfig::default()).unwrap();
assert!(r.outlier_indices.is_empty());
// A single spike in an otherwise-constant signal -> flagged.
let mut spiked = vec![5.0; 20];
spiked[10] = 5.5;
let r = hampel_filter(&spiked, &HampelConfig::default()).unwrap();
assert!(r.outlier_indices.contains(&10));
}
}
+205 -20
View File
@@ -8,6 +8,66 @@ use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::VecDeque;
// ---------------------------------------------------------------------------
// Tuning constants (ADR-154 §7.4 #18 — de-magicked; EMPIRICAL DEFAULTS).
//
// These were previously bare literals inside the scoring functions. They are
// lifted to named, documented consts so the implicit weighting becomes
// explicit and a future retune is a visible, tested change. The values are
// **unchanged** from the original literals — boundary/characterization tests
// pin the current behaviour. None of these is calibrated against labelled
// occupancy data; they are heuristic fusion weights.
// ---------------------------------------------------------------------------
/// Motion-score fusion weights when a Doppler component is present.
/// `(variance, correlation, phase, doppler)` — sums to 1.0.
const MOTION_WEIGHTS_WITH_DOPPLER: (f64, f64, f64, f64) = (0.3, 0.2, 0.2, 0.3);
/// Motion-score fusion weights with no Doppler component.
/// `(variance, correlation, phase)` — sums to 1.0.
const MOTION_WEIGHTS_NO_DOPPLER: (f64, f64, f64) = (0.4, 0.3, 0.3);
/// Doppler magnitude (Hz-ish, arbitrary units) that maps to a full-scale
/// (1.0) Doppler motion component. Larger magnitudes saturate at 1.0.
const DOPPLER_FULL_SCALE_MAGNITUDE: f64 = 100.0;
/// Reference variance that maps to a full-scale (1.0) heuristic motion score
/// when no calibrated baseline is available. Empirical default.
const VARIANCE_HEURISTIC_FULL_SCALE: f64 = 0.5;
/// Reference phase variance that maps to a full-scale (1.0) phase motion
/// component. Empirical default.
const PHASE_VARIANCE_FULL_SCALE: f64 = 0.5;
/// Blend weight between phase-variance and phase-coherence in the phase score.
const PHASE_SCORE_VARIANCE_WEIGHT: f64 = 0.5;
/// Reference dynamic range that maps to a full-scale (1.0) amplitude-quality
/// confidence indicator. Empirical default.
const AMP_QUALITY_FULL_SCALE_RANGE: f64 = 2.0;
/// Confidence-indicator blend weights (`amplitude`, `phase`, `correlation`,
/// `doppler`) — each is the fraction of total confidence that indicator
/// contributes when present.
const CONF_WEIGHT_AMPLITUDE: f64 = 0.3;
const CONF_WEIGHT_PHASE: f64 = 0.3;
const CONF_WEIGHT_CORRELATION: f64 = 0.2;
const CONF_WEIGHT_DOPPLER: f64 = 0.2;
/// Minimum baseline floor added before dividing by the calibration baseline
/// variance, preventing a divide-by-zero on an all-constant calibration.
const BASELINE_VARIANCE_FLOOR: f64 = 1e-10;
/// Lower / upper clamp for the adaptive human-detection threshold
/// (`mean + 1σ` of recent motion scores). Keeps the adaptive threshold inside
/// a sane operating band. Empirical default.
const ADAPTIVE_THRESHOLD_MIN: f64 = 0.3;
const ADAPTIVE_THRESHOLD_MAX: f64 = 0.95;
/// Minimum history length before the adaptive threshold engages; below this
/// the configured fixed threshold is used.
const ADAPTIVE_THRESHOLD_MIN_HISTORY: usize = 10;
/// Motion score with component breakdown
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MotionScore {
@@ -37,12 +97,11 @@ impl MotionScore {
) -> Self {
// Calculate weighted total
let total = if let Some(doppler) = doppler_component {
0.3 * variance_component
+ 0.2 * correlation_component
+ 0.2 * phase_component
+ 0.3 * doppler
let (wv, wc, wp, wd) = MOTION_WEIGHTS_WITH_DOPPLER;
wv * variance_component + wc * correlation_component + wp * phase_component + wd * doppler
} else {
0.4 * variance_component + 0.3 * correlation_component + 0.3 * phase_component
let (wv, wc, wp) = MOTION_WEIGHTS_NO_DOPPLER;
wv * variance_component + wc * correlation_component + wp * phase_component
};
Self {
@@ -304,7 +363,7 @@ impl MotionDetector {
// Calculate Doppler-based score if available
let doppler_score = features.doppler.as_ref().map(|d| {
// Normalize Doppler magnitude to 0-1 range
(d.mean_magnitude / 100.0).clamp(0.0, 1.0)
(d.mean_magnitude / DOPPLER_FULL_SCALE_MAGNITUDE).clamp(0.0, 1.0)
});
let motion_score = MotionScore::new(
@@ -355,11 +414,11 @@ impl MotionDetector {
// Normalize using baseline if available
if let Some(baseline) = self.baseline_variance {
let ratio = mean_variance / (baseline + 1e-10);
let ratio = mean_variance / (baseline + BASELINE_VARIANCE_FLOOR);
(ratio - 1.0).max(0.0).tanh()
} else {
// Use heuristic normalization
(mean_variance / 0.5).clamp(0.0, 1.0)
(mean_variance / VARIANCE_HEURISTIC_FULL_SCALE).clamp(0.0, 1.0)
}
}
@@ -393,7 +452,9 @@ impl MotionDetector {
let coherence_factor = 1.0 - phase.coherence.abs();
// Combine factors
let score = 0.5 * (mean_variance / 0.5).clamp(0.0, 1.0) + 0.5 * coherence_factor;
let w = PHASE_SCORE_VARIANCE_WEIGHT;
let score = w * (mean_variance / PHASE_VARIANCE_FULL_SCALE).clamp(0.0, 1.0)
+ (1.0 - w) * coherence_factor;
score.clamp(0.0, 1.0)
}
@@ -416,26 +477,27 @@ impl MotionDetector {
let mut weight_sum = 0.0;
// Amplitude quality indicator
let amp_quality = (features.amplitude.dynamic_range / 2.0).clamp(0.0, 1.0);
confidence += amp_quality * 0.3;
weight_sum += 0.3;
let amp_quality =
(features.amplitude.dynamic_range / AMP_QUALITY_FULL_SCALE_RANGE).clamp(0.0, 1.0);
confidence += amp_quality * CONF_WEIGHT_AMPLITUDE;
weight_sum += CONF_WEIGHT_AMPLITUDE;
// Phase coherence indicator
let phase_quality = features.phase.coherence.abs();
confidence += phase_quality * 0.3;
weight_sum += 0.3;
confidence += phase_quality * CONF_WEIGHT_PHASE;
weight_sum += CONF_WEIGHT_PHASE;
// Correlation consistency indicator
let corr_quality = (1.0 - features.correlation.correlation_spread).clamp(0.0, 1.0);
confidence += corr_quality * 0.2;
weight_sum += 0.2;
confidence += corr_quality * CONF_WEIGHT_CORRELATION;
weight_sum += CONF_WEIGHT_CORRELATION;
// Doppler quality if available
if let Some(ref doppler) = features.doppler {
let doppler_quality =
(doppler.spread / doppler.mean_magnitude.max(1.0)).clamp(0.0, 1.0);
confidence += (1.0 - doppler_quality) * 0.2;
weight_sum += 0.2;
confidence += (1.0 - doppler_quality) * CONF_WEIGHT_DOPPLER;
weight_sum += CONF_WEIGHT_DOPPLER;
}
if weight_sum > 0.0 {
@@ -542,7 +604,7 @@ impl MotionDetector {
/// Calculate adaptive threshold based on recent history
fn calculate_adaptive_threshold(&self) -> f64 {
if self.motion_history.len() < 10 {
if self.motion_history.len() < ADAPTIVE_THRESHOLD_MIN_HISTORY {
return self.config.human_detection_threshold;
}
@@ -555,7 +617,7 @@ impl MotionDetector {
};
// Threshold is mean + 1 std deviation, clamped to reasonable range
(mean + std).clamp(0.3, 0.95)
(mean + std).clamp(ADAPTIVE_THRESHOLD_MIN, ADAPTIVE_THRESHOLD_MAX)
}
/// Update baseline variance (for calibration)
@@ -838,4 +900,127 @@ mod tests {
let stats = detector.get_statistics();
assert_eq!(stats.history_size, 10); // Should not exceed max
}
// -- ADR-154 §7.4 #18: de-magic-constant + boundary characterization tests.
// These pin CURRENT behaviour so a future retune is a visible, tested change.
/// The de-magicked tuning consts MUST equal the prior bare literals exactly
/// (this milestone is cleanup — operating values are unchanged).
#[test]
fn motion_tuning_consts_unchanged_from_literals() {
assert_eq!(MOTION_WEIGHTS_WITH_DOPPLER, (0.3, 0.2, 0.2, 0.3));
assert_eq!(MOTION_WEIGHTS_NO_DOPPLER, (0.4, 0.3, 0.3));
assert_eq!(DOPPLER_FULL_SCALE_MAGNITUDE, 100.0);
assert_eq!(VARIANCE_HEURISTIC_FULL_SCALE, 0.5);
assert_eq!(PHASE_VARIANCE_FULL_SCALE, 0.5);
assert_eq!(PHASE_SCORE_VARIANCE_WEIGHT, 0.5);
assert_eq!(AMP_QUALITY_FULL_SCALE_RANGE, 2.0);
assert_eq!(CONF_WEIGHT_AMPLITUDE, 0.3);
assert_eq!(CONF_WEIGHT_PHASE, 0.3);
assert_eq!(CONF_WEIGHT_CORRELATION, 0.2);
assert_eq!(CONF_WEIGHT_DOPPLER, 0.2);
assert_eq!(BASELINE_VARIANCE_FLOOR, 1e-10);
assert_eq!(ADAPTIVE_THRESHOLD_MIN, 0.3);
assert_eq!(ADAPTIVE_THRESHOLD_MAX, 0.95);
assert_eq!(ADAPTIVE_THRESHOLD_MIN_HISTORY, 10);
// Fusion weights are a convex combination (sum to 1.0).
let (wv, wc, wp, wd) = MOTION_WEIGHTS_WITH_DOPPLER;
assert!((wv + wc + wp + wd - 1.0).abs() < 1e-12);
let (wv, wc, wp) = MOTION_WEIGHTS_NO_DOPPLER;
assert!((wv + wc + wp - 1.0).abs() < 1e-12);
}
/// Doppler component saturates at full scale (`/100.0` then clamp(0,1)).
/// Pins behaviour at/just-below/just-above the full-scale magnitude.
#[test]
fn doppler_component_saturates_at_full_scale() {
use crate::features::DopplerFeatures;
use ndarray::Array1;
let make = |mag: f64| DopplerFeatures {
shifts: Array1::zeros(1),
peak_frequency: 0.0,
mean_magnitude: mag,
spread: 0.0,
};
let detector = MotionDetector::default_config();
// just below full scale -> < 1.0
let mut features = create_test_features(0.5);
features.doppler = Some(make(DOPPLER_FULL_SCALE_MAGNITUDE - 1.0));
let below = detector.analyze_motion(&features).score.doppler_component.unwrap();
assert!(below < 1.0 && below > 0.98);
// exactly full scale -> 1.0
features.doppler = Some(make(DOPPLER_FULL_SCALE_MAGNITUDE));
let at = detector.analyze_motion(&features).score.doppler_component.unwrap();
assert_eq!(at, 1.0);
// above full scale -> clamped to 1.0
features.doppler = Some(make(DOPPLER_FULL_SCALE_MAGNITUDE * 10.0));
let above = detector.analyze_motion(&features).score.doppler_component.unwrap();
assert_eq!(above, 1.0);
}
/// `calculate_correlation_score` returns 0.0 for n<2 (the small-matrix
/// guard) and a finite, clamped value for n>=2. Pins the n=1 boundary.
#[test]
fn correlation_score_zero_below_n2_boundary() {
use crate::features::CorrelationFeatures;
use ndarray::Array2;
let detector = MotionDetector::default_config();
let one = CorrelationFeatures {
matrix: Array2::from_elem((1, 1), 1.0),
mean_correlation: 0.0,
max_correlation: 0.0,
correlation_spread: 0.0,
};
assert_eq!(detector.calculate_correlation_score(&one), 0.0);
let two = CorrelationFeatures {
matrix: Array2::from_shape_fn((2, 2), |(i, j)| if i == j { 1.0 } else { 0.0 }),
mean_correlation: 0.0,
max_correlation: 0.0,
correlation_spread: 0.0,
};
let s = detector.calculate_correlation_score(&two);
assert!(s.is_finite() && (0.0..=1.0).contains(&s));
}
/// `calculate_temporal_variance` returns 0.0 with fewer than 2 history
/// entries, finite otherwise. Pins the len<2 boundary.
#[test]
fn temporal_variance_zero_below_two_history() {
let mut detector = MotionDetector::default_config();
assert_eq!(detector.calculate_temporal_variance(), 0.0); // 0 entries
detector
.motion_history
.push_back(MotionScore::new(0.5, 0.5, 0.5, None));
assert_eq!(detector.calculate_temporal_variance(), 0.0); // 1 entry
detector
.motion_history
.push_back(MotionScore::new(0.1, 0.1, 0.1, None));
assert!(detector.calculate_temporal_variance() > 0.0); // 2 entries
}
/// The adaptive threshold engages only at/after `ADAPTIVE_THRESHOLD_MIN_HISTORY`
/// history entries; below it falls back to the configured fixed threshold.
/// Pins the history=9 (fixed) vs history=10 (adaptive) boundary.
#[test]
fn adaptive_threshold_engages_at_history_boundary() {
let config = MotionDetectorConfig::builder()
.adaptive_threshold(true)
.human_detection_threshold(0.8)
.history_size(50)
.build();
let mut detector = MotionDetector::new(config);
// Push exactly 9 entries: still uses the fixed configured threshold.
for _ in 0..(ADAPTIVE_THRESHOLD_MIN_HISTORY - 1) {
detector
.motion_history
.push_back(MotionScore::new(0.5, 0.5, 0.5, None));
}
assert_eq!(detector.calculate_adaptive_threshold(), 0.8);
// 10th entry: adaptive band kicks in, clamped to [MIN, MAX].
detector
.motion_history
.push_back(MotionScore::new(0.5, 0.5, 0.5, None));
let t = detector.calculate_adaptive_threshold();
assert!((ADAPTIVE_THRESHOLD_MIN..=ADAPTIVE_THRESHOLD_MAX).contains(&t));
}
}
@@ -24,6 +24,18 @@ use midstreamer_attractor::{AttractorAnalyzer, AttractorType, PhasePoint};
use super::longitudinal::DriftMetric;
// ---------------------------------------------------------------------------
// Internal constants (ADR-154 §7.4 — de-magicked; values unchanged)
// ---------------------------------------------------------------------------
/// Per-metric ring-buffer capacity: one year of daily observations.
const METRIC_BUFFER_CAPACITY: usize = 365;
/// Number of most-recent values averaged to estimate a point-attractor's
/// stable centre. Empirical default — a short tail that tracks the latest
/// converged level without over-smoothing.
const STABLE_CENTER_WINDOW: usize = 10;
// ---------------------------------------------------------------------------
// Configuration
// ---------------------------------------------------------------------------
@@ -232,7 +244,7 @@ impl AttractorDriftAnalyzer {
let buffers = DriftMetric::all()
.iter()
.map(|&m| MetricBuffer::new(m, 365)) // 1 year of daily observations
.map(|&m| MetricBuffer::new(m, METRIC_BUFFER_CAPACITY))
.collect();
Ok(Self {
@@ -296,8 +308,12 @@ impl AttractorDriftAnalyzer {
match info.attractor_type {
AttractorType::PointAttractor => {
// Compute center as mean of last few values
let recent = &values[values.len().saturating_sub(10)..];
// Compute center as the mean of the last STABLE_CENTER_WINDOW
// values. `recent` is non-empty here: the `count < min_needed`
// guard above guarantees `values.len() >= min_observations >= 1`
// before this branch, so `recent.len() >= 1` and the division
// below cannot be a divide-by-zero.
let recent = &values[values.len().saturating_sub(STABLE_CENTER_WINDOW)..];
let center = recent.iter().sum::<f64>() / recent.len() as f64;
BiophysicalAttractor::Stable { center }
}
@@ -563,4 +579,38 @@ mod tests {
let dbg = format!("{:?}", a);
assert!(dbg.contains("AttractorDriftAnalyzer"));
}
// -- ADR-154 §7.4: de-magic-constant + boundary characterization tests.
/// De-magicked internal constants must equal the prior inline literals.
#[test]
fn attractor_consts_unchanged_from_literals() {
assert_eq!(METRIC_BUFFER_CAPACITY, 365);
assert_eq!(STABLE_CENTER_WINDOW, 10);
}
/// `analyze` returns InsufficientData strictly below `min_observations` and
/// succeeds at exactly `min_observations`. Pins the off-by-one boundary
/// (previously only the well-below case was tested) and, with it, the
/// implicit `recent.len() >= 1` divide-safety in the PointAttractor branch.
#[test]
fn analyze_min_observations_boundary() {
let cfg = AttractorDriftConfig {
min_observations: 12,
..Default::default()
};
let mut a = AttractorDriftAnalyzer::new(7, cfg.clone()).unwrap();
// One below the boundary -> InsufficientData.
for i in 0..(cfg.min_observations - 1) {
a.add_observation(DriftMetric::GaitSymmetry, 0.1 + i as f64 * 0.001);
}
assert!(matches!(
a.analyze(DriftMetric::GaitSymmetry, 0),
Err(AttractorDriftError::InsufficientData { needed: 12, have: 11 })
));
// Exactly at the boundary -> Ok (no panic, finite center if Stable).
a.add_observation(DriftMetric::GaitSymmetry, 0.111);
let report = a.analyze(DriftMetric::GaitSymmetry, 0).unwrap();
assert_eq!(report.observation_count, 12);
}
}
@@ -40,6 +40,30 @@ const VERSION: u8 = 1;
const HEADER_LEN: usize = 16; // magic(4) + version(1) + tier(1) + reserved(2) + unix_s(8)
const SUBCARRIER_RECORD_LEN: usize = 16; // 4 × f32
// ADR-154 §7.4 — de-magicked (values unchanged). The tuning thresholds below
// are EMPIRICAL DEFAULTS pending labelled empty-vs-occupied calibration traces.
/// Default minimum frames for a baseline finalization (30 s @ 20 Hz). Shared by
/// every tier constructor (`ht20`/`ht40`/`he20`/`he40`).
const DEFAULT_MIN_FRAMES: u32 = 600;
/// Amplitude standard-deviation floor used as the z-score divisor in
/// `deviation()`, guarding against a zero-variance baseline subcarrier.
const AMP_STD_FLOOR: f32 = 1e-12;
/// `deviation()` flags motion when the median amplitude z-score exceeds this
/// many σ. EMPIRICAL DEFAULT.
const MOTION_AMP_Z_THRESHOLD: f32 = 2.0;
/// `deviation()` flags motion when the median phase drift exceeds this many
/// radians (π/6 = 30°). EMPIRICAL DEFAULT.
const MOTION_PHASE_DRIFT_THRESHOLD: f32 = std::f32::consts::PI / 6.0;
/// Minimum complex magnitude in `subtract_in_place` below which a bin is left
/// untouched (a near-zero bin has no meaningful baseline to subtract and the
/// `(norm - baseline)/norm` scaling would be ill-conditioned).
const SUBTRACT_MIN_NORM: f64 = 1e-30;
// ---------------------------------------------------------------------------
// PHY tier
// ---------------------------------------------------------------------------
@@ -103,11 +127,11 @@ pub struct CalibrationConfig {
impl CalibrationConfig {
/// HT20 defaults: 64 FFT, 52 active, 600 frame minimum (30 s @ 20 Hz).
pub fn ht20() -> Self {
Self { tier: PhyTier::Ht20, num_subcarriers: 64, num_active: 52, min_frames: 600, max_phase_variance: 0.3 }
Self { tier: PhyTier::Ht20, num_subcarriers: 64, num_active: 52, min_frames: DEFAULT_MIN_FRAMES, max_phase_variance: 0.3 }
}
/// HT40 defaults: 128 FFT, 114 active.
pub fn ht40() -> Self {
Self { tier: PhyTier::Ht40, num_subcarriers: 128, num_active: 114, min_frames: 600, max_phase_variance: 0.3 }
Self { tier: PhyTier::Ht40, num_subcarriers: 128, num_active: 114, min_frames: DEFAULT_MIN_FRAMES, max_phase_variance: 0.3 }
}
/// HE20 defaults: 256 FFT, **256 active** (record all delivered bins).
///
@@ -128,11 +152,11 @@ impl CalibrationConfig {
/// `cir.rs` (`HE20_ACTIVE`), where the Φ sensing matrix genuinely needs it;
/// the baseline recorder does not.
pub fn he20() -> Self {
Self { tier: PhyTier::He20, num_subcarriers: 256, num_active: 256, min_frames: 600, max_phase_variance: 0.3 }
Self { tier: PhyTier::He20, num_subcarriers: 256, num_active: 256, min_frames: DEFAULT_MIN_FRAMES, max_phase_variance: 0.3 }
}
/// HE40 defaults: 512 FFT, 484 active.
pub fn he40() -> Self {
Self { tier: PhyTier::He40, num_subcarriers: 512, num_active: 484, min_frames: 600, max_phase_variance: 0.3 }
Self { tier: PhyTier::He40, num_subcarriers: 512, num_active: 484, min_frames: DEFAULT_MIN_FRAMES, max_phase_variance: 0.3 }
}
}
@@ -264,7 +288,7 @@ impl BaselineCalibration {
for (ki, (c, baseline)) in y.iter().zip(self.subcarriers.iter()).enumerate() {
let _ = ki;
let amp = c.norm();
let std = baseline.amp_variance.sqrt().max(1e-12_f32);
let std = baseline.amp_variance.sqrt().max(AMP_STD_FLOOR);
z_amp.push((amp - baseline.amp_mean) / std);
let theta = c.arg();
let drift = circular_distance(theta, baseline.phase_mean);
@@ -273,7 +297,8 @@ impl BaselineCalibration {
let amplitude_z_median = median_abs(&z_amp);
let amplitude_z_max = z_amp.iter().map(|v| v.abs()).fold(0.0_f32, f32::max);
let phase_drift_median = median_slice(&phase_drift);
let motion_flagged = amplitude_z_median > 2.0 || phase_drift_median > std::f32::consts::PI / 6.0;
let motion_flagged =
amplitude_z_median > MOTION_AMP_Z_THRESHOLD || phase_drift_median > MOTION_PHASE_DRIFT_THRESHOLD;
Ok(CalibrationDeviationScore { amplitude_z_median, amplitude_z_max, phase_drift_median, motion_flagged })
}
@@ -338,7 +363,7 @@ impl BaselineCalibration {
for s in 0..n_streams {
let c = frame.data[[s, ki]];
let norm = c.norm();
if norm > 1e-30 {
if norm > SUBTRACT_MIN_NORM {
let scale = ((norm - baseline_amp).max(0.0)) / norm;
frame.data[[s, ki]] = num_complex::Complex64::new(c.re * scale, c.im * scale);
}
@@ -491,7 +516,8 @@ impl CalibrationRecorder {
let amplitude_z_median = median_slice(&z_amp_abs);
let amplitude_z_max = z_amp_abs.iter().copied().fold(0.0_f32, f32::max);
let phase_drift_median = median_slice(&phase_drift);
let motion_flagged = amplitude_z_median > 2.0 || phase_drift_median > std::f32::consts::PI / 6.0;
let motion_flagged =
amplitude_z_median > MOTION_AMP_Z_THRESHOLD || phase_drift_median > MOTION_PHASE_DRIFT_THRESHOLD;
Ok(CalibrationDeviationScore { amplitude_z_median, amplitude_z_max, phase_drift_median, motion_flagged })
}
@@ -736,6 +762,27 @@ mod tests {
}
}
// -- ADR-154 §7.4: de-magic-constant pin test.
/// The de-magicked calibration constants MUST equal the prior literals, and
/// every tier constructor MUST share the one DEFAULT_MIN_FRAMES default.
#[test]
fn calibration_consts_unchanged_from_literals() {
assert_eq!(DEFAULT_MIN_FRAMES, 600);
assert_eq!(AMP_STD_FLOOR, 1e-12_f32);
assert_eq!(MOTION_AMP_Z_THRESHOLD, 2.0_f32);
assert_eq!(MOTION_PHASE_DRIFT_THRESHOLD, std::f32::consts::PI / 6.0);
assert_eq!(SUBTRACT_MIN_NORM, 1e-30_f64);
for cfg in [
CalibrationConfig::ht20(),
CalibrationConfig::ht40(),
CalibrationConfig::he20(),
CalibrationConfig::he40(),
] {
assert_eq!(cfg.min_frames, DEFAULT_MIN_FRAMES);
}
}
// Binary magic / version check.
#[test]
fn binary_magic_and_version() {
@@ -1458,6 +1458,79 @@ mod tests {
}
}
/// ADR-154 §7.4 #14: the `fft_operator` path *changes the witness hash*
/// (documented in `CirConfig::fft_operator`), so it must be pinned as
/// numerically **close** to the dense path — not silently divergent. The
/// existing `fft_estimate_matches_dense_dominant_tap` covers HT20 / one tau;
/// this test asserts the **full `Cir` output** (every tap + every scalar
/// field) stays within a documented relative tolerance on the production
/// **canonical-56** config across several realistic delays. A regression
/// that lets the FFT path drift (wrong scaling, off-by-one Φ column, etc.)
/// fails here instead of corrupting a downstream witness unnoticed.
#[test]
fn fft_operator_within_tolerance_of_dense_canonical56() {
// Relative tolerances — documented, not silent. The FFT operator sums the
// same Φ entries in a different order, so taps agree to ~float epsilon
// scaled by the dominant-tap magnitude; ISTA can differ by a few last
// bits over its trajectory, hence 1e-2 (same order as the existing test).
const TAP_REL_TOL: f32 = 1e-2;
const RATIO_ABS_TOL: f32 = 1e-2;
const SPREAD_REL_TOL: f64 = 1e-2;
for &tau in &[20e-9_f64, 50e-9, 90e-9] {
let dense_cfg = CirConfig::canonical56();
let mut fft_cfg = CirConfig::canonical56();
fft_cfg.fft_operator = true;
let frame = make_single_tap_frame(dense_cfg.num_subcarriers, tau);
let dense = CirEstimator::new(dense_cfg).estimate(&frame).unwrap();
let fast = CirEstimator::new(fft_cfg).estimate(&frame).unwrap();
assert_eq!(dense.taps.len(), fast.taps.len());
// Full tap vector close (relative to the dominant tap magnitude).
let dom = dense.taps[dense.dominant_tap_idx].norm().max(1e-6);
let mut max_tap_err = 0.0_f32;
for (a, b) in dense.taps.iter().zip(&fast.taps) {
max_tap_err = max_tap_err.max((a - b).norm());
}
assert!(
max_tap_err <= TAP_REL_TOL * dom,
"tau={tau:e}: FFT taps diverged from dense — max err {max_tap_err} > {TAP_REL_TOL} * {dom} (NOT numerically close)"
);
// The dominant tap and the scalar summary fields must agree too —
// these feed the witness, so a silent divergence here is the bug #14
// guards against.
assert_eq!(
dense.dominant_tap_idx, fast.dominant_tap_idx,
"tau={tau:e}: dominant tap index moved"
);
assert!(
(dense.dominant_tap_ratio - fast.dominant_tap_ratio).abs() <= RATIO_ABS_TOL,
"tau={tau:e}: dominant_tap_ratio drift {} vs {}",
dense.dominant_tap_ratio,
fast.dominant_tap_ratio
);
assert_eq!(
dense.active_tap_count, fast.active_tap_count,
"tau={tau:e}: active_tap_count changed"
);
assert_eq!(
dense.ranging_valid, fast.ranging_valid,
"tau={tau:e}: ranging_valid flipped"
);
let spread_ref = dense.rms_delay_spread_s.abs().max(1e-12);
assert!(
(dense.rms_delay_spread_s - fast.rms_delay_spread_s).abs()
<= SPREAD_REL_TOL * spread_ref,
"tau={tau:e}: rms_delay_spread drift {} vs {}",
dense.rms_delay_spread_s,
fast.rms_delay_spread_s
);
}
}
/// The default configs keep the FFT operator off — the dense, bit-exact
/// witness path is the default (enabling FFT shifts float results).
#[test]
@@ -79,7 +79,7 @@ impl CoherenceState {
Self {
reference: vec![0.0; n_subcarriers],
variance: vec![1.0; n_subcarriers],
decay: 0.95,
decay: DEFAULT_EMA_DECAY,
current_score: 1.0,
stale_count: 0,
drift_profile: DriftProfile::Stable,
@@ -200,8 +200,8 @@ impl CoherenceState {
let diff = obs - old_ref;
*v = self.decay * *v + alpha * diff * diff;
// Ensure variance does not collapse to zero
if *v < 1e-6 {
*v = 1e-6;
if *v < VARIANCE_FLOOR {
*v = VARIANCE_FLOOR;
}
}
}
@@ -229,7 +229,7 @@ pub fn coherence_score(current: &[f32], reference: &[f32], variance: &[f32]) ->
return 0.0;
}
let epsilon = 1e-6_f32;
let epsilon = VARIANCE_FLOOR;
let mut weighted_sum = 0.0_f32;
let mut weight_sum = 0.0_f32;
@@ -260,6 +260,18 @@ const DRIFT_STABLE_SCORE: f32 = 0.85;
/// DATA-GATED). EMPIRICAL DEFAULT pending labelled calibration.
const DRIFT_STEP_CHANGE_MAX_STALE: u64 = 10;
/// Variance floor (ADR-154 §7.4 — de-magicked): the online variance estimate
/// is never allowed to collapse below this, which keeps the inverse-variance
/// weight and the z-score divisor finite. Used as both the floor in
/// `update_reference` and the epsilon in `coherence_score` /
/// `per_subcarrier_zscores`. Value unchanged from the prior `1e-6` literals.
const VARIANCE_FLOOR: f32 = 1e-6;
/// Default EMA decay rate for the reference/variance update (ADR-154 §7.4 —
/// de-magicked from the inline `0.95` in `CoherenceState::new`). EMPIRICAL
/// DEFAULT; override via [`CoherenceState::with_decay`].
const DEFAULT_EMA_DECAY: f32 = 0.95;
/// Classify drift profile based on coherence history.
fn classify_drift(score: f32, stale_count: u64) -> DriftProfile {
if score >= DRIFT_STABLE_SCORE {
@@ -280,7 +292,7 @@ pub fn per_subcarrier_zscores(current: &[f32], reference: &[f32], variance: &[f3
let n = current.len().min(reference.len()).min(variance.len());
(0..n)
.map(|i| {
let var = variance[i].max(1e-6);
let var = variance[i].max(VARIANCE_FLOOR);
(current[i] - reference[i]).abs() / var.sqrt()
})
.collect()
@@ -439,6 +451,23 @@ mod tests {
fn drift_consts_unchanged_from_literals() {
assert_eq!(DRIFT_STABLE_SCORE, 0.85);
assert_eq!(DRIFT_STEP_CHANGE_MAX_STALE, 10);
// ADR-154 §7.4 M3: variance-floor + default-decay de-magic.
assert_eq!(VARIANCE_FLOOR, 1e-6_f32);
assert_eq!(DEFAULT_EMA_DECAY, 0.95_f32);
}
/// `coherence_score` stays finite and in [0,1] when a subcarrier reports
/// zero variance — the [`VARIANCE_FLOOR`] keeps the z-score divisor and the
/// inverse-variance weight finite. Pins the floor's effect.
#[test]
fn coherence_score_finite_with_zero_variance() {
let current = [1.0_f32, 2.0, 3.0];
let reference = [1.0_f32, 2.0, 3.0];
let zero_var = [0.0_f32, 0.0, 0.0];
let s = coherence_score(&current, &reference, &zero_var);
assert!(s.is_finite() && (0.0..=1.0).contains(&s));
// Perfect agreement with floored variance -> ~1.0.
assert!((s - 1.0).abs() < 1e-3);
}
/// Stable score boundary: `>= 0.85` is Stable; just below flips to a
@@ -23,6 +23,10 @@
//! # References
//! - ADR-030 Tier 5: Cross-Room Identity Continuity
/// Denominator guard for cosine similarity (ADR-154 §7.4 — de-magicked):
/// a product of norms below this is treated as a zero-norm vector ⇒ 0.0.
const COSINE_SIMILARITY_EPSILON: f32 = 1e-9;
// ---------------------------------------------------------------------------
// Error types
// ---------------------------------------------------------------------------
@@ -359,12 +363,15 @@ impl CrossRoomTracker {
}
/// Cosine similarity between two f32 vectors.
///
/// Returns `0.0` when either vector has (near-)zero norm — the product of
/// norms falls below [`COSINE_SIMILARITY_EPSILON`] and the division is skipped.
fn cosine_similarity_f32(a: &[f32], b: &[f32]) -> f32 {
let dot: f32 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum();
let norm_a: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
let norm_b: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
let denom = norm_a * norm_b;
if denom < 1e-9 {
if denom < COSINE_SIMILARITY_EPSILON {
0.0
} else {
dot / denom
@@ -623,4 +630,23 @@ mod tests {
let sim = cosine_similarity_f32(&a, &b);
assert!(sim.abs() < 1e-5);
}
// -- ADR-154 §7.4: de-magic-constant + boundary characterization tests.
/// De-magicked epsilon must equal the prior literal.
#[test]
fn cosine_epsilon_unchanged_from_literal() {
assert_eq!(COSINE_SIMILARITY_EPSILON, 1e-9_f32);
}
/// A zero-norm vector falls below the denominator epsilon ⇒ similarity 0.0.
/// Previously untested (both existing tests use unit-norm vectors).
#[test]
fn test_cosine_similarity_zero_vector() {
let zero = vec![0.0_f32; 4];
let v = vec![1.0_f32, 2.0, 3.0, 4.0];
assert_eq!(cosine_similarity_f32(&zero, &v), 0.0);
assert_eq!(cosine_similarity_f32(&v, &zero), 0.0);
assert_eq!(cosine_similarity_f32(&zero, &zero), 0.0);
}
}
@@ -14,6 +14,15 @@
use super::QualityScored;
/// Multiplicative coherence penalty applied per recorded contradiction
/// (ADR-154 §7.4 — de-magicked; EMPIRICAL DEFAULT). `n` contradictions scale
/// coherence by `CONTRADICTION_PENALTY.powi(n)`.
const CONTRADICTION_PENALTY: f32 = 0.8;
/// Confidence-bound half-width added per recorded contradiction (clamped so the
/// interval stays within `[0, 1]`). EMPIRICAL DEFAULT.
const CONTRADICTION_BOUND_HALFWIDTH: f32 = 0.1;
/// Identifies which sensing family produced a fused frame, so one
/// [`QualityScore`] can be correlated across the signal-domain fuser
/// (`multistatic.rs`) and the embedding-domain fuser (`viewpoint/fusion.rs`).
@@ -113,7 +122,7 @@ impl QualityScore {
/// streaming engine routes/gates on.
#[must_use]
pub fn penalized_coherence(&self) -> f32 {
let penalty = 0.8_f32.powi(self.contradiction_flags.len() as i32);
let penalty = CONTRADICTION_PENALTY.powi(self.contradiction_flags.len() as i32);
(self.base_coherence * penalty).clamp(0.0, 1.0)
}
}
@@ -127,7 +136,8 @@ impl QualityScored for QualityScore {
// Width grows with the number of tolerated contradictions: each adds
// ±0.1 of uncertainty around the penalized coherence, clamped to [0,1].
let c = self.penalized_coherence();
let half = (0.1 * self.contradiction_flags.len() as f32).min(c.min(1.0 - c));
let half =
(CONTRADICTION_BOUND_HALFWIDTH * self.contradiction_flags.len() as f32).min(c.min(1.0 - c));
((c - half).max(0.0), (c + half).min(1.0))
}
}
@@ -185,4 +195,24 @@ mod tests {
assert!((0.0..=1.0).contains(&s));
assert!(0.0 <= lo && lo <= hi && hi <= 1.0);
}
// -- ADR-154 §7.4: de-magic-constant + boundary characterization tests.
/// De-magicked penalty/bound consts must equal the prior literals.
#[test]
fn fusion_quality_consts_unchanged_from_literals() {
assert_eq!(CONTRADICTION_PENALTY, 0.8_f32);
assert_eq!(CONTRADICTION_BOUND_HALFWIDTH, 0.1_f32);
}
/// Zero contradictions: penalty is `0.8^0 = 1.0` (coherence unchanged) and
/// the confidence bounds collapse to a point. Pins the n=0 boundary.
#[test]
fn no_contradiction_is_identity() {
let q = base();
assert!(q.contradiction_flags.is_empty());
assert!((q.penalized_coherence() - q.base_coherence).abs() < 1e-6);
let (lo, hi) = q.confidence_bounds();
assert!((hi - lo).abs() < 1e-6); // half-width is 0 with no contradictions
}
}
@@ -19,6 +19,16 @@
//! - Sakoe & Chiba (1978), "Dynamic programming algorithm optimization
//! for spoken word recognition" IEEE TASSP
// ---------------------------------------------------------------------------
// Tuning constants (ADR-154 §7.4 — de-magicked; value unchanged)
// ---------------------------------------------------------------------------
/// Minimum second-best DTW distance below which the relative-margin
/// confidence formula `1 - best/second_best` would divide by a near-zero
/// denominator. Below this we fall back to the `max_distance`-relative
/// confidence. Empirical guard, not a tuned operating point.
const CONFIDENCE_SECOND_BEST_EPSILON: f64 = 1e-10;
// ---------------------------------------------------------------------------
// Error types
// ---------------------------------------------------------------------------
@@ -236,7 +246,10 @@ impl GestureClassifier {
let recognized = best_dist <= self.config.max_distance;
// Confidence: how much better is the best match vs second best
let confidence = if recognized && second_best_dist.is_finite() && second_best_dist > 1e-10 {
let confidence = if recognized
&& second_best_dist.is_finite()
&& second_best_dist > CONFIDENCE_SECOND_BEST_EPSILON
{
(1.0 - best_dist / second_best_dist).clamp(0.0, 1.0)
} else if recognized {
(1.0 - best_dist / self.config.max_distance).clamp(0.0, 1.0)
@@ -364,7 +377,24 @@ fn dtw_distance(seq_a: &[Vec<f64>], seq_b: &[Vec<f64>], band_width: usize) -> f6
}
/// Euclidean distance between two feature vectors.
///
/// # Caller contract (ADR-154 §7.4 #12)
/// `a` and `b` are expected to have the **same** dimension (`feature_dim`).
/// The implementation `zip`s the two slices, so on a length mismatch it
/// **silently truncates to the shorter vector** rather than erroring. Every
/// in-tree caller (`dtw_distance` over a single classifier's templates)
/// already enforces equal `feature_dim`, so a mismatch indicates a
/// construction bug; a `debug_assert!` makes that loud in debug builds while
/// keeping the release operating path (and its output) unchanged.
fn euclidean_distance(a: &[f64], b: &[f64]) -> f64 {
debug_assert_eq!(
a.len(),
b.len(),
"euclidean_distance: feature-vector length mismatch ({} vs {}) — \
zip() would silently truncate; callers must use a uniform feature_dim",
a.len(),
b.len()
);
a.iter()
.zip(b.iter())
.map(|(x, y)| (x - y) * (x - y))
@@ -688,4 +718,34 @@ mod tests {
assert_eq!(GestureType::Circle.name(), "circle");
assert_eq!(GestureType::Custom.name(), "custom");
}
// -- ADR-154 §7.4 #12 + de-magic: boundary / characterization tests.
/// De-magicked confidence epsilon must equal the prior literal.
#[test]
fn confidence_epsilon_unchanged_from_literal() {
assert_eq!(CONFIDENCE_SECOND_BEST_EPSILON, 1e-10);
}
/// `dtw_distance` returns +inf when EITHER sequence is empty. Pins the
/// n=0 / m=0 boundary (previously exercised only with n,m >= 3).
#[test]
fn dtw_empty_sequence_is_infinite() {
let nonempty: Vec<Vec<f64>> = vec![vec![1.0], vec![2.0]];
let empty: Vec<Vec<f64>> = vec![];
assert!(dtw_distance(&empty, &nonempty, 3).is_infinite());
assert!(dtw_distance(&nonempty, &empty, 3).is_infinite());
assert!(dtw_distance(&empty, &empty, 3).is_infinite());
}
/// `euclidean_distance` over equal-length vectors is the L2 norm of the
/// difference. Pins the documented same-dimension caller contract (#12);
/// the mismatch case is guarded by a debug_assert in debug builds and
/// truncates in release — not exercised here to keep the test
/// release/debug-agnostic.
#[test]
fn euclidean_distance_equal_length_is_l2() {
assert!((euclidean_distance(&[1.0, 2.0, 2.0], &[0.0, 0.0, 0.0]) - 3.0).abs() < 1e-12);
assert_eq!(euclidean_distance(&[], &[]), 0.0);
}
}
@@ -21,6 +21,11 @@
use std::collections::VecDeque;
/// Minimum acceleration magnitude (ADR-154 §7.4 — de-magicked) below which the
/// lead-time estimate `t = (v_thresh - v) / a` would divide by a near-zero
/// acceleration; below this the lead time is reported as 0.0.
const LEAD_TIME_MIN_ACCEL: f64 = 1e-10;
// ---------------------------------------------------------------------------
// Error types
// ---------------------------------------------------------------------------
@@ -233,7 +238,7 @@ impl IntentionDetector {
let detected = self.sustained_count >= self.config.min_sustained_frames;
// Estimate lead time based on current acceleration and velocity
let estimated_lead = if detected && accel_mag > 1e-10 {
let estimated_lead = if detected && accel_mag > LEAD_TIME_MIN_ACCEL {
// Time until velocity reaches threshold: t = (v_thresh - v) / a
let remaining = (self.config.max_pre_movement_velocity - velocity_mag) / accel_mag;
remaining.clamp(0.0, self.config.max_lead_time_s)
@@ -508,4 +513,29 @@ mod tests {
let sd = embedding_second_diff(&a, &b, &c, 1.0);
assert!((sd[0] - 2.0).abs() < 1e-10);
}
// -- ADR-154 §7.4: de-magic-constant + boundary characterization tests.
/// De-magicked lead-time accel guard must equal the prior literal.
#[test]
fn lead_time_accel_const_unchanged_from_literal() {
assert_eq!(LEAD_TIME_MIN_ACCEL, 1e-10);
}
/// A static (zero-motion) embedding stream produces ~zero acceleration, so
/// the lead-time estimate stays at the 0.0 sentinel rather than dividing by
/// a near-zero acceleration. Pins the `accel_mag <= LEAD_TIME_MIN_ACCEL`
/// branch behaviour.
#[test]
fn lead_time_zero_for_static_stream() {
let config = make_config();
let mut detector = IntentionDetector::new(config).unwrap();
let mut last = None;
for frame in 0..6_u64 {
last = Some(detector.update(&static_embedding(), frame * 50_000).unwrap());
}
let signal = last.unwrap();
assert!(signal.acceleration_magnitude < LEAD_TIME_MIN_ACCEL.max(1e-9));
assert_eq!(signal.estimated_lead_time_s, 0.0);
}
}
@@ -18,6 +18,38 @@
use crate::ruvsense::field_model::WelfordStats;
// ---------------------------------------------------------------------------
// Drift-detection thresholds (ADR-154 §7.4 — de-magicked; EMPIRICAL DEFAULTS).
//
// These encode the "Key Invariants" documented in the module header. They were
// previously bare literals scattered through `update_daily`/`is_ready`. Lifting
// them to named consts makes the policy explicit and a future retune a visible,
// tested change. Values are unchanged.
// ---------------------------------------------------------------------------
/// Minimum observation days before drift detection activates.
const BASELINE_MIN_OBSERVATION_DAYS: u32 = 7;
/// EMA update weight applied to the embedding centroid each day (the new
/// sample's weight; the centroid retains `1 - EMBEDDING_EMA_ALPHA` of its old
/// value, i.e. a decay of 0.95). Kept as the literal `0.05` rather than
/// `1.0 - 0.95_f32` to stay bit-identical (the f32 subtraction is not exactly
/// 0.05).
const EMBEDDING_EMA_ALPHA: f32 = 0.05;
/// Per-metric absolute z-score above which a day counts toward sustained drift.
const DRIFT_ZSCORE_SIGMA: f64 = 2.0;
/// Consecutive drift days required before a drift report is emitted.
const DRIFT_SUSTAINED_DAYS: u32 = 3;
/// Consecutive drift days at/above which monitoring escalates from `Drift`
/// to `RiskCorrelation`.
const DRIFT_ESCALATION_DAYS: u32 = 7;
/// Denominator guard for cosine similarity (zero-norm vectors ⇒ 0.0).
const COSINE_SIMILARITY_EPSILON: f32 = 1e-9;
// ---------------------------------------------------------------------------
// Error types
// ---------------------------------------------------------------------------
@@ -226,7 +258,7 @@ impl PersonalBaseline {
/// Whether baseline has enough data for drift detection.
pub fn is_ready(&self) -> bool {
self.observation_days >= 7
self.observation_days >= BASELINE_MIN_OBSERVATION_DAYS
}
/// Update baseline with a daily summary.
@@ -240,10 +272,10 @@ impl PersonalBaseline {
self.observation_days += 1;
self.updated_at_us = timestamp_us;
// Update embedding centroid with EMA (decay = 0.95)
// Update embedding centroid with EMA (decay 0.95, alpha = 1 - 0.95)
if let Some(ref emb) = summary.embedding_centroid {
if emb.len() == self.embedding_centroid.len() {
let alpha = 0.05_f32; // 1 - 0.95
let alpha = EMBEDDING_EMA_ALPHA;
for (c, e) in self.embedding_centroid.iter_mut().zip(emb.iter()) {
*c = (1.0 - alpha) * *c + alpha * *e;
}
@@ -271,20 +303,20 @@ impl PersonalBaseline {
let idx = Self::metric_index(metric);
if z.abs() > 2.0 {
if z.abs() > DRIFT_ZSCORE_SIGMA {
self.drift_counters[idx] += 1;
} else {
self.drift_counters[idx] = 0;
}
if self.drift_counters[idx] >= 3 {
if self.drift_counters[idx] >= DRIFT_SUSTAINED_DAYS {
let direction = if z > 0.0 {
DriftDirection::Increasing
} else {
DriftDirection::Decreasing
};
let level = if self.drift_counters[idx] >= 7 {
let level = if self.drift_counters[idx] >= DRIFT_ESCALATION_DAYS {
MonitoringLevel::RiskCorrelation
} else {
MonitoringLevel::Drift
@@ -310,7 +342,7 @@ impl PersonalBaseline {
/// Check readiness at a specific observation day count (internal helper).
fn is_ready_at(&self, days: u32) -> bool {
days >= 7
days >= BASELINE_MIN_OBSERVATION_DAYS
}
/// Get current drift counter for a metric.
@@ -545,12 +577,15 @@ impl EmbeddingHistory {
}
/// Cosine similarity between two f32 vectors.
///
/// Returns `0.0` if either vector has (near-)zero norm — the product of norms
/// falls below [`COSINE_SIMILARITY_EPSILON`], so the division is skipped.
fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
let dot: f32 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum();
let norm_a: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
let norm_b: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
let denom = norm_a * norm_b;
if denom < 1e-9 {
if denom < COSINE_SIMILARITY_EPSILON {
0.0
} else {
dot / denom
@@ -1017,4 +1052,40 @@ mod tests {
assert!(*i < h.len());
}
}
// -- ADR-154 §7.4: de-magic-constant + boundary characterization tests.
/// The de-magicked drift thresholds MUST equal the prior bare literals.
#[test]
fn drift_consts_unchanged_from_literals() {
assert_eq!(BASELINE_MIN_OBSERVATION_DAYS, 7);
assert_eq!(EMBEDDING_EMA_ALPHA, 0.05_f32);
assert_eq!(DRIFT_ZSCORE_SIGMA, 2.0);
assert_eq!(DRIFT_SUSTAINED_DAYS, 3);
assert_eq!(DRIFT_ESCALATION_DAYS, 7);
assert_eq!(COSINE_SIMILARITY_EPSILON, 1e-9_f32);
}
/// `is_ready_at` pins the exact day-6 (not ready) / day-7 (ready) boundary
/// independent of Welford state.
#[test]
fn is_ready_at_day_boundary() {
let baseline = PersonalBaseline::new(1, 8);
assert!(!baseline.is_ready_at(BASELINE_MIN_OBSERVATION_DAYS - 1)); // day 6
assert!(baseline.is_ready_at(BASELINE_MIN_OBSERVATION_DAYS)); // day 7
assert!(baseline.is_ready_at(BASELINE_MIN_OBSERVATION_DAYS + 1)); // day 8
}
/// Cosine similarity returns 0.0 for a zero-norm vector (denominator below
/// `COSINE_SIMILARITY_EPSILON`) and a finite value otherwise.
#[test]
fn cosine_similarity_zero_vector_is_zero() {
let zero = [0.0_f32; 4];
let v = [1.0_f32, 2.0, 3.0, 4.0];
assert_eq!(cosine_similarity(&zero, &v), 0.0);
assert_eq!(cosine_similarity(&v, &zero), 0.0);
assert_eq!(cosine_similarity(&zero, &zero), 0.0);
// identical non-zero vectors -> ~1.0
assert!((cosine_similarity(&v, &v) - 1.0).abs() < 1e-5);
}
}
@@ -198,7 +198,15 @@ fn compute_cross_channel_coherence(frames: &[CanonicalCsiFrame]) -> f32 {
((mean_corr + 1.0) / 2.0).clamp(0.0, 1.0) as f32
}
/// Denominator guard for the Pearson correlation (ADR-154 §7.4 — de-magicked):
/// a product of standard deviations below this is treated as a zero-variance
/// (constant) input ⇒ correlation 0.0.
const PEARSON_DENOMINATOR_EPSILON: f32 = 1e-12;
/// Pearson correlation coefficient between two f32 slices.
///
/// Returns `0.0` for empty inputs or when either slice has (near-)zero
/// variance (the denominator falls below [`PEARSON_DENOMINATOR_EPSILON`]).
fn pearson_correlation_f32(a: &[f32], b: &[f32]) -> f32 {
let n = a.len().min(b.len());
if n == 0 {
@@ -222,7 +230,7 @@ fn pearson_correlation_f32(a: &[f32], b: &[f32]) -> f32 {
}
let denom = (var_a * var_b).sqrt();
if denom < 1e-12 {
if denom < PEARSON_DENOMINATOR_EPSILON {
return 0.0;
}
@@ -439,4 +447,24 @@ mod tests {
assert_eq!(cfg.window_us, 200_000);
assert!((cfg.min_coherence - 0.3).abs() < f32::EPSILON);
}
// -- ADR-154 §7.4: de-magic-constant + boundary characterization tests.
/// De-magicked denominator epsilon must equal the prior literal.
#[test]
fn pearson_epsilon_unchanged_from_literal() {
assert_eq!(PEARSON_DENOMINATOR_EPSILON, 1e-12_f32);
}
/// A constant (zero-variance) input makes the denominator fall below the
/// epsilon ⇒ correlation 0.0. Previously untested (existing tests use
/// non-constant inputs).
#[test]
fn pearson_correlation_zero_variance() {
let constant = vec![3.0_f32; 5];
let varying = vec![1.0_f32, 2.0, 3.0, 4.0, 5.0];
assert_eq!(pearson_correlation_f32(&constant, &varying), 0.0);
assert_eq!(pearson_correlation_f32(&varying, &constant), 0.0);
assert_eq!(pearson_correlation_f32(&constant, &constant), 0.0);
}
}
@@ -201,12 +201,29 @@ fn find_static_subcarriers(
/// Estimate per-channel phase offsets using iterative Neumann-style refinement.
///
/// Channel 0 is the reference (offset = 0).
/// Channel 0 is the reference (offset = 0). Thin wrapper that drops the
/// iteration count; `estimate_phase_offsets_counted` is the instrumented core.
fn estimate_phase_offsets(
frames: &[CanonicalCsiFrame],
static_indices: &[usize],
config: &PhaseAlignConfig,
) -> std::result::Result<Vec<f32>, PhaseAlignError> {
estimate_phase_offsets_counted(frames, static_indices, config).map(|(offsets, _iters)| offsets)
}
/// Core of [`estimate_phase_offsets`], also returning the number of refinement
/// iterations actually executed.
///
/// The returned count is bounded by `config.max_iterations` — that bound is the
/// convergence cap that guarantees termination on inputs the damped Neumann
/// update never drives below `config.tolerance` (ADR-154 §7.4 #16). The offset
/// vector is identical to the public `estimate_phase_offsets` path; only the
/// iteration count is surfaced (for the cap test).
fn estimate_phase_offsets_counted(
frames: &[CanonicalCsiFrame],
static_indices: &[usize],
config: &PhaseAlignConfig,
) -> std::result::Result<(Vec<f32>, usize), PhaseAlignError> {
let n_ch = frames.len();
let mut offsets = vec![0.0_f32; n_ch];
@@ -220,7 +237,7 @@ fn estimate_phase_offsets(
}
// Iterative refinement (Neumann-style)
for _iter in 0..config.max_iterations {
for iter in 0..config.max_iterations {
let mut max_update = 0.0_f32;
for c in 1..n_ch {
@@ -241,12 +258,13 @@ fn estimate_phase_offsets(
}
if max_update < config.tolerance {
return Ok(offsets);
return Ok((offsets, iter + 1));
}
}
// Even if we do not converge tightly, return best estimate
Ok(offsets)
// Even if we do not converge tightly, return best estimate. The loop ran the
// full cap — termination is guaranteed by `config.max_iterations`.
Ok((offsets, config.max_iterations))
}
/// Apply phase correction: subtract offset from each subcarrier phase.
@@ -446,6 +464,73 @@ mod tests {
assert_eq!(cfg.min_static_subcarriers, 5);
}
// ADR-154 §7.4 #16: the iterative LO-offset refinement must TERMINATE at the
// `max_iterations` cap on a non-converging input — no unbounded loop.
//
// We force non-convergence by setting `tolerance` to an unreachable value
// (the damped Neumann update on bounded phase residuals can never drive
// `max_update` below 0.0), so the `max_update < tolerance` early-exit is
// never taken. The instrumented core must then run *exactly*
// `max_iterations` and return — proving the cap, not convergence, is what
// bounds the loop.
#[test]
fn refinement_terminates_at_iteration_cap_when_not_converging() {
let n_sub = 56;
let max_iterations = 7;
let config = PhaseAlignConfig {
max_iterations,
// Unreachable tolerance: `max_update` is always ≥ 0, never < 0.0,
// so the convergence branch can never fire.
tolerance: 0.0,
static_fraction: 0.3,
min_static_subcarriers: 5,
};
// Two channels with a real, persistent offset so each iteration keeps
// producing a non-zero update.
let f0 = make_frame_with_phase(n_sub, 0.0, 0.0);
let f1 = make_frame_with_phase(n_sub, 0.0, 1.3);
let frames = vec![f0, f1];
let static_indices = find_static_subcarriers(&frames, &config).unwrap();
let (offsets, iters) =
estimate_phase_offsets_counted(&frames, &static_indices, &config).unwrap();
// The cap, not convergence, terminated the loop.
assert_eq!(
iters, max_iterations,
"expected the loop to run the full cap ({max_iterations}), got {iters}"
);
// It still returns a finite best-estimate offset vector.
assert_eq!(offsets.len(), 2);
assert!(offsets.iter().all(|o| o.is_finite()));
// Reference channel offset stays 0.
assert_eq!(offsets[0], 0.0);
}
// Convergent companion: a near-identical input converges *before* the cap,
// so the cap is an upper bound, not the only exit.
#[test]
fn refinement_converges_before_cap_on_easy_input() {
let n_sub = 56;
let config = PhaseAlignConfig {
max_iterations: 50,
tolerance: 1e-2, // loose: a tiny offset converges in a few iters
static_fraction: 0.3,
min_static_subcarriers: 5,
};
let f0 = make_frame_with_phase(n_sub, 0.0, 0.0);
let f1 = make_frame_with_phase(n_sub, 0.0, 0.02);
let frames = vec![f0, f1];
let static_indices = find_static_subcarriers(&frames, &config).unwrap();
let (_offsets, iters) =
estimate_phase_offsets_counted(&frames, &static_indices, &config).unwrap();
assert!(
iters < config.max_iterations,
"easy input should converge before the cap, ran {iters}/{}",
config.max_iterations
);
}
#[test]
fn phase_correction_preserves_amplitude() {
let mut aligner = PhaseAligner::new(2);
@@ -13,6 +13,27 @@
use crate::ruvsense::field_model::WelfordStats;
/// Nanoseconds per day, for migration-rate (m/day) conversion (ADR-154 §7.4 —
/// de-magicked from the inline `86_400_000_000_000.0` literal). 24·60·60·1e9.
const NS_PER_DAY: f64 = 86_400_000_000_000.0;
/// Minimum observed span (in days) below which migration rate is reported as
/// 0.0 — guards `cumulative_drift_m / span_days` against a near-zero span.
const MIGRATION_MIN_SPAN_DAYS: f64 = 1e-9;
// ADR-154 §7.4: the v1 fixed-map defaults below were bare literals in
// `fixed_map()`. They are EMPIRICAL DEFAULTS (ADR-143), unchanged.
/// Default association radius (m): a sighting within this of a reflector's
/// running mean is folded into it; otherwise it seeds a new reflector.
const FIXED_MAP_ASSOC_RADIUS_M: f64 = 0.5;
/// Default minimum sightings before a reflector counts as "persistent".
const FIXED_MAP_MIN_SIGHTINGS: u64 = 20;
/// Default minimum tap coherence for a sighting to be admitted.
const FIXED_MAP_MIN_COHERENCE: f32 = 0.6;
/// Classification of a discovered persistent reflector (mirrors ADR-139
/// `AnchorKind`; kept local to avoid a crate dependency on the WorldGraph).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -102,8 +123,8 @@ impl PersistentReflector {
if span_ns == 0 {
return 0.0;
}
let span_days = span_ns as f64 / 86_400_000_000_000.0; // ns → days
if span_days < 1e-9 {
let span_days = span_ns as f64 / NS_PER_DAY; // ns → days
if span_days < MIGRATION_MIN_SPAN_DAYS {
return 0.0;
}
self.cumulative_drift_m / span_days
@@ -145,9 +166,9 @@ impl RfSlam {
pub fn fixed_map() -> Self {
Self {
reflectors: Vec::new(),
assoc_radius_m: 0.5,
min_sightings: 20,
min_coherence: 0.6,
assoc_radius_m: FIXED_MAP_ASSOC_RADIUS_M,
min_sightings: FIXED_MAP_MIN_SIGHTINGS,
min_coherence: FIXED_MAP_MIN_COHERENCE,
discovery_enabled: false,
}
}
@@ -298,4 +319,29 @@ mod tests {
assert_eq!(anchors.len(), 1);
assert_eq!(anchors[0].1, ReflectorClass::Wall);
}
// -- ADR-154 §7.4: de-magic-constant + boundary characterization tests.
/// De-magicked constants must equal the prior inline literals.
#[test]
fn migration_consts_unchanged_from_literals() {
assert_eq!(NS_PER_DAY, 86_400_000_000_000.0);
assert_eq!(NS_PER_DAY, 24.0 * 60.0 * 60.0 * 1e9);
assert_eq!(MIGRATION_MIN_SPAN_DAYS, 1e-9);
assert_eq!(FIXED_MAP_ASSOC_RADIUS_M, 0.5);
assert_eq!(FIXED_MAP_MIN_SIGHTINGS, 20);
assert_eq!(FIXED_MAP_MIN_COHERENCE, 0.6_f32);
}
/// A single sighting has first_ns == last_ns ⇒ zero span ⇒ migration rate
/// 0.0 (pins the `span_ns == 0` / `span_days < MIGRATION_MIN_SPAN_DAYS`
/// guard, and that such a reflector classifies as a Wall).
#[test]
fn migration_zero_span_is_zero_rate() {
let mut slam = RfSlam::with_discovery(0.5, 1, 0.6);
slam.observe(&obs([1.0, 2.0, 0.0], 12_345));
let r = slam.persistent()[0];
assert_eq!(r.migration_m_per_day(), 0.0);
assert_eq!(r.classify(0.05, 1.0), ReflectorClass::Wall);
}
}
@@ -18,6 +18,16 @@ use midstreamer_temporal_compare::{ComparisonAlgorithm, Sequence, TemporalCompar
use super::gesture::{GestureConfig, GestureError, GestureResult, GestureTemplate};
/// Minimum second-best distance (ADR-154 §7.4 — de-magicked) below which the
/// relative-margin confidence `1 - best/second_best` would divide by a
/// near-zero denominator; below this we fall back to the `max_distance`-relative
/// confidence. Mirrors the same guard in `gesture.rs`.
const CONFIDENCE_SECOND_BEST_EPSILON: f64 = 1e-10;
/// Fixed-point scale used to quantize a frame's L2 norm to an i64 for the
/// integer temporal comparator (norm·SCALE truncated). Empirical resolution.
const NORM_QUANTIZATION_SCALE: f64 = 1000.0;
// ---------------------------------------------------------------------------
// Configuration
// ---------------------------------------------------------------------------
@@ -192,7 +202,10 @@ impl TemporalGestureClassifier {
let recognized = best_distance <= self.config.max_distance;
// Confidence based on margin between best and second-best
let confidence = if recognized && second_best.is_finite() && second_best > 1e-10 {
let confidence = if recognized
&& second_best.is_finite()
&& second_best > CONFIDENCE_SECOND_BEST_EPSILON
{
(1.0 - best_distance / second_best).clamp(0.0, 1.0)
} else if recognized {
(1.0 - best_distance / self.config.max_distance).clamp(0.0, 1.0)
@@ -244,13 +257,13 @@ impl TemporalGestureClassifier {
/// Convert a feature sequence to a midstreamer `Sequence<i64>`.
///
/// Each frame's L2 norm is quantized to an i64 (multiplied by 1000)
/// for use with the generic comparator.
/// Each frame's L2 norm is quantized to an i64 (multiplied by
/// [`NORM_QUANTIZATION_SCALE`]) for use with the generic comparator.
fn to_sequence(frames: &[Vec<f64>]) -> Sequence<i64> {
let mut seq = Sequence::new();
for (i, frame) in frames.iter().enumerate() {
let norm = frame.iter().map(|x| x * x).sum::<f64>().sqrt();
let quantized = (norm * 1000.0) as i64;
let quantized = (norm * NORM_QUANTIZATION_SCALE) as i64;
seq.push(quantized, i as u64);
}
seq
@@ -537,4 +550,14 @@ mod tests {
let dbg = format!("{:?}", classifier);
assert!(dbg.contains("TemporalGestureClassifier"));
}
// -- ADR-154 §7.4: de-magic-constant pin test.
/// De-magicked confidence epsilon + quantization scale must equal the
/// prior inline literals.
#[test]
fn temporal_gesture_consts_unchanged_from_literals() {
assert_eq!(CONFIDENCE_SECOND_BEST_EPSILON, 1e-10);
assert_eq!(NORM_QUANTIZATION_SCALE, 1000.0);
}
}
@@ -9,9 +9,10 @@
use ndarray::Array2;
use num_complex::Complex64;
use rustfft::FftPlanner;
use rustfft::{Fft, FftPlanner};
use ruvector_attn_mincut::attn_mincut;
use std::f64::consts::PI;
use std::sync::Arc;
/// Configuration for spectrogram generation.
#[derive(Debug, Clone)]
@@ -87,12 +88,40 @@ pub fn compute_spectrogram(
return Err(SpectrogramError::InvalidWindowSize);
}
let n_frames = (signal.len() - config.window_size) / config.hop_size + 1;
let n_freq = config.window_size / 2 + 1;
let window = make_window(config.window_fn, config.window_size);
let mut planner = FftPlanner::new();
let fft = planner.plan_fft_forward(config.window_size);
let window = make_window(config.window_fn, config.window_size);
Ok(compute_spectrogram_with_plan(
signal,
sample_rate,
config,
&fft,
&window,
))
}
/// STFT core that runs against a **pre-planned** FFT and pre-built window.
///
/// ADR-154 §7.4 #20: `compute_spectrogram` re-plans the FFT on every call, so
/// `compute_multi_subcarrier_spectrogram` (which calls it once per subcarrier)
/// re-planned the same length-`window_size` FFT for *every* subcarrier. This
/// helper hoists the plan + window out of the per-subcarrier loop. The numeric
/// body is byte-for-byte the old loop — only the plan/window construction is
/// lifted — so the output is **bit-identical** to the per-call path (asserted by
/// `multi_subcarrier_hoisted_plan_bit_identical`). Callers must pass a plan
/// built for exactly `config.window_size` and a window of that length.
fn compute_spectrogram_with_plan(
signal: &[f64],
sample_rate: f64,
config: &SpectrogramConfig,
fft: &Arc<dyn Fft<f64>>,
window: &[f64],
) -> Spectrogram {
debug_assert_eq!(window.len(), config.window_size, "window/plan size mismatch");
debug_assert_eq!(fft.len(), config.window_size, "FFT/window size mismatch");
let n_frames = (signal.len() - config.window_size) / config.hop_size + 1;
let n_freq = config.window_size / 2 + 1;
let mut data = Array2::zeros((n_freq, n_frames));
@@ -116,13 +145,13 @@ pub fn compute_spectrogram(
}
}
Ok(Spectrogram {
Spectrogram {
data,
n_freq,
n_time: n_frames,
freq_resolution: sample_rate / config.window_size as f64,
time_resolution: config.hop_size as f64 / sample_rate,
})
}
}
/// Compute spectrogram for each subcarrier from a temporal CSI matrix.
@@ -134,12 +163,40 @@ pub fn compute_multi_subcarrier_spectrogram(
sample_rate: f64,
config: &SpectrogramConfig,
) -> Result<Vec<Spectrogram>, SpectrogramError> {
let (_, n_sc) = csi_temporal.dim();
let mut spectrograms = Vec::with_capacity(n_sc);
let (n_samples, n_sc) = csi_temporal.dim();
// ADR-154 §7.4 #20: validate *once* (same checks `compute_spectrogram`
// makes), then plan the FFT + build the window *once* and reuse them across
// every subcarrier instead of re-planning per column. The window length is
// identical for all subcarriers, so this is pure hoisting — output stays
// bit-identical to the per-call path.
if n_samples < config.window_size {
return Err(SpectrogramError::SignalTooShort {
signal_len: n_samples,
window_size: config.window_size,
});
}
if config.hop_size == 0 {
return Err(SpectrogramError::InvalidHopSize);
}
if config.window_size == 0 {
return Err(SpectrogramError::InvalidWindowSize);
}
let mut planner = FftPlanner::new();
let fft = planner.plan_fft_forward(config.window_size);
let window = make_window(config.window_fn, config.window_size);
let mut spectrograms = Vec::with_capacity(n_sc);
for sc in 0..n_sc {
let col: Vec<f64> = csi_temporal.column(sc).to_vec();
spectrograms.push(compute_spectrogram(&col, sample_rate, config)?);
spectrograms.push(compute_spectrogram_with_plan(
&col,
sample_rate,
config,
&fft,
&window,
));
}
Ok(spectrograms)
@@ -372,6 +429,67 @@ mod tests {
assert_eq!(spec.n_freq, 65);
}
}
// ADR-154 §7.4 #20: the FFT-planner hoist in
// `compute_multi_subcarrier_spectrogram` must produce **bit-identical**
// output to calling `compute_spectrogram` (fresh planner) per subcarrier.
// We compare `f64::to_bits` of every spectrogram value across several
// window functions and a realistic 56-subcarrier CSI matrix — the planner
// change only reorders *when* the (identical) plan is built, never the math.
#[test]
fn multi_subcarrier_hoisted_plan_bit_identical() {
let n_samples = 600;
let n_sc = 56; // canonical-56 grid — the production subcarrier count
let sample_rate = 100.0;
let csi = Array2::from_shape_fn((n_samples, n_sc), |(t, sc)| {
// Deterministic, non-trivial per-subcarrier content.
let freq = 0.7 + sc as f64 * 0.13;
(2.0 * PI * freq * t as f64 / sample_rate).sin()
+ 0.3 * (2.0 * PI * (freq * 2.1) * t as f64 / sample_rate).cos()
});
for window_fn in [
WindowFunction::Hann,
WindowFunction::Hamming,
WindowFunction::Blackman,
WindowFunction::Rectangular,
] {
for &power in &[true, false] {
let config = SpectrogramConfig {
window_size: 128,
hop_size: 37, // non-divisor hop to exercise frame edges
window_fn,
power,
};
// AFTER: hoisted-plan path.
let hoisted =
compute_multi_subcarrier_spectrogram(&csi, sample_rate, &config).unwrap();
// BEFORE: independent per-subcarrier fresh-planner path.
let reference: Vec<Spectrogram> = (0..n_sc)
.map(|sc| {
let col: Vec<f64> = csi.column(sc).to_vec();
compute_spectrogram(&col, sample_rate, &config).unwrap()
})
.collect();
assert_eq!(hoisted.len(), reference.len());
for (sc, (h, r)) in hoisted.iter().zip(reference.iter()).enumerate() {
assert_eq!(h.data.dim(), r.data.dim(), "dim sc={sc} {window_fn:?}");
for (a, b) in h.data.iter().zip(r.data.iter()) {
assert_eq!(
a.to_bits(),
b.to_bits(),
"bit mismatch sc={sc} {window_fn:?} power={power}: {a} vs {b}"
);
}
assert_eq!(h.freq_resolution.to_bits(), r.freq_resolution.to_bits());
assert_eq!(h.time_resolution.to_bits(), r.time_resolution.to_bits());
}
}
}
}
}
#[cfg(test)]
+10
View File
@@ -50,6 +50,10 @@ pub mod error;
pub mod eval;
pub mod geometry;
pub mod mae;
/// Canonical pose-metric core (ADR-155 §Tier-1.1) — `pck_canonical` /
/// `oks_canonical`, available **without** the `tch-backend` feature so the
/// single metric definition is reachable from the workspace test gate.
pub mod metrics_core;
pub mod rapid_adapt;
pub mod ruview_metrics;
pub mod signal_features;
@@ -79,6 +83,12 @@ pub mod occupancy_bench;
pub mod trainer;
// Convenient re-exports at the crate root.
// Canonical metric (ADR-155 §Tier-1.1) — re-exported un-gated so the single
// source of truth is reachable with or without `tch-backend`.
pub use metrics_core::{
canonical_torso_size, oks_canonical, pck_canonical, CANON_LEFT_HIP, CANON_RIGHT_HIP,
COCO_KP_SIGMAS,
};
pub use config::TrainingConfig;
pub use dataset::{
CsiDataset, CsiSample, DataLoader, MmFiDataset, SyntheticConfig, SyntheticCsiDataset,
+19 -202
View File
@@ -4,7 +4,8 @@
//!
//! As of ADR-155 there is exactly **one** definition of PCK and one of OKS
//! that may be used for any *reported / claimed* number. They live in the
//! [`canonical`] region of this module:
//! un-gated [`crate::metrics_core`] module (so the single definition is
//! reachable with or without `tch-backend`) and are re-exported here:
//!
//! - [`pck_canonical`] — **PCK\@k, torso-normalized.** A keypoint `j` is
//! correct iff `‖pred_j gt_j‖₂ ≤ k · torso`, where
@@ -47,177 +48,23 @@ use petgraph::visit::EdgeRef;
use ruvector_mincut::{DynamicMinCut, MinCutBuilder};
use std::collections::VecDeque;
// ---------------------------------------------------------------------------
// COCO keypoint sigmas (17 joints)
// ---------------------------------------------------------------------------
/// Per-joint sigma values from the COCO keypoint evaluation standard.
///
/// These constants control the spread of the OKS Gaussian kernel for each
/// of the 17 COCO-defined body joints.
pub const COCO_KP_SIGMAS: [f32; 17] = [
0.026, // 0 nose
0.025, // 1 left_eye
0.025, // 2 right_eye
0.035, // 3 left_ear
0.035, // 4 right_ear
0.079, // 5 left_shoulder
0.079, // 6 right_shoulder
0.072, // 7 left_elbow
0.072, // 8 right_elbow
0.062, // 9 left_wrist
0.062, // 10 right_wrist
0.107, // 11 left_hip
0.107, // 12 right_hip
0.087, // 13 left_knee
0.087, // 14 right_knee
0.089, // 15 left_ankle
0.089, // 16 right_ankle
];
// ===========================================================================
// CANONICAL METRIC — single source of truth (ADR-155 §Tier-1.1)
// ===========================================================================
//
// The canonical metric core was hoisted to the **un-gated** `metrics_core`
// module (ADR-155 Milestone-1) so the single PCK/OKS definition is reachable
// from the workspace test gate (`--no-default-features`) — this whole `metrics`
// module is gated behind `tch-backend`. Re-exporting here keeps every existing
// call site (`MetricsAccumulator`, `compute_pck`, the deprecated v2 path, the
// tch trainer) pointing at exactly **one** implementation.
/// COCO joint index of the left hip.
pub const CANON_LEFT_HIP: usize = 11;
/// COCO joint index of the right hip.
pub const CANON_RIGHT_HIP: usize = 12;
/// Canonical torso normalizer used by [`pck_canonical`].
///
/// Returns `‖left_hip right_hip‖₂` (COCO joints 11↔12) when both hips are
/// visible; otherwise the diagonal of the visible-keypoint bounding box. The
/// distance is computed in whatever coordinate space `kpts` is expressed in
/// (the canonical PCK requires pred and gt to share that space).
///
/// Returns `None` when there is no positive-extent reference available (no
/// visible hips *and* a degenerate/empty visible bbox), signalling the caller
/// that the sample cannot be scored.
pub fn canonical_torso_size(gt_kpts: &Array2<f32>, visibility: &Array1<f32>) -> Option<f32> {
let n = gt_kpts.shape()[0].min(visibility.len());
if CANON_LEFT_HIP < n
&& CANON_RIGHT_HIP < n
&& visibility[CANON_LEFT_HIP] >= 0.5
&& visibility[CANON_RIGHT_HIP] >= 0.5
{
let dx = gt_kpts[[CANON_LEFT_HIP, 0]] - gt_kpts[[CANON_RIGHT_HIP, 0]];
let dy = gt_kpts[[CANON_LEFT_HIP, 1]] - gt_kpts[[CANON_RIGHT_HIP, 1]];
let torso = (dx * dx + dy * dy).sqrt();
if torso > 1e-6 {
return Some(torso);
}
}
// Fallback: bounding-box diagonal of visible keypoints.
let diag = bounding_box_diagonal(gt_kpts, visibility, n);
if diag > 1e-6 {
Some(diag)
} else {
None
}
}
/// **CANONICAL PCK\@`threshold`** — the single definition used for every
/// reported number (ADR-155 §Tier-1.1).
///
/// A keypoint `j` with `visibility[j] >= 0.5` is *correct* iff
/// `‖pred_j gt_j‖₂ ≤ threshold · torso`, where `torso` is
/// [`canonical_torso_size`] in the keypoint coordinate space.
///
/// # Returns
/// `(correct, total, pck)` where `pck ∈ [0,1]`. **`(0, 0, 0.0)` when no
/// keypoint is visible or the torso reference is degenerate** — a sample with
/// no measurable evidence scores 0, never 1 (closes the
/// `MetricsAccumulator` false-perfect bug).
pub fn pck_canonical(
pred_kpts: &Array2<f32>,
gt_kpts: &Array2<f32>,
visibility: &Array1<f32>,
threshold: f32,
) -> (usize, usize, f32) {
let n = pred_kpts.shape()[0]
.min(gt_kpts.shape()[0])
.min(visibility.len());
let torso = match canonical_torso_size(gt_kpts, visibility) {
Some(t) => t,
// No measurable reference scale ⇒ cannot score ⇒ 0.0 (NOT trivially 1.0).
None => return (0, 0, 0.0),
};
let dist_threshold = threshold * torso;
let mut correct = 0usize;
let mut total = 0usize;
for j in 0..n {
if visibility[j] < 0.5 {
continue;
}
total += 1;
let dx = pred_kpts[[j, 0]] - gt_kpts[[j, 0]];
let dy = pred_kpts[[j, 1]] - gt_kpts[[j, 1]];
if (dx * dx + dy * dy).sqrt() <= dist_threshold {
correct += 1;
}
}
let pck = if total > 0 {
correct as f32 / total as f32
} else {
0.0
};
(correct, total, pck)
}
/// **CANONICAL OKS** — COCO Object Keypoint Similarity (ADR-155 §Tier-1.1).
///
/// `OKS = Σⱼ exp(dⱼ² / (2 s² kⱼ²)) · δ(vⱼ≥0.5) / Σⱼ δ(vⱼ≥0.5)` with
/// `s = sqrt(area)` derived from the **GT keypoint bounding box in the
/// keypoint coordinate space** (via [`canonical_torso_size`]² as a robust,
/// always-positive proxy for area when an explicit bbox is unavailable).
///
/// Passing normalized [0,1] coordinates is fine *because the scale is derived
/// from the pose itself* — there is no `s = 1.0` escape hatch that would make
/// OKS ≈ 1.0 for any pose (the historical "fake Gold tier" bug).
///
/// Returns 0.0 when no keypoints are visible or the scale is degenerate.
pub fn oks_canonical(
pred_kpts: &Array2<f32>,
gt_kpts: &Array2<f32>,
visibility: &Array1<f32>,
) -> f32 {
let n = pred_kpts.shape()[0]
.min(gt_kpts.shape()[0])
.min(visibility.len());
// Scale: area ≈ torso². Derived from the actual pose, never a fixed 1.0.
let s = match canonical_torso_size(gt_kpts, visibility) {
Some(t) => t,
None => return 0.0,
};
let s_sq = s * s;
if s_sq <= 0.0 {
return 0.0;
}
let mut num = 0.0f32;
let mut den = 0.0f32;
for j in 0..n {
if visibility[j] < 0.5 {
continue;
}
den += 1.0;
let dx = pred_kpts[[j, 0]] - gt_kpts[[j, 0]];
let dy = pred_kpts[[j, 1]] - gt_kpts[[j, 1]];
let d_sq = dx * dx + dy * dy;
let k = if j < COCO_KP_SIGMAS.len() {
COCO_KP_SIGMAS[j]
} else {
0.07
};
num += (-d_sq / (2.0 * s_sq * k * k)).exp();
}
if den > 0.0 {
num / den
} else {
0.0
}
}
pub use crate::metrics_core::{
canonical_torso_size, oks_canonical, pck_canonical, CANON_LEFT_HIP, CANON_RIGHT_HIP,
COCO_KP_SIGMAS,
};
// `bounding_box_diagonal` stays crate-internal (metrics_core); the only caller
// here is a test, which references it via its full path.
// ---------------------------------------------------------------------------
// MetricsResult
@@ -400,39 +247,9 @@ impl MetricsAccumulator {
// ---------------------------------------------------------------------------
// Geometric helpers
// ---------------------------------------------------------------------------
/// Compute the Euclidean diagonal of the bounding box of visible keypoints.
///
/// The bounding box is defined by the axis-aligned extent of all keypoints
/// that have `visibility[j] >= 0.5`. Returns 0.0 if there are no visible
/// keypoints or all are co-located.
fn bounding_box_diagonal(kp: &Array2<f32>, visibility: &Array1<f32>, num_joints: usize) -> f32 {
let mut x_min = f32::MAX;
let mut x_max = f32::MIN;
let mut y_min = f32::MAX;
let mut y_max = f32::MIN;
let mut any_visible = false;
for j in 0..num_joints {
if visibility[j] >= 0.5 {
let x = kp[[j, 0]];
let y = kp[[j, 1]];
x_min = x_min.min(x);
x_max = x_max.max(x);
y_min = y_min.min(y);
y_max = y_max.max(y);
any_visible = true;
}
}
if !any_visible {
return 0.0;
}
let w = (x_max - x_min).max(0.0);
let h = (y_max - y_min).max(0.0);
(w * w + h * h).sqrt()
}
//
// `bounding_box_diagonal` (the canonical normalizer's bbox fallback) now lives
// in `metrics_core` alongside the canonical metric it supports.
// ---------------------------------------------------------------------------
// Per-sample PCK and OKS free functions (required by the training evaluator)
@@ -1441,7 +1258,7 @@ mod tests {
fn bbox_diagonal_unit_square() {
let kp = array![[0.0_f32, 0.0], [1.0, 1.0]];
let vis = array![2.0_f32, 2.0];
let diag = bounding_box_diagonal(&kp, &vis, 2);
let diag = crate::metrics_core::bounding_box_diagonal(&kp, &vis, 2);
assert_abs_diff_eq!(diag, std::f32::consts::SQRT_2, epsilon = 1e-5);
}
@@ -0,0 +1,251 @@
//! Canonical pose-metric core (ADR-155 §Tier-1.1) — the single source of truth
//! for PCK and OKS, **available without the `tch-backend` feature**.
//!
//! # Why this module exists (ADR-155 Milestone-1, §8 backlog resolution)
//!
//! The full [`crate::metrics`] module is gated behind `tch-backend` (libtorch
//! FFI) because it also hosts the trainer accumulators, min-cut matchers, and
//! ndarray/petgraph machinery. But the *metric definition itself*
//! ([`pck_canonical`], [`oks_canonical`], [`canonical_torso_size`]) depends only
//! on `ndarray` — no tch. Hoisting those four functions here makes the canonical
//! definition reachable from the workspace test gate
//! (`cargo test --no-default-features`) so the integration test
//! (`tests/test_metrics.rs`) can validate the **production** function against
//! hand-computed fixtures, instead of testing an independent reimplementation
//! that could be wrong the same way (the §8 "reference kernels" finding).
//!
//! [`crate::metrics`] re-exports every item here, so all existing call sites and
//! the tch-gated trainer path are unchanged: there is still exactly **one**
//! implementation of each metric, now in one *un-gated* place.
//!
//! # CANONICAL METRIC (the only definitions valid for a *reported* number)
//!
//! - [`pck_canonical`] — **PCK\@k, torso-normalized.** A keypoint `j` is correct
//! iff `‖pred_j gt_j‖₂ ≤ k · torso`, where
//! `torso = ‖left_hip(11) right_hip(12)‖₂` in the keypoint coordinate space,
//! with a bounding-box-diagonal fallback when the hips are not both visible.
//! **Zero visible joints ⇒ `(0, 0, 0.0)`** — no evidence scores 0, never 1.
//! - [`oks_canonical`] — **COCO OKS** with `s = sqrt(area)` derived from the GT
//! pose extent (never a fixed `1.0`); a degenerate pose returns 0.0.
//!
//! # No mock data
//!
//! All computations are grounded in real geometry following published metric
//! definitions. No random or synthetic values are introduced at runtime.
use ndarray::{Array1, Array2};
// ---------------------------------------------------------------------------
// COCO keypoint sigmas (17 joints)
// ---------------------------------------------------------------------------
/// Per-joint sigma values from the COCO keypoint evaluation standard.
///
/// These constants control the spread of the OKS Gaussian kernel for each
/// of the 17 COCO-defined body joints.
pub const COCO_KP_SIGMAS: [f32; 17] = [
0.026, // 0 nose
0.025, // 1 left_eye
0.025, // 2 right_eye
0.035, // 3 left_ear
0.035, // 4 right_ear
0.079, // 5 left_shoulder
0.079, // 6 right_shoulder
0.072, // 7 left_elbow
0.072, // 8 right_elbow
0.062, // 9 left_wrist
0.062, // 10 right_wrist
0.107, // 11 left_hip
0.107, // 12 right_hip
0.087, // 13 left_knee
0.087, // 14 right_knee
0.089, // 15 left_ankle
0.089, // 16 right_ankle
];
// ===========================================================================
// CANONICAL METRIC — single source of truth (ADR-155 §Tier-1.1)
// ===========================================================================
/// COCO joint index of the left hip.
pub const CANON_LEFT_HIP: usize = 11;
/// COCO joint index of the right hip.
pub const CANON_RIGHT_HIP: usize = 12;
/// Compute the Euclidean diagonal of the bounding box of visible keypoints.
///
/// The bounding box is defined by the axis-aligned extent of all keypoints
/// that have `visibility[j] >= 0.5`. Returns 0.0 if there are no visible
/// keypoints or all are co-located.
pub(crate) fn bounding_box_diagonal(
kp: &Array2<f32>,
visibility: &Array1<f32>,
num_joints: usize,
) -> f32 {
let mut x_min = f32::MAX;
let mut x_max = f32::MIN;
let mut y_min = f32::MAX;
let mut y_max = f32::MIN;
let mut any_visible = false;
for j in 0..num_joints {
if visibility[j] >= 0.5 {
let x = kp[[j, 0]];
let y = kp[[j, 1]];
x_min = x_min.min(x);
x_max = x_max.max(x);
y_min = y_min.min(y);
y_max = y_max.max(y);
any_visible = true;
}
}
if !any_visible {
return 0.0;
}
let w = (x_max - x_min).max(0.0);
let h = (y_max - y_min).max(0.0);
(w * w + h * h).sqrt()
}
/// Canonical torso normalizer used by [`pck_canonical`].
///
/// Returns `‖left_hip right_hip‖₂` (COCO joints 11↔12) when both hips are
/// visible; otherwise the diagonal of the visible-keypoint bounding box. The
/// distance is computed in whatever coordinate space `gt_kpts` is expressed in
/// (the canonical PCK requires pred and gt to share that space).
///
/// Returns `None` when there is no positive-extent reference available (no
/// visible hips *and* a degenerate/empty visible bbox), signalling the caller
/// that the sample cannot be scored.
pub fn canonical_torso_size(gt_kpts: &Array2<f32>, visibility: &Array1<f32>) -> Option<f32> {
let n = gt_kpts.shape()[0].min(visibility.len());
if CANON_LEFT_HIP < n
&& CANON_RIGHT_HIP < n
&& visibility[CANON_LEFT_HIP] >= 0.5
&& visibility[CANON_RIGHT_HIP] >= 0.5
{
let dx = gt_kpts[[CANON_LEFT_HIP, 0]] - gt_kpts[[CANON_RIGHT_HIP, 0]];
let dy = gt_kpts[[CANON_LEFT_HIP, 1]] - gt_kpts[[CANON_RIGHT_HIP, 1]];
let torso = (dx * dx + dy * dy).sqrt();
if torso > 1e-6 {
return Some(torso);
}
}
// Fallback: bounding-box diagonal of visible keypoints.
let diag = bounding_box_diagonal(gt_kpts, visibility, n);
if diag > 1e-6 {
Some(diag)
} else {
None
}
}
/// **CANONICAL PCK\@`threshold`** — the single definition used for every
/// reported number (ADR-155 §Tier-1.1).
///
/// A keypoint `j` with `visibility[j] >= 0.5` is *correct* iff
/// `‖pred_j gt_j‖₂ ≤ threshold · torso`, where `torso` is
/// [`canonical_torso_size`] in the keypoint coordinate space.
///
/// # Returns
/// `(correct, total, pck)` where `pck ∈ [0,1]`. **`(0, 0, 0.0)` when no
/// keypoint is visible or the torso reference is degenerate** — a sample with
/// no measurable evidence scores 0, never 1 (closes the
/// `MetricsAccumulator` false-perfect bug).
///
/// # Normalization basis (vs other PCK definitions in the workspace)
/// This is **hip↔hip torso WIDTH** normalized in the keypoint coordinate space.
/// It is deliberately **distinct** from the live sensing-server's
/// `compute_pck_torso_height` (torso-HEIGHT nose→hip, pixel-space) — see ADR-155
/// §2.1 / §8. Those numbers must never be conflated.
pub fn pck_canonical(
pred_kpts: &Array2<f32>,
gt_kpts: &Array2<f32>,
visibility: &Array1<f32>,
threshold: f32,
) -> (usize, usize, f32) {
let n = pred_kpts.shape()[0]
.min(gt_kpts.shape()[0])
.min(visibility.len());
let torso = match canonical_torso_size(gt_kpts, visibility) {
Some(t) => t,
// No measurable reference scale ⇒ cannot score ⇒ 0.0 (NOT trivially 1.0).
None => return (0, 0, 0.0),
};
let dist_threshold = threshold * torso;
let mut correct = 0usize;
let mut total = 0usize;
for j in 0..n {
if visibility[j] < 0.5 {
continue;
}
total += 1;
let dx = pred_kpts[[j, 0]] - gt_kpts[[j, 0]];
let dy = pred_kpts[[j, 1]] - gt_kpts[[j, 1]];
if (dx * dx + dy * dy).sqrt() <= dist_threshold {
correct += 1;
}
}
let pck = if total > 0 {
correct as f32 / total as f32
} else {
0.0
};
(correct, total, pck)
}
/// **CANONICAL OKS** — COCO Object Keypoint Similarity (ADR-155 §Tier-1.1).
///
/// `OKS = Σⱼ exp(dⱼ² / (2 s² kⱼ²)) · δ(vⱼ≥0.5) / Σⱼ δ(vⱼ≥0.5)` with
/// `s = sqrt(area)` derived from the **GT keypoint bounding box in the
/// keypoint coordinate space** (via [`canonical_torso_size`]² as a robust,
/// always-positive proxy for area when an explicit bbox is unavailable).
///
/// Passing normalized [0,1] coordinates is fine *because the scale is derived
/// from the pose itself* — there is no `s = 1.0` escape hatch that would make
/// OKS ≈ 1.0 for any pose (the historical "fake Gold tier" bug).
///
/// Returns 0.0 when no keypoints are visible or the scale is degenerate.
pub fn oks_canonical(
pred_kpts: &Array2<f32>,
gt_kpts: &Array2<f32>,
visibility: &Array1<f32>,
) -> f32 {
let n = pred_kpts.shape()[0]
.min(gt_kpts.shape()[0])
.min(visibility.len());
// Scale: area ≈ torso². Derived from the actual pose, never a fixed 1.0.
let s = match canonical_torso_size(gt_kpts, visibility) {
Some(t) => t,
None => return 0.0,
};
let s_sq = s * s;
if s_sq <= 0.0 {
return 0.0;
}
let mut num = 0.0f32;
let mut den = 0.0f32;
for j in 0..n {
if visibility[j] < 0.5 {
continue;
}
den += 1.0;
let dx = pred_kpts[[j, 0]] - gt_kpts[[j, 0]];
let dy = pred_kpts[[j, 1]] - gt_kpts[[j, 1]];
let d_sq = dx * dx + dy * dy;
let k = if j < COCO_KP_SIGMAS.len() {
COCO_KP_SIGMAS[j]
} else {
0.07
};
num += (-d_sq / (2.0 * s_sq * k * k)).exp();
}
if den > 0.0 {
num / den
} else {
0.0
}
}
@@ -1,14 +1,34 @@
//! Integration tests for [`wifi_densepose_train::metrics`].
//! Integration tests for `wifi_densepose_train` pose metrics.
//!
//! The metrics module is only compiled when the `tch-backend` feature is
//! enabled (because it is gated in `lib.rs`). Tests that use
//! `EvalMetrics` are wrapped in `#[cfg(feature = "tch-backend")]`.
//! # ADR-155 Milestone-1 — §8 "reference kernels" resolution
//!
//! The deterministic PCK, OKS, and Hungarian assignment tests that require
//! no tch dependency are implemented inline in the non-gated section below
//! using hand-computed helper functions.
//! The full `metrics` module is gated behind `tch-backend` (libtorch), but the
//! **canonical** metric core (`pck_canonical` / `oks_canonical`) now lives in
//! the un-gated `metrics_core` module and is re-exported at the crate root, so
//! these workspace tests (run under `--no-default-features`) validate the
//! **production** functions directly.
//!
//! All inputs are fixed, deterministic arrays — no `rand`, no OS entropy.
//! Previously this file carried its own local `compute_pck` / `compute_oks`
//! reimplementations and asserted properties of *those* — a test that could
//! not catch a bug in the canonical implementation (both could be wrong the
//! same way). That is fixed two ways here:
//!
//! 1. **Fixture tests** (`canonical_pck_matches_hand_computed_fixture`,
//! `canonical_oks_*`) assert the production `pck_canonical` / `oks_canonical`
//! equal *hand-computed* expected values — numbers worked out by hand below,
//! NOT a second implementation of the same algorithm.
//! 2. **Differential test** (`test_kernel_agrees_with_canonical`) keeps a small
//! independent reference kernel and asserts it **agrees** with the canonical
//! function on shared inputs (in the torso=raw-threshold regime where the two
//! coincide), so the reference adds genuine cross-check value rather than
//! duplicating the algorithm under test.
//!
//! `EvalMetrics` tests remain `#[cfg(feature = "tch-backend")]` (that type is in
//! the gated module). All inputs are fixed, deterministic arrays — no `rand`,
//! no OS entropy.
use ndarray::{Array1, Array2};
use wifi_densepose_train::{oks_canonical, pck_canonical, CANON_LEFT_HIP, CANON_RIGHT_HIP};
// ---------------------------------------------------------------------------
// Tests that use `EvalMetrics` (requires tch-backend because the metrics
@@ -163,146 +183,236 @@ mod eval_metrics_tests {
}
// ---------------------------------------------------------------------------
// Deterministic PCK computation tests (pure Rust, no tch, no feature gate)
// Canonical PCK / OKS validation (production functions, no tch)
// ---------------------------------------------------------------------------
/// Compute PCK@threshold for a (pred, gt) pair.
fn compute_pck(pred: &[[f64; 2]], gt: &[[f64; 2]], threshold: f64) -> f64 {
let n = pred.len();
if n == 0 {
return 0.0;
/// Build a 17-joint pose in `[0,1]` coordinates from an `(x, y)` per-joint list,
/// padding any unspecified joint to `(0,0)`. Returns `[17, 2]`.
fn pose17(joints: &[(usize, f32, f32)]) -> Array2<f32> {
let mut a = Array2::<f32>::zeros((17, 2));
for &(j, x, y) in joints {
a[[j, 0]] = x;
a[[j, 1]] = y;
}
let correct = pred
.iter()
.zip(gt.iter())
.filter(|(p, g)| {
let dx = p[0] - g[0];
let dy = p[1] - g[1];
(dx * dx + dy * dy).sqrt() <= threshold
})
.count();
correct as f64 / n as f64
a
}
/// PCK of a perfect prediction (pred == gt) must be 1.0.
#[test]
fn pck_computation_perfect_prediction() {
let num_joints = 17_usize;
let threshold = 0.5_f64;
let pred: Vec<[f64; 2]> = (0..num_joints)
.map(|j| [j as f64 * 0.05, j as f64 * 0.04])
.collect();
let gt = pred.clone();
let pck = compute_pck(&pred, &gt, threshold);
assert!(
(pck - 1.0).abs() < 1e-9,
"PCK for perfect prediction must be 1.0, got {pck}"
);
}
/// PCK of completely wrong predictions must be 0.0.
#[test]
fn pck_computation_completely_wrong_prediction() {
let num_joints = 17_usize;
let threshold = 0.05_f64;
let gt: Vec<[f64; 2]> = (0..num_joints).map(|_| [0.0, 0.0]).collect();
let pred: Vec<[f64; 2]> = (0..num_joints).map(|_| [10.0, 10.0]).collect();
let pck = compute_pck(&pred, &gt, threshold);
assert!(
pck.abs() < 1e-9,
"PCK for completely wrong prediction must be 0.0, got {pck}"
);
}
/// PCK is monotone: a prediction closer to GT scores higher.
#[test]
fn pck_monotone_with_accuracy() {
let gt = vec![[0.5_f64, 0.5_f64]];
let close_pred = vec![[0.51_f64, 0.50_f64]];
let far_pred = vec![[0.60_f64, 0.50_f64]];
let very_far_pred = vec![[0.90_f64, 0.50_f64]];
let threshold = 0.05_f64;
let pck_close = compute_pck(&close_pred, &gt, threshold);
let pck_far = compute_pck(&far_pred, &gt, threshold);
let pck_very_far = compute_pck(&very_far_pred, &gt, threshold);
assert!(
pck_close >= pck_far,
"closer prediction must score at least as high: close={pck_close}, far={pck_far}"
);
assert!(
pck_far >= pck_very_far,
"farther prediction must score lower or equal: far={pck_far}, very_far={pck_very_far}"
);
}
// ---------------------------------------------------------------------------
// Deterministic OKS computation tests (pure Rust, no tch, no feature gate)
// ---------------------------------------------------------------------------
/// Compute OKS for a (pred, gt) pair.
fn compute_oks(pred: &[[f64; 2]], gt: &[[f64; 2]], sigma: f64, scale: f64) -> f64 {
let n = pred.len();
if n == 0 {
return 0.0;
/// Visibility vector with the listed joints visible (`2.0`), rest invisible.
fn vis17(visible: &[usize]) -> Array1<f32> {
let mut v = Array1::<f32>::zeros(17);
for &j in visible {
v[j] = 2.0;
}
let denom = 2.0 * scale * scale * sigma * sigma;
let sum: f64 = pred
.iter()
.zip(gt.iter())
.map(|(p, g)| {
let dx = p[0] - g[0];
let dy = p[1] - g[1];
(-(dx * dx + dy * dy) / denom).exp()
})
.sum();
sum / n as f64
v
}
/// OKS of a perfect prediction (pred == gt) must be 1.0.
/// **Fixture test (Goal B).** The production `pck_canonical` must equal a value
/// worked out *by hand* on a constructed pose — not a reimplementation.
///
/// Construction (all coordinates in `[0,1]`):
/// * left_hip(11) = (0.40, 0.50), right_hip(12) = (0.60, 0.50)
/// ⇒ canonical torso = hip↔hip width = 0.20.
/// * threshold = 0.2 ⇒ dist_threshold = 0.2 × 0.20 = **0.04**.
/// * Visible joints: {0 (nose), 5 (l_shoulder), 11, 12}. (4 visible.)
/// - nose(0): pred == gt ⇒ dist 0.00 ≤ 0.04 ⇒ CORRECT
/// - l_shoulder(5): pred off by dy=0.10 ⇒ dist 0.10 > 0.04 ⇒ wrong
/// - l_hip(11): pred == gt ⇒ dist 0.00 ≤ 0.04 ⇒ CORRECT
/// - r_hip(12): pred off by dx=0.03 ⇒ dist 0.03 ≤ 0.04 ⇒ CORRECT
/// Hand result: correct = 3, total = 4, pck = 3/4 = **0.75**.
#[test]
fn oks_perfect_prediction_is_one() {
let num_joints = 17_usize;
let sigma = 0.05_f64;
let scale = 1.0_f64;
fn canonical_pck_matches_hand_computed_fixture() {
let gt = pose17(&[
(0, 0.50, 0.20), // nose
(5, 0.35, 0.35), // left_shoulder
(CANON_LEFT_HIP, 0.40, 0.50),
(CANON_RIGHT_HIP, 0.60, 0.50),
]);
let pred = pose17(&[
(0, 0.50, 0.20), // exact
(5, 0.35, 0.45), // off by dy = 0.10 (> 0.04)
(CANON_LEFT_HIP, 0.40, 0.50), // exact
(CANON_RIGHT_HIP, 0.63, 0.50), // off by dx = 0.03 (<= 0.04)
]);
let vis = vis17(&[0, 5, CANON_LEFT_HIP, CANON_RIGHT_HIP]);
let pred: Vec<[f64; 2]> = (0..num_joints).map(|j| [j as f64 * 0.05, 0.3]).collect();
let gt = pred.clone();
let oks = compute_oks(&pred, &gt, sigma, scale);
let (correct, total, pck) = pck_canonical(&pred, &gt, &vis, 0.2);
assert_eq!(total, 4, "4 visible joints expected, got {total}");
assert_eq!(correct, 3, "hand-computed: 3 of 4 within 0.04, got {correct}");
assert!(
(oks - 1.0).abs() < 1e-9,
"OKS for perfect prediction must be 1.0, got {oks}"
(pck - 0.75).abs() < 1e-6,
"hand-computed PCK is 0.75, got {pck}"
);
}
/// OKS must decrease as the L2 distance between pred and GT increases.
/// Pin the **normalizer**: PCK uses hip↔hip torso width. A prediction error of
/// 0.18 (just under 0.2 × torso=1.0 wide hips) is CORRECT, but the same error
/// is WRONG once the hips are squeezed to width 0.20 (threshold 0.04). If the
/// implementation ignored the torso normalizer this test would fail.
#[test]
fn oks_decreases_with_distance() {
let sigma = 0.05_f64;
let scale = 1.0_f64;
fn canonical_pck_uses_hip_to_hip_torso_normalizer() {
// Wide hips: width 1.0 ⇒ threshold 0.2. An error of 0.18 on joint 5 is OK.
let gt_wide = pose17(&[(5, 0.50, 0.50), (CANON_LEFT_HIP, 0.0, 0.5), (CANON_RIGHT_HIP, 1.0, 0.5)]);
let pred_wide = pose17(&[(5, 0.68, 0.50), (CANON_LEFT_HIP, 0.0, 0.5), (CANON_RIGHT_HIP, 1.0, 0.5)]);
let vis = vis17(&[5, CANON_LEFT_HIP, CANON_RIGHT_HIP]);
let (_, _, pck_wide) = pck_canonical(&pred_wide, &gt_wide, &vis, 0.2);
let gt = vec![[0.5_f64, 0.5_f64]];
let pred_d0 = vec![[0.5_f64, 0.5_f64]];
let pred_d1 = vec![[0.6_f64, 0.5_f64]];
let pred_d2 = vec![[1.0_f64, 0.5_f64]];
let oks_d0 = compute_oks(&pred_d0, &gt, sigma, scale);
let oks_d1 = compute_oks(&pred_d1, &gt, sigma, scale);
let oks_d2 = compute_oks(&pred_d2, &gt, sigma, scale);
// Narrow hips: width 0.20 ⇒ threshold 0.04. Same 0.18 error on joint 5 is wrong.
let gt_narrow = pose17(&[(5, 0.50, 0.50), (CANON_LEFT_HIP, 0.40, 0.5), (CANON_RIGHT_HIP, 0.60, 0.5)]);
let pred_narrow = pose17(&[(5, 0.68, 0.50), (CANON_LEFT_HIP, 0.40, 0.5), (CANON_RIGHT_HIP, 0.60, 0.5)]);
let (_, _, pck_narrow) = pck_canonical(&pred_narrow, &gt_narrow, &vis, 0.2);
// Joints 11/12 are exact (correct in both); joint 5 flips.
// Wide: 3/3 = 1.0; Narrow: 2/3 ≈ 0.667.
assert!((pck_wide - 1.0).abs() < 1e-6, "wide-hip PCK should be 1.0, got {pck_wide}");
assert!(
oks_d0 > oks_d1,
"OKS at distance 0 must be > OKS at distance 0.1: {oks_d0} vs {oks_d1}"
(pck_narrow - 2.0 / 3.0).abs() < 1e-6,
"narrow-hip PCK should be 2/3 (joint 5 now out of tolerance), got {pck_narrow}"
);
}
/// The claim-inflating bug: no visible joints must score **0.0**, never 1.0.
#[test]
fn canonical_pck_zero_visible_is_zero() {
let kpts = pose17(&[(CANON_LEFT_HIP, 0.4, 0.5), (CANON_RIGHT_HIP, 0.6, 0.5)]);
let vis = vis17(&[]); // nothing visible
let (correct, total, pck) = pck_canonical(&kpts, &kpts, &vis, 0.2);
assert_eq!((correct, total), (0, 0));
assert_eq!(pck, 0.0, "no-visible-joint PCK must be 0.0 (not the old 1.0)");
}
// ---------------------------------------------------------------------------
// Canonical OKS validation (production function, no tch)
// ---------------------------------------------------------------------------
/// **Fixture test (Goal B).** A perfect prediction (pred == gt) makes every
/// Gaussian term `exp(0) = 1`, so the canonical OKS is exactly **1.0** —
/// hand-evident, independent of the (positive) scale.
#[test]
fn canonical_oks_perfect_prediction_is_one() {
let gt = pose17(&[
(0, 0.50, 0.20),
(5, 0.35, 0.35),
(CANON_LEFT_HIP, 0.40, 0.50),
(CANON_RIGHT_HIP, 0.60, 0.50),
]);
let vis = vis17(&[0, 5, CANON_LEFT_HIP, CANON_RIGHT_HIP]);
let oks = oks_canonical(&gt, &gt, &vis);
assert!(
oks_d1 > oks_d2,
"OKS at distance 0.1 must be > OKS at distance 0.5: {oks_d1} vs {oks_d2}"
(oks - 1.0).abs() < 1e-6,
"OKS for a perfect prediction must be 1.0, got {oks}"
);
}
/// **The "fake Gold tier" bug, pinned (Goal B).** On normalized `[0,1]`
/// coordinates the historical `s = 1.0` path returned ≈1.0 for *any* pose.
/// Canonical derives `s` from the pose extent (here torso width = 0.20), so a
/// pose whose visible non-hip joint is off by ~3× the torso scores far below
/// the "Gold" tier. Hand bound: for joint 5 with d ≈ 0.60, s = 0.20, k = 0.079,
/// the exponent `-d²/(2 s² k²)` is enormously negative ⇒ that term ≈ 0; the two
/// (exact) hip terms give 1 each ⇒ OKS ≈ 2/3 at most, and with joint-5 ≈ 0 the
/// mean is ≈ 0.667. We assert it is comfortably **< 0.8** (and the wrong joint
/// contributes ≈ 0), i.e. nowhere near the old ≈1.0.
#[test]
fn canonical_oks_not_one_for_wrong_pose_on_normalized_coords() {
let gt = pose17(&[
(5, 0.30, 0.50),
(CANON_LEFT_HIP, 0.40, 0.50),
(CANON_RIGHT_HIP, 0.60, 0.50),
]);
// Joint 5 dragged 0.60 away (3× the 0.20 torso); hips exact.
let pred = pose17(&[
(5, 0.90, 0.50),
(CANON_LEFT_HIP, 0.40, 0.50),
(CANON_RIGHT_HIP, 0.60, 0.50),
]);
let vis = vis17(&[5, CANON_LEFT_HIP, CANON_RIGHT_HIP]);
let oks = oks_canonical(&pred, &gt, &vis);
assert!(
oks < 0.8,
"wrong-pose OKS on [0,1] coords must NOT be ≈1.0 (fake-Gold bug); got {oks}"
);
// The two exact hips alone give 2/3; the wrong joint must add ~nothing.
assert!(
(oks - 2.0 / 3.0).abs() < 0.05,
"wrong joint should contribute ≈0 ⇒ OKS ≈ 2/3, got {oks}"
);
}
/// Canonical OKS decreases monotonically with prediction error.
#[test]
fn canonical_oks_decreases_with_distance() {
let gt = pose17(&[(5, 0.50, 0.50), (CANON_LEFT_HIP, 0.40, 0.50), (CANON_RIGHT_HIP, 0.60, 0.50)]);
let vis = vis17(&[5, CANON_LEFT_HIP, CANON_RIGHT_HIP]);
let mk = |x5: f32| pose17(&[(5, x5, 0.50), (CANON_LEFT_HIP, 0.40, 0.50), (CANON_RIGHT_HIP, 0.60, 0.50)]);
let oks0 = oks_canonical(&mk(0.50), &gt, &vis);
let oks1 = oks_canonical(&mk(0.52), &gt, &vis);
let oks2 = oks_canonical(&mk(0.60), &gt, &vis);
assert!(oks0 > oks1, "OKS must drop as error grows: {oks0} vs {oks1}");
assert!(oks1 > oks2, "OKS must drop as error grows: {oks1} vs {oks2}");
}
// ---------------------------------------------------------------------------
// Differential cross-check: independent reference kernel vs canonical (Goal B)
// ---------------------------------------------------------------------------
/// A deliberately *independent* PCK reference implementation in the simplest
/// regime — a **raw distance threshold** (no torso normalization). It is kept
/// only to cross-check the canonical function, not to define the metric.
fn reference_pck_raw(pred: &[(f32, f32)], gt: &[(f32, f32)], dist_threshold: f32) -> (usize, usize, f32) {
let n = pred.len().min(gt.len());
let mut correct = 0usize;
for i in 0..n {
let dx = pred[i].0 - gt[i].0;
let dy = pred[i].1 - gt[i].1;
if (dx * dx + dy * dy).sqrt() <= dist_threshold {
correct += 1;
}
}
let pck = if n > 0 { correct as f32 / n as f32 } else { 0.0 };
(correct, n, pck)
}
/// **Differential test (Goal B).** In the regime where the canonical torso
/// normalizer equals 1.0 (hips exactly one unit apart, so `threshold · torso`
/// reduces to the raw `threshold`), the canonical PCK and an independent
/// raw-threshold reference kernel MUST agree on shared inputs. This catches a
/// canonical-side bug that a pure self-fixture could miss, *because* the second
/// implementation is genuinely independent.
#[test]
fn test_kernel_agrees_with_canonical() {
// Hips one unit apart ⇒ canonical torso == 1.0 ⇒ dist_threshold == threshold.
let gt = pose17(&[
(0, 0.30, 0.30),
(5, 0.55, 0.55),
(7, 0.10, 0.90),
(CANON_LEFT_HIP, 0.00, 0.50),
(CANON_RIGHT_HIP, 1.00, 0.50),
]);
let pred = pose17(&[
(0, 0.31, 0.30), // err 0.01
(5, 0.70, 0.55), // err 0.15
(7, 0.10, 0.98), // err 0.08
(CANON_LEFT_HIP, 0.00, 0.50), // exact
(CANON_RIGHT_HIP, 1.00, 0.50), // exact
]);
let visible = [0usize, 5, 7, CANON_LEFT_HIP, CANON_RIGHT_HIP];
let vis = vis17(&visible);
let threshold = 0.1_f32;
let (c_can, t_can, pck_can) = pck_canonical(&pred, &gt, &vis, threshold);
// Reference over the SAME visible joints with the SAME raw threshold
// (torso == 1.0 so threshold·torso == threshold).
let pred_v: Vec<(f32, f32)> = visible.iter().map(|&j| (pred[[j, 0]], pred[[j, 1]])).collect();
let gt_v: Vec<(f32, f32)> = visible.iter().map(|&j| (gt[[j, 0]], gt[[j, 1]])).collect();
let (c_ref, t_ref, pck_ref) = reference_pck_raw(&pred_v, &gt_v, threshold);
assert_eq!(t_can, t_ref, "visible counts must match: {t_can} vs {t_ref}");
assert_eq!(c_can, c_ref, "correct counts must match: {c_can} vs {c_ref}");
assert!(
(pck_can - pck_ref).abs() < 1e-6,
"canonical PCK {pck_can} must agree with independent reference {pck_ref}"
);
}
@@ -309,6 +309,61 @@ impl WlanApiScanner {
})
}
/// Measure the **real** achieved rate of a *specific* backend over a
/// fixed wall-clock `window`, for an honest native-vs-netsh comparison.
///
/// Unlike [`benchmark`](Self::benchmark) (which picks native-first and so
/// never exercises netsh on a box where native works), this runs back-to-
/// back scans on **exactly** the requested backend until `window` elapses,
/// then reports the measured scans/second and mean BSSIDs/scan. This is the
/// ADR-157 §5 #4 measurement primitive: drive it once per backend over the
/// same window and compare the two `rate_hz` values — no rate is assumed.
///
/// Returns `None` for [`ScanBackend::Native`] when the native path is
/// unavailable (non-Windows or WLAN service error), so a caller can report
/// the honest negative rather than a fabricated number.
///
/// # Errors
///
/// Propagates the first scan error from the chosen backend.
pub fn benchmark_backend(
&self,
backend: ScanBackend,
window: Duration,
) -> Result<Option<BenchmarkResult>, WifiScanError> {
// Probe native availability first so an unavailable native path is an
// honest `None`, not an error charged against the comparison.
if backend == ScanBackend::Native && wlanapi_native::scan_native().is_err() {
return Ok(None);
}
let start = Instant::now();
let mut iterations: u32 = 0;
let mut total_bssids: u64 = 0;
while start.elapsed() < window {
let list = match backend {
ScanBackend::Native => wlanapi_native::scan_native()?,
ScanBackend::Netsh => self.inner.scan_sync()?,
};
total_bssids += list.len() as u64;
iterations += 1;
}
let total = start.elapsed();
let secs = total.as_secs_f64().max(f64::MIN_POSITIVE);
Ok(Some(BenchmarkResult {
iterations,
total,
rate_hz: f64::from(iterations) / secs,
mean_bssids: if iterations == 0 {
0.0
} else {
total_bssids as f64 / f64::from(iterations)
},
backend,
}))
}
/// Perform an async scan by offloading the blocking call to a
/// background thread (native-first, netsh fallback inside the task).
///
@@ -560,4 +615,76 @@ mod tests {
);
assert!(bench.rate_hz > 0.0);
}
/// ADR-157 §5 #4 honest native-vs-netsh throughput comparison. `#[ignore]`
/// (live WLAN, ~20 s). Run with:
/// `cargo test -p wifi-densepose-wifiscan -- --ignored --nocapture
/// measure_native_vs_netsh_throughput`. Drives BOTH backends over the same
/// fixed wall-clock window and prints the measured Hz + BSSIDs/scan for
/// each, plus the ratio — the real number, whatever it is (a null/negative
/// result is a valid outcome and must be reported, not hidden).
#[cfg(windows)]
#[test]
#[ignore = "live WLAN native-vs-netsh comparison; run with --ignored --nocapture"]
fn measure_native_vs_netsh_throughput() {
let scanner = WlanApiScanner::new();
let window = Duration::from_secs(10);
let native = scanner
.benchmark_backend(ScanBackend::Native, window)
.expect("native benchmark must not error");
let netsh = scanner
.benchmark_backend(ScanBackend::Netsh, window)
.expect("netsh benchmark must not error")
.expect("netsh is always available on Windows");
match native {
Some(n) => {
println!(
"NATIVE: {:.2} Hz ({} scans / {:?}), mean {:.1} BSSIDs/scan",
n.rate_hz, n.iterations, n.total, n.mean_bssids
);
println!(
"NETSH: {:.2} Hz ({} scans / {:?}), mean {:.1} BSSIDs/scan",
netsh.rate_hz, netsh.iterations, netsh.total, netsh.mean_bssids
);
let ratio = n.rate_hz / netsh.rate_hz.max(f64::MIN_POSITIVE);
println!("RATIO native/netsh: {ratio:.2}x");
assert!(n.rate_hz > 0.0 && netsh.rate_hz > 0.0);
}
None => {
println!(
"NATIVE: unavailable on this box (WLAN service error). \
NETSH: {:.2} Hz, mean {:.1} BSSIDs/scan",
netsh.rate_hz, netsh.mean_bssids
);
}
}
}
/// Determinism + handle-cleanup pin: N back-to-back native scans must all
/// succeed (or all be the same typed error) with no resource exhaustion —
/// a `WlanOpenHandle`/`WlanCloseHandle` leak would, after enough calls,
/// surface as a `ScanFailed`. Running 50 iterations here exercises the
/// open→enum→getlist→free→close cycle repeatedly. `#[ignore]` for CI (live
/// WLAN service) but RUN on this box to verify no leak.
#[cfg(windows)]
#[test]
#[ignore = "live WLAN handle-cleanup check; run with --ignored --nocapture"]
fn native_scans_dont_leak_handles() {
let scanner = WlanApiScanner::new();
let mut ok = 0u32;
let mut failed = 0u32;
for _ in 0..50 {
match scanner.scan_native() {
Ok(_) => ok += 1,
Err(WifiScanError::ScanFailed { .. }) => failed += 1,
Err(e) => panic!("unexpected error during leak check: {e:?}"),
}
}
println!("native leak check: {ok} ok, {failed} scan-failed of 50");
// No leak ⇒ behavior is consistent across all 50 calls (all ok, or all
// the same WLAN-service-off failure) — not a degrade partway through.
assert!(ok == 50 || failed == 50, "inconsistent results suggest a leak: {ok} ok / {failed} failed");
}
}