feat(adr-185): P2 MERIDIAN bindings (wifi_densepose.meridian) + parity harness

Bind the ADR-027 MERIDIAN cross-environment domain-generalization surface
into the wheel behind a gated [meridian] extra / Cargo `meridian` feature.
Inference/adaptation path only (tch-free), per ADR-185 section 3.3.

Surface (bound against the REAL code at HEAD, not the ADR wishlist):
- HardwareType / HardwareNormalizer / CanonicalCsiFrame (from
  wifi-densepose-signal::hardware_norm)
- MeridianGeometryConfig / GeometryEncoder (64-dim, permutation-invariant)
- RapidAdaptation / AdaptationResult (push_frame + adapt, LoRA deltas)
- CrossDomainEvaluator + mpjpe (from wifi-densepose-train, NO tch-backend)
All compute paths GIL-released (py.allow_threads).

Honest deviations from ADR section 3.3 (documented in the module header):
- ADR's RapidAdaptation.calibrate(windows) and AdaptationResult.converged
  DO NOT EXIST. Real API is push_frame + adapt(); result carries
  {lora_weights, final_loss, frames_used, adaptation_epochs}. Bound as-is.
- ADR's HardwareType.detect exposed as a staticmethod delegating to the
  real HardwareNormalizer::detect_hardware.
- ADR's normalize(frame: CsiFrame, hw) is really normalize(amplitude,
  phase, hw) over f64 vectors returning Result; bound faithfully.
- CanonicalCsiFrame fields are singular amplitude/phase (ADR said plural).
Training-time types (DomainFactorizer, GradientReversalLayer,
VirtualDomainAugmentor) are out of P6 scope (need the libtorch tier).

Parity (section 4.1, release-blocking): committed fixture
meridian_input.json -> native Rust reference (tests/meridian_parity.rs,
calls hardware_norm + geometry + rapid_adapt directly) locks
tests/golden/meridian_output.sha256 over the concatenated f32 outputs
(esp32+intel canonical frames, 64-dim geometry vector, rapid-adapt LoRA
weights); pytest (tests/test_meridian.py) runs the same fixture through
the binding and asserts the identical SHA-256. Both pass.

Verified:
  cargo test --features meridian --test meridian_parity -> 2/2 pass
  maturin develop --features meridian + pytest tests/test_meridian.py
    -> 13/13 pass
  default cargo build clean, 0 train/signal/sensing-server refs in the
    default dep graph (gate keeps the base wheel lean).

WHEEL-SIZE FINDING (ADR-185 section 9 / section 1.2): the libtorch risk
the ADR feared is AVOIDED -- wifi-densepose-train's `tch` dep is properly
optional (feature tch-backend, OFF), so no libtorch links. BUT train
still carries NON-optional deps: tokio (rt subset), the five ruvector-*
crates, and wifi-densepose-nn (which itself pulls `ort` / ONNX Runtime +
reqwest/hyper). So a [meridian] wheel exceeds the ADR-117 section 5.4
<=5 MB budget (though lighter than AETHER's axum/tokio server tree). The
clean fix is the same leaf-crate hoist: move the pure inference modules
(geometry, rapid_adapt, eval, hardware_norm) into a tch/tokio/ort-free
leaf crate. A required pre-release follow-up, not a functional blocker;
P2 binds real code and proves parity today.
This commit is contained in:
ruv
2026-07-21 16:38:17 -07:00
parent 569cd237fb
commit 189ac9dfb0
11 changed files with 1422 additions and 3 deletions
+1
View File
@@ -0,0 +1 @@
{"esp32_amplitude": [0.51171875, 0.5390625, 0.56640625, 0.59375, 0.62109375, 0.6484375, 0.67578125, 0.703125, 0.73046875, 0.7578125, 0.78515625, 0.8125, 0.83984375, 0.8671875, 0.89453125, 0.921875, 0.94921875, 0.9765625, 1.00390625, 1.03125, 1.05859375, 1.0859375, 1.11328125, 1.140625, 1.16796875, 1.1953125, 1.22265625, 1.25, 1.27734375, 1.3046875, 1.33203125, 1.359375, 1.38671875, 1.4140625, 1.44140625, 1.46875, 1.49609375, 0.5234375, 0.55078125, 0.578125, 0.60546875, 0.6328125, 0.66015625, 0.6875, 0.71484375, 0.7421875, 0.76953125, 0.796875, 0.82421875, 0.8515625, 0.87890625, 0.90625, 0.93359375, 0.9609375, 0.98828125, 1.015625, 1.04296875, 1.0703125, 1.09765625, 1.125, 1.15234375, 1.1796875, 1.20703125, 1.234375], "esp32_phase": [-0.45703125, -0.4375, -0.41796875, -0.3984375, -0.37890625, -0.359375, -0.33984375, -0.3203125, -0.30078125, -0.28125, -0.26171875, -0.2421875, -0.22265625, -0.203125, -0.18359375, -0.1640625, -0.14453125, -0.125, -0.10546875, -0.0859375, -0.06640625, -0.046875, -0.02734375, -0.0078125, 0.01171875, 0.03125, 0.05078125, 0.0703125, 0.08984375, 0.109375, 0.12890625, 0.1484375, 0.16796875, 0.1875, 0.20703125, 0.2265625, 0.24609375, 0.265625, 0.28515625, 0.3046875, 0.32421875, 0.34375, 0.36328125, 0.3828125, 0.40234375, 0.421875, 0.44140625, 0.4609375, 0.48046875, -0.5, -0.48046875, -0.4609375, -0.44140625, -0.421875, -0.40234375, -0.3828125, -0.36328125, -0.34375, -0.32421875, -0.3046875, -0.28515625, -0.265625, -0.24609375, -0.2265625], "intel_amplitude": [0.50390625, 0.546875, 0.58984375, 0.6328125, 0.67578125, 0.71875, 0.76171875, 0.8046875, 0.84765625, 0.890625, 0.93359375, 0.9765625, 1.01953125, 1.0625, 1.10546875, 1.1484375, 1.19140625, 1.234375, 1.27734375, 1.3203125, 1.36328125, 1.40625, 1.44921875, 1.4921875, 0.53515625, 0.578125, 0.62109375, 0.6640625, 0.70703125, 0.75], "intel_phase": [-0.47265625, -0.4609375, -0.44921875, -0.4375, -0.42578125, -0.4140625, -0.40234375, -0.390625, -0.37890625, -0.3671875, -0.35546875, -0.34375, -0.33203125, -0.3203125, -0.30859375, -0.296875, -0.28515625, -0.2734375, -0.26171875, -0.25, -0.23828125, -0.2265625, -0.21484375, -0.203125, -0.19140625, -0.1796875, -0.16796875, -0.15625, -0.14453125, -0.1328125], "ap_positions": [[0.25, 0.5, 0.75], [1.0, 1.25, 1.5], [2.0, 0.0, -0.5]], "rapid_frames": [[0.0, 0.01953125, 0.0390625, 0.05859375, 0.078125, 0.09765625, 0.1171875, 0.13671875, 0.15625, 0.17578125, 0.1953125, 0.21484375, 0.234375, 0.25390625, 0.2734375, 0.29296875], [0.05078125, 0.0703125, 0.08984375, 0.109375, 0.12890625, 0.1484375, 0.16796875, 0.1875, 0.20703125, 0.2265625, 0.24609375, 0.265625, 0.28515625, 0.3046875, 0.32421875, 0.34375], [0.1015625, 0.12109375, 0.140625, 0.16015625, 0.1796875, 0.19921875, 0.21875, 0.23828125, 0.2578125, 0.27734375, 0.296875, 0.31640625, 0.3359375, 0.35546875, 0.375, 0.39453125], [0.15234375, 0.171875, 0.19140625, 0.2109375, 0.23046875, 0.25, 0.26953125, 0.2890625, 0.30859375, 0.328125, 0.34765625, 0.3671875, 0.38671875, 0.40625, 0.42578125, 0.4453125], [0.203125, 0.22265625, 0.2421875, 0.26171875, 0.28125, 0.30078125, 0.3203125, 0.33984375, 0.359375, 0.37890625, 0.3984375, 0.41796875, 0.4375, 0.45703125, 0.4765625, 0.49609375], [0.25390625, 0.2734375, 0.29296875, 0.3125, 0.33203125, 0.3515625, 0.37109375, 0.390625, 0.41015625, 0.4296875, 0.44921875, 0.46875, 0.48828125, 0.5078125, 0.52734375, 0.546875], [0.3046875, 0.32421875, 0.34375, 0.36328125, 0.3828125, 0.40234375, 0.421875, 0.44140625, 0.4609375, 0.48046875, 0.5, 0.51953125, 0.5390625, 0.55859375, 0.578125, 0.59765625], [0.35546875, 0.375, 0.39453125, 0.4140625, 0.43359375, 0.453125, 0.47265625, 0.4921875, 0.51171875, 0.53125, 0.55078125, 0.5703125, 0.58984375, 0.609375, 0.62890625, 0.6484375], [0.40625, 0.42578125, 0.4453125, 0.46484375, 0.484375, 0.50390625, 0.5234375, 0.54296875, 0.5625, 0.58203125, 0.6015625, 0.62109375, 0.640625, 0.66015625, 0.6796875, 0.69921875], [0.45703125, 0.4765625, 0.49609375, 0.515625, 0.53515625, 0.5546875, 0.57421875, 0.59375, 0.61328125, 0.6328125, 0.65234375, 0.671875, 0.69140625, 0.7109375, 0.73046875, 0.75], [0.5078125, 0.52734375, 0.546875, 0.56640625, 0.5859375, 0.60546875, 0.625, 0.64453125, 0.6640625, 0.68359375, 0.703125, 0.72265625, 0.7421875, 0.76171875, 0.78125, 0.80078125], [0.55859375, 0.578125, 0.59765625, 0.6171875, 0.63671875, 0.65625, 0.67578125, 0.6953125, 0.71484375, 0.734375, 0.75390625, 0.7734375, 0.79296875, 0.8125, 0.83203125, 0.8515625]]}
@@ -0,0 +1 @@
0486402d5a860f459a319cd779ca44a112d8543442ae9ce9eb7b1a01780aee4b
+178
View File
@@ -0,0 +1,178 @@
//! ADR-185 §4.1 — MERIDIAN bit-for-bit parity: native-Rust reference half.
//!
//! Calls the canonical `wifi-densepose-signal::hardware_norm` +
//! `wifi-densepose-train::{geometry,rapid_adapt}` code DIRECTLY (no PyO3)
//! on the committed `tests/golden/meridian_input.json` fixture and locks
//! the SHA-256 of the concatenated f32 outputs into
//! `tests/golden/meridian_output.sha256`.
//!
//! Concatenation order (identical in the pytest half, tests/test_meridian.py):
//! 1. esp32 canonical amplitude (56) 2. esp32 canonical phase (56)
//! 3. intel5300 canonical amplitude 4. intel5300 canonical phase
//! 5. geometry.encode(ap_positions) 6. rapid_adapt lora_weights
//!
//! Regenerate (only on an intentional Rust change): delete the .sha256 and
//! re-run `cargo test --features meridian --test meridian_parity`.
#![cfg(feature = "meridian")]
use std::fs;
use std::path::PathBuf;
use serde_json::Value;
use sha2::{Digest, Sha256};
use wifi_densepose_signal::hardware_norm::{HardwareNormalizer, HardwareType};
use wifi_densepose_train::geometry::{GeometryEncoder, MeridianGeometryConfig};
use wifi_densepose_train::rapid_adapt::{AdaptationLoss, RapidAdaptation};
fn golden_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("golden")
}
fn fixture() -> Value {
let raw = fs::read_to_string(golden_dir().join("meridian_input.json"))
.expect("read meridian_input.json fixture");
serde_json::from_str(&raw).expect("parse meridian_input.json")
}
fn f64_vec(v: &Value, key: &str) -> Vec<f64> {
v[key]
.as_array()
.unwrap()
.iter()
.map(|x| x.as_f64().unwrap())
.collect()
}
fn f32_frames(v: &Value, key: &str) -> Vec<Vec<f32>> {
v[key]
.as_array()
.unwrap()
.iter()
.map(|row| {
row.as_array()
.unwrap()
.iter()
.map(|x| x.as_f64().unwrap() as f32)
.collect()
})
.collect()
}
/// Compute the full concatenated MERIDIAN output vector, mirroring the
/// Python binding's default construction exactly.
fn meridian_output(fx: &Value) -> Vec<f32> {
let mut out: Vec<f32> = Vec::new();
// 14: hardware normalization (default normalizer, canonical 56).
let norm = HardwareNormalizer::new();
let esp = norm
.normalize(
&f64_vec(fx, "esp32_amplitude"),
&f64_vec(fx, "esp32_phase"),
HardwareType::Esp32S3,
)
.unwrap();
out.extend_from_slice(&esp.amplitude);
out.extend_from_slice(&esp.phase);
let intel = norm
.normalize(
&f64_vec(fx, "intel_amplitude"),
&f64_vec(fx, "intel_phase"),
HardwareType::Intel5300,
)
.unwrap();
out.extend_from_slice(&intel.amplitude);
out.extend_from_slice(&intel.phase);
// 5: geometry encoding (default config → 64-dim).
let enc = GeometryEncoder::new(&MeridianGeometryConfig::default());
let aps: Vec<[f32; 3]> = fx["ap_positions"]
.as_array()
.unwrap()
.iter()
.map(|p| {
let a = p.as_array().unwrap();
[
a[0].as_f64().unwrap() as f32,
a[1].as_f64().unwrap() as f32,
a[2].as_f64().unwrap() as f32,
]
})
.collect();
out.extend_from_slice(&enc.encode(&aps));
// 6: rapid adaptation lora weights (Combined, epochs 5, lr 1e-3, λ 0.5).
let mut ra = RapidAdaptation::new(
10,
4,
AdaptationLoss::Combined {
epochs: 5,
lr: 0.001,
lambda_ent: 0.5,
},
);
for frame in f32_frames(fx, "rapid_frames") {
ra.push_frame(&frame);
}
out.extend_from_slice(&ra.adapt().unwrap().lora_weights);
out
}
fn sha256_le(vals: &[f32]) -> String {
let mut hasher = Sha256::new();
for &x in vals {
hasher.update(x.to_le_bytes());
}
hasher
.finalize()
.iter()
.map(|b| format!("{b:02x}"))
.collect()
}
#[test]
fn native_canonical_frames_are_56_wide() {
let fx = fixture();
let norm = HardwareNormalizer::new();
let esp = norm
.normalize(
&f64_vec(&fx, "esp32_amplitude"),
&f64_vec(&fx, "esp32_phase"),
HardwareType::Esp32S3,
)
.unwrap();
assert_eq!(esp.amplitude.len(), 56);
assert_eq!(esp.phase.len(), 56);
let intel = norm
.normalize(
&f64_vec(&fx, "intel_amplitude"),
&f64_vec(&fx, "intel_phase"),
HardwareType::Intel5300,
)
.unwrap();
assert_eq!(intel.amplitude.len(), 56);
// 64-dim geometry vector.
let enc = GeometryEncoder::new(&MeridianGeometryConfig::default());
assert_eq!(enc.encode(&[[0.25, 0.5, 0.75]]).len(), 64);
}
#[test]
fn native_meridian_matches_committed_golden() {
let got = sha256_le(&meridian_output(&fixture()));
let path = golden_dir().join("meridian_output.sha256");
match fs::read_to_string(&path) {
Ok(expected) => assert_eq!(
got,
expected.trim(),
"native MERIDIAN hash drifted from committed golden \
(intentional? delete the .sha256 and regenerate)"
),
Err(_) => {
fs::write(&path, &got).expect("write golden sha256");
panic!("no committed golden found; wrote {got}. Re-run to verify parity.");
}
}
}
+160
View File
@@ -0,0 +1,160 @@
"""ADR-185 P2 — MERIDIAN binding tests, incl. the §4.1 bit-for-bit parity gate.
The parity test packs the binding's concatenated outputs (2× canonical
frame, geometry vector, rapid-adapt LoRA weights) to little-endian f32
bytes and asserts SHA-256 equality with the golden produced by the
native-Rust reference (`tests/meridian_parity.rs`). A mismatch is a
release blocker.
"""
from __future__ import annotations
import hashlib
import json
import struct
from pathlib import Path
import pytest
from wifi_densepose import meridian as mer
GOLDEN = Path(__file__).parent / "golden"
def fixture() -> dict:
return json.loads((GOLDEN / "meridian_input.json").read_text())
# ─── HardwareType / HardwareNormalizer / CanonicalCsiFrame ───────────
def test_hardware_type_detect() -> None:
assert mer.HardwareType.detect(64) == mer.HardwareType.Esp32S3
assert mer.HardwareType.detect(30) == mer.HardwareType.Intel5300
assert mer.HardwareType.detect(56) == mer.HardwareType.Atheros
assert mer.HardwareType.detect(128) == mer.HardwareType.Generic
def test_hardware_type_properties() -> None:
assert mer.HardwareType.Esp32S3.subcarrier_count == 64
assert mer.HardwareType.Esp32S3.mimo_streams == 1
assert mer.HardwareType.Intel5300.mimo_streams == 3
def test_normalize_shapes_and_hardware() -> None:
fx = fixture()
norm = mer.HardwareNormalizer()
assert norm.canonical_subcarriers == 56
frame = norm.normalize(fx["esp32_amplitude"], fx["esp32_phase"], mer.HardwareType.Esp32S3)
assert len(frame.amplitude) == 56
assert len(frame.phase) == 56
assert frame.hardware_type == mer.HardwareType.Esp32S3
def test_normalize_rejects_mismatched_lengths() -> None:
norm = mer.HardwareNormalizer()
with pytest.raises(ValueError):
norm.normalize([1.0, 2.0], [1.0], mer.HardwareType.Generic)
# ─── GeometryEncoder ─────────────────────────────────────────────────
def test_geometry_encode_dim_and_permutation_invariance() -> None:
enc = mer.GeometryEncoder(mer.MeridianGeometryConfig())
aps = [[0.25, 0.5, 0.75], [1.0, 1.25, 1.5], [2.0, 0.0, -0.5]]
v = enc.encode(aps)
assert len(v) == 64
# DeepSets mean-pool is permutation-invariant.
v_perm = enc.encode([aps[2], aps[0], aps[1]])
assert max(abs(a - b) for a, b in zip(v, v_perm)) < 1e-5
def test_geometry_encode_rejects_empty_and_bad_shape() -> None:
enc = mer.GeometryEncoder()
with pytest.raises(ValueError):
enc.encode([])
with pytest.raises(ValueError):
enc.encode([[0.0, 1.0]]) # not 3 coords
# ─── RapidAdaptation ─────────────────────────────────────────────────
def test_rapid_adaptation_adapt() -> None:
fx = fixture()
ra = mer.RapidAdaptation(
min_calibration_frames=10, lora_rank=4, loss_kind="combined",
epochs=5, lr=0.001, lambda_ent=0.5,
)
for frame in fx["rapid_frames"]:
ra.push_frame(frame)
assert ra.is_ready()
assert ra.buffer_len == 12
res = ra.adapt()
assert res.frames_used == 12
assert res.adaptation_epochs == 5
assert len(res.lora_weights) == 2 * 16 * 4 # 2 * fdim * rank
def test_rapid_adaptation_rejects_bad_loss_kind() -> None:
with pytest.raises(ValueError):
mer.RapidAdaptation(10, 4, loss_kind="nonsense")
def test_rapid_adaptation_empty_buffer_raises() -> None:
ra = mer.RapidAdaptation(1, 4)
with pytest.raises(ValueError):
ra.adapt()
# ─── CrossDomainEvaluator ────────────────────────────────────────────
def test_cross_domain_evaluator_gap_ratio() -> None:
ev = mer.CrossDomainEvaluator(1)
preds = [
([0.0, 0.0, 0.0], [1.0, 0.0, 0.0]), # domain 0, err 1
([0.0, 0.0, 0.0], [2.0, 0.0, 0.0]), # domain 1, err 2
]
m = ev.evaluate(preds, [0, 1])
assert abs(m["in_domain_mpjpe"] - 1.0) < 1e-6
assert abs(m["cross_domain_mpjpe"] - 2.0) < 1e-6
assert abs(m["domain_gap_ratio"] - 2.0) < 1e-6
def test_mpjpe_module_fn() -> None:
assert abs(mer.mpjpe([0.0, 0.0, 0.0], [3.0, 4.0, 0.0], 1) - 5.0) < 1e-6
# ─── §4.1 bit-for-bit parity gate (release-blocking) ─────────────────
def test_bit_for_bit_parity_with_native_rust() -> None:
fx = fixture()
out: list[float] = []
norm = mer.HardwareNormalizer()
esp = norm.normalize(fx["esp32_amplitude"], fx["esp32_phase"], mer.HardwareType.Esp32S3)
out += list(esp.amplitude) + list(esp.phase)
intel = norm.normalize(fx["intel_amplitude"], fx["intel_phase"], mer.HardwareType.Intel5300)
out += list(intel.amplitude) + list(intel.phase)
enc = mer.GeometryEncoder(mer.MeridianGeometryConfig())
out += list(enc.encode(fx["ap_positions"]))
ra = mer.RapidAdaptation(
min_calibration_frames=10, lora_rank=4, loss_kind="combined",
epochs=5, lr=0.001, lambda_ent=0.5,
)
for frame in fx["rapid_frames"]:
ra.push_frame(frame)
out += list(ra.adapt().lora_weights)
packed = b"".join(struct.pack("<f", x) for x in out)
got = hashlib.sha256(packed).hexdigest()
expected = (GOLDEN / "meridian_output.sha256").read_text().strip()
assert got == expected, (
f"Python binding MERIDIAN output diverged from native-Rust golden "
f"({got} != {expected})"
)
def test_base_wheel_import_error_message() -> None:
src = Path(mer.__file__).read_text()
assert "pip install wifi-densepose[meridian]" in src