diff --git a/python/Cargo.lock b/python/Cargo.lock index 85511a2c..762945a8 100644 --- a/python/Cargo.lock +++ b/python/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "aho-corasick" version = "1.1.4" @@ -108,6 +114,15 @@ dependencies = [ "num-traits", ] +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "arrayref" version = "0.3.9" @@ -445,6 +460,19 @@ dependencies = [ "memchr", ] +[[package]] +name = "console" +version = "0.15.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "unicode-width", + "windows-sys 0.59.0", +] + [[package]] name = "const-oid" version = "0.9.6" @@ -516,6 +544,15 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossbeam" version = "0.8.4" @@ -582,6 +619,27 @@ dependencies = [ "typenum", ] +[[package]] +name = "csv" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" +dependencies = [ + "csv-core", + "itoa", + "ryu", + "serde_core", +] + +[[package]] +name = "csv-core" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" +dependencies = [ + "memchr", +] + [[package]] name = "curve25519-dalek" version = "4.1.3" @@ -670,12 +728,33 @@ dependencies = [ "zeroize", ] +[[package]] +name = "der" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69dedd701da44b0536442edf09c81a64b0ab97a7a4a5e3d1971f00027cbc63d" +dependencies = [ + "pem-rfc7468", + "zeroize", +] + [[package]] name = "deranged" version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "digest" version = "0.10.7" @@ -728,6 +807,12 @@ version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + [[package]] name = "enum-as-inner" version = "0.6.1" @@ -815,6 +900,16 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "miniz_oxide", + "zlib-rs", +] + [[package]] name = "foldhash" version = "0.1.5" @@ -1034,6 +1129,12 @@ dependencies = [ "digest", ] +[[package]] +name = "hmac-sha256" +version = "1.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec9d92d097f4749b64e8cc33d924d9f40a2d4eb91402b458014b781f5733d60f" + [[package]] name = "hnsw_rs" version = "0.3.4" @@ -1331,6 +1432,19 @@ dependencies = [ "serde_core", ] +[[package]] +name = "indicatif" +version = "0.17.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "183b3088984b400f4cfac3620d5e076c84da5364016b4f49473de574b2586235" +dependencies = [ + "console", + "number_prefix", + "portable-atomic", + "unicode-width", + "web-time", +] + [[package]] name = "indoc" version = "2.0.7" @@ -1546,6 +1660,12 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "lzma-rust2" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e20f57f9918e5bd7bc58c22cdd70a6afc7375d4dd9683af5f2b34bd3d2bba619" + [[package]] name = "mach2" version = "0.4.3" @@ -1679,6 +1799,16 @@ dependencies = [ "unicase", ] +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.2.2" @@ -1803,6 +1933,20 @@ dependencies = [ "serde", ] +[[package]] +name = "ndarray-npy" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58e8a348bca0075000d999d750420d74434fd0d3e0993b456554f885e7657a11" +dependencies = [ + "byteorder", + "ndarray 0.17.2", + "num-complex", + "num-traits", + "py_literal", + "zip", +] + [[package]] name = "nix" version = "0.30.1" @@ -1889,6 +2033,12 @@ dependencies = [ "libc", ] +[[package]] +name = "number_prefix" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" + [[package]] name = "numpy" version = "0.22.1" @@ -1968,6 +2118,30 @@ dependencies = [ "num-traits", ] +[[package]] +name = "ort" +version = "2.0.0-rc.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7de3af33d24a745ffb8fab904b13478438d1cd52868e6f17735ef6e1f8bf133" +dependencies = [ + "ndarray 0.17.2", + "ort-sys", + "smallvec", + "tracing", + "ureq 3.3.0", +] + +[[package]] +name = "ort-sys" +version = "2.0.0-rc.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7b497d21a8b6fbb4b5a544f8fadb77e801a09ae0add9e411d31c6f89e3c1e90" +dependencies = [ + "hmac-sha256", + "lzma-rust2", + "ureq 3.3.0", +] + [[package]] name = "parking_lot" version = "0.12.5" @@ -2007,12 +2181,63 @@ dependencies = [ "serde_core", ] +[[package]] +name = "pem-rfc7468" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pest" +version = "2.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47627dd7305c6a2d6c8c6bcd24c5a4c17dbbf425f4f9c5313e724b38fc9782e9" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b4254325ecad416ab689e27ba51da03ba01a9632bc6e108f5fe7c3c4ad29d58" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c4c0e91ead7a8f7acecbca6f003fc2e8282b1dbe2dd9c9d2f16aba42995e0a7" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "pest_meta" +version = "2.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9744bc48116fee06334924bb5f2bad41eed5e89bd26e29b0b799f9a3f82c210" +dependencies = [ + "pest", +] + [[package]] name = "petgraph" version = "0.6.5" @@ -2035,7 +2260,7 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" dependencies = [ - "der", + "der 0.7.10", "spki", ] @@ -2132,6 +2357,19 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "py_literal" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "102df7a3d46db9d3891f178dcc826dc270a6746277a9ae6436f8d29fd490a8e1" +dependencies = [ + "num-bigint", + "num-complex", + "num-traits", + "pest", + "pest_derive", +] + [[package]] name = "pyo3" version = "0.22.6" @@ -3063,6 +3301,12 @@ dependencies = [ "wide", ] +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + [[package]] name = "simd_cesu8" version = "1.2.0" @@ -3116,6 +3360,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "socks" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0c3dbbd9ae980613c6dd8e28a9407b50509d3803b57624d5dfe8315218cd58b" +dependencies = [ + "byteorder", + "libc", + "winapi", +] + [[package]] name = "spki" version = "0.7.3" @@ -3123,7 +3378,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" dependencies = [ "base64ct", - "der", + "der 0.7.10", ] [[package]] @@ -3608,6 +3863,12 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + [[package]] name = "unicase" version = "2.9.0" @@ -3620,6 +3881,12 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "unicode-xid" version = "0.2.6" @@ -3661,6 +3928,36 @@ dependencies = [ "webpki-roots 0.26.11", ] +[[package]] +name = "ureq" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" +dependencies = [ + "base64", + "der 0.8.1", + "log", + "native-tls", + "percent-encoding", + "rustls-pki-types", + "socks", + "ureq-proto", + "utf8-zero", + "webpki-root-certs", +] + +[[package]] +name = "ureq-proto" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" +dependencies = [ + "base64", + "http", + "httparse", + "log", +] + [[package]] name = "url" version = "2.5.8" @@ -3679,6 +3976,12 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -3991,6 +4294,23 @@ dependencies = [ "tracing", ] +[[package]] +name = "wifi-densepose-nn" +version = "0.3.1" +dependencies = [ + "anyhow", + "memmap2", + "ndarray 0.17.2", + "num-traits", + "ort", + "parking_lot", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tracing", +] + [[package]] name = "wifi-densepose-py" version = "2.0.0-alpha.1" @@ -4002,6 +4322,8 @@ dependencies = [ "wifi-densepose-bfld", "wifi-densepose-core", "wifi-densepose-sensing-server", + "wifi-densepose-signal", + "wifi-densepose-train", "wifi-densepose-vitals", ] @@ -4050,7 +4372,7 @@ dependencies = [ "tower-http", "tracing", "tracing-subscriber", - "ureq", + "ureq 2.12.1", "wifi-densepose-bfld", "wifi-densepose-engine", "wifi-densepose-geo", @@ -4085,6 +4407,39 @@ dependencies = [ "wifi-densepose-ruvector", ] +[[package]] +name = "wifi-densepose-train" +version = "0.3.2" +dependencies = [ + "anyhow", + "chrono", + "clap", + "csv", + "indicatif", + "memmap2", + "ndarray 0.17.2", + "ndarray-npy", + "num-complex", + "num-traits", + "petgraph", + "ruvector-attention", + "ruvector-attn-mincut", + "ruvector-mincut", + "ruvector-solver", + "ruvector-temporal-tensor", + "serde", + "serde_json", + "sha2", + "thiserror 2.0.18", + "tokio", + "toml", + "tracing", + "tracing-subscriber", + "walkdir", + "wifi-densepose-nn", + "wifi-densepose-signal", +] + [[package]] name = "wifi-densepose-vitals" version = "0.3.1" @@ -4647,8 +5002,40 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "zip" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2a05c7c36fde6c09b08576c9f7fb4cda705990f73b58fe011abf7dfb24168b" +dependencies = [ + "arbitrary", + "crc32fast", + "flate2", + "indexmap", + "memchr", + "zopfli", +] + +[[package]] +name = "zlib-rs" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b142a20ec14a91d5bc708c1dc21b080c550113d8aa77afa29635673a65dd02c5" + [[package]] name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] diff --git a/python/Cargo.toml b/python/Cargo.toml index de69defc..cb726f65 100644 --- a/python/Cargo.toml +++ b/python/Cargo.toml @@ -31,6 +31,11 @@ default = [] # `wifi-densepose-sensing-server` crate (see the honest wheel-size note # on that dep below). aether = ["dep:wifi-densepose-sensing-server"] +# ADR-185 P2 — MERIDIAN domain generalization. Binds the tch-free +# inference/adaptation path only (see the wheel-size note on the deps +# below). `wifi-densepose-train` is depended on WITHOUT `tch-backend`, +# so no libtorch is linked. +meridian = ["dep:wifi-densepose-train", "dep:wifi-densepose-signal"] [dependencies] # PyO3 with abi3-py310 — one compiled binary covers Python 3.10, 3.11, @@ -75,6 +80,23 @@ numpy = "0.22" # hoist is the required pre-release follow-up. wifi-densepose-sensing-server = { version = "0.3.0", path = "../v2/crates/wifi-densepose-sensing-server", optional = true, default-features = false } +# ADR-185 P2 — MERIDIAN backing crates (optional, `meridian`-gated). +# +# HONEST WHEEL-SIZE NOTE (ADR-185 §9 / §1.2): unlike AETHER, the libtorch +# risk is AVOIDED here — `wifi-densepose-train`'s `tch` dep is properly +# optional (feature `tch-backend`, OFF by default), so no libtorch links. +# BUT `wifi-densepose-train` still carries NON-optional deps: `tokio` (rt +# subset), the five `ruvector-*` crates, `wifi-densepose-nn`, petgraph, +# memmap2, indicatif, ndarray-npy, csv, toml, clap. So a `[meridian]` +# wheel is heavier than the ≤5 MB ADR-117 §5.4 budget (though far 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-free leaf crate. +# `wifi-densepose-signal` is depended on `default-features = false` to +# drop the optional ndarray-linalg/BLAS chain (Windows-friendly). +wifi-densepose-train = { version = "0.3.0", path = "../v2/crates/wifi-densepose-train", optional = true, default-features = false } +wifi-densepose-signal = { version = "0.3.0", path = "../v2/crates/wifi-densepose-signal", optional = true, default-features = false } + [dev-dependencies] # ADR-185 §4.1 parity harness — SHA-256 the native-Rust reference # embedding and read the committed golden fixture. diff --git a/python/pyproject.toml b/python/pyproject.toml index a4d09486..4e7705eb 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -54,6 +54,9 @@ client = [ # feature axis, ADR-185 §3.1). Installing the base wheel and importing # `wifi_densepose.aether` raises a clear ImportError naming this extra. aether = [] +# ADR-185 P2 — MERIDIAN domain generalization. Same compiled-feature +# marker pattern as `aether` (built via `maturin ... --features meridian`). +meridian = [] # Developer dependencies for running the test suite + lint. dev = [ "pytest>=8.0", diff --git a/python/src/bindings/meridian.rs b/python/src/bindings/meridian.rs new file mode 100644 index 00000000..b2a5d093 --- /dev/null +++ b/python/src/bindings/meridian.rs @@ -0,0 +1,492 @@ +//! ADR-185 P2 — PyO3 bindings for MERIDIAN cross-environment domain +//! generalization (ADR-027). +//! +//! Surfaces the **pure-sync, tch-free** inference/adaptation path into +//! `wifi_densepose.meridian`: +//! +//! - `HardwareType` / `HardwareNormalizer` / `CanonicalCsiFrame` +//! (from `wifi-densepose-signal::hardware_norm`) +//! - `MeridianGeometryConfig` / `GeometryEncoder` +//! - `RapidAdaptation` / `AdaptationResult` +//! - `CrossDomainEvaluator` + `mpjpe` +//! (from `wifi-densepose-train`, NO `tch-backend`) +//! +//! ## Honest scope vs ADR-185 §3.3 +//! +//! ADR-185 §3.3 names a surface that partly diverges from the code at HEAD; +//! this binding tracks the **real** API and documents each deviation: +//! +//! - `HardwareType.detect(subcarrier_count)` — the real detector is the +//! static `HardwareNormalizer::detect_hardware`; exposed here as a +//! `HardwareType.detect` staticmethod delegating to it (no reimpl). +//! - `HardwareNormalizer.normalize(frame: CsiFrame, hw)` — the real method +//! takes raw `(amplitude, phase)` f64 vectors and returns a `Result`, so +//! it is bound as `normalize(amplitude, phase, hw)` (raises on error). +//! - `CanonicalCsiFrame.amplitudes/.phases` — the real fields are singular +//! `amplitude`/`phase`; bound under their real names. +//! - `RapidAdaptation.calibrate(csi_windows) -> AdaptationResult` with a +//! `converged` field — **does not exist**. The real engine is +//! `push_frame` + `adapt()`, and `AdaptationResult` carries +//! `{lora_weights, final_loss, frames_used, adaptation_epochs}` (no +//! `converged`). Bound as-is; the `calibrate`/`converged` surface is a +//! Rust-side gap, not fabricated here. +//! +//! Training-time types (`DomainFactorizer`, `GradientReversalLayer`, +//! `VirtualDomainAugmentor`) are out of P6 scope (ADR-185 §3.3 / Open Q +//! §11.2) — inference/adaptation only. +//! +//! ## GIL release (per ADR-117 §7, matching bindings/vitals.rs) +//! +//! `normalize`, `encode`, `adapt`, and `evaluate` are pure-sync numeric +//! ops touching no Python objects, so they run inside `py.allow_threads`. + +use std::collections::HashMap; + +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +use wifi_densepose_signal::hardware_norm::{ + CanonicalCsiFrame, HardwareNormalizer, HardwareType, +}; +use wifi_densepose_train::eval::{mpjpe as rust_mpjpe, CrossDomainEvaluator}; +use wifi_densepose_train::geometry::{GeometryEncoder, MeridianGeometryConfig}; +use wifi_densepose_train::rapid_adapt::{AdaptationLoss, AdaptationResult, RapidAdaptation}; + +// ─── HardwareType ──────────────────────────────────────────────────── + +/// WiFi chipset family, keyed by subcarrier count. +#[pyclass(eq, eq_int, frozen, hash, name = "HardwareType")] +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +pub enum PyHardwareType { + Esp32S3 = 0, + Intel5300 = 1, + Atheros = 2, + Generic = 3, +} + +impl PyHardwareType { + fn as_rust(self) -> HardwareType { + match self { + Self::Esp32S3 => HardwareType::Esp32S3, + Self::Intel5300 => HardwareType::Intel5300, + Self::Atheros => HardwareType::Atheros, + Self::Generic => HardwareType::Generic, + } + } + fn from_rust(hw: HardwareType) -> Self { + match hw { + HardwareType::Esp32S3 => Self::Esp32S3, + HardwareType::Intel5300 => Self::Intel5300, + HardwareType::Atheros => Self::Atheros, + HardwareType::Generic => Self::Generic, + } + } +} + +#[pymethods] +impl PyHardwareType { + /// Detect hardware from subcarrier count (64→Esp32S3, 30→Intel5300, + /// 56→Atheros, else Generic). Delegates to the real + /// `HardwareNormalizer::detect_hardware`. + #[staticmethod] + fn detect(subcarrier_count: usize) -> Self { + Self::from_rust(HardwareNormalizer::detect_hardware(subcarrier_count)) + } + + #[getter] + fn subcarrier_count(&self) -> usize { + self.as_rust().subcarrier_count() + } + + #[getter] + fn mimo_streams(&self) -> usize { + self.as_rust().mimo_streams() + } + + fn __repr__(&self) -> String { + format!("HardwareType.{:?}", self.as_rust()) + } +} + +// ─── CanonicalCsiFrame ─────────────────────────────────────────────── + +/// A CSI frame canonicalized to the normalizer's subcarrier grid +/// (default 56): z-scored amplitude + sanitized (unwrapped, detrended) +/// phase. +#[pyclass(frozen, name = "CanonicalCsiFrame")] +pub struct PyCanonicalCsiFrame { + inner: CanonicalCsiFrame, +} + +#[pymethods] +impl PyCanonicalCsiFrame { + #[getter] + fn amplitude(&self) -> Vec { + self.inner.amplitude.clone() + } + + #[getter] + fn phase(&self) -> Vec { + self.inner.phase.clone() + } + + #[getter] + fn hardware_type(&self) -> PyHardwareType { + PyHardwareType::from_rust(self.inner.hardware_type) + } + + fn __repr__(&self) -> String { + format!( + "CanonicalCsiFrame(subcarriers={}, hardware_type={:?})", + self.inner.amplitude.len(), + self.inner.hardware_type, + ) + } +} + +// ─── HardwareNormalizer ────────────────────────────────────────────── + +/// Normalizes CSI frames from heterogeneous chipsets into a canonical +/// representation (cubic resample → z-score amplitude → sanitize phase). +#[pyclass(name = "HardwareNormalizer")] +pub struct PyHardwareNormalizer { + inner: HardwareNormalizer, +} + +#[pymethods] +impl PyHardwareNormalizer { + /// Create a normalizer. `canonical_subcarriers` defaults to 56. + #[new] + #[pyo3(signature = (canonical_subcarriers=56))] + fn new(canonical_subcarriers: usize) -> PyResult { + HardwareNormalizer::with_canonical_subcarriers(canonical_subcarriers) + .map(|inner| Self { inner }) + .map_err(|e| PyValueError::new_err(e.to_string())) + } + + /// Detect hardware from subcarrier count (static). + #[staticmethod] + fn detect_hardware(subcarrier_count: usize) -> PyHardwareType { + PyHardwareType::from_rust(HardwareNormalizer::detect_hardware(subcarrier_count)) + } + + #[getter] + fn canonical_subcarriers(&self) -> usize { + self.inner.canonical_subcarriers() + } + + /// Normalize a raw CSI frame given per-subcarrier `amplitude` and + /// `phase` (equal length) and its `hardware` type. Raises + /// `ValueError` on empty/mismatched input. GIL released. + fn normalize( + &self, + py: Python<'_>, + amplitude: Vec, + phase: Vec, + hardware: PyHardwareType, + ) -> PyResult { + let hw = hardware.as_rust(); + py.allow_threads(|| self.inner.normalize(&litude, &phase, hw)) + .map(|inner| PyCanonicalCsiFrame { inner }) + .map_err(|e| PyValueError::new_err(e.to_string())) + } + + fn __repr__(&self) -> String { + format!( + "HardwareNormalizer(canonical_subcarriers={})", + self.inner.canonical_subcarriers() + ) + } +} + +// ─── MeridianGeometryConfig ────────────────────────────────────────── + +/// Config for the geometry encoder (Fourier bands + DeepSets output dim). +#[pyclass(frozen, name = "MeridianGeometryConfig")] +#[derive(Clone)] +pub struct PyMeridianGeometryConfig { + inner: MeridianGeometryConfig, +} + +#[pymethods] +impl PyMeridianGeometryConfig { + #[new] + #[pyo3(signature = (n_frequencies=10, scale=1.0, geometry_dim=64, seed=42))] + fn new(n_frequencies: usize, scale: f32, geometry_dim: usize, seed: u64) -> Self { + Self { + inner: MeridianGeometryConfig { + n_frequencies, + scale, + geometry_dim, + seed, + }, + } + } + + #[getter] + fn n_frequencies(&self) -> usize { + self.inner.n_frequencies + } + #[getter] + fn scale(&self) -> f32 { + self.inner.scale + } + #[getter] + fn geometry_dim(&self) -> usize { + self.inner.geometry_dim + } + #[getter] + fn seed(&self) -> u64 { + self.inner.seed + } + + fn __repr__(&self) -> String { + format!( + "MeridianGeometryConfig(n_frequencies={}, scale={}, geometry_dim={}, seed={})", + self.inner.n_frequencies, self.inner.scale, self.inner.geometry_dim, self.inner.seed, + ) + } +} + +// ─── GeometryEncoder ───────────────────────────────────────────────── + +/// Permutation-invariant encoder: variable-count AP positions `[x,y,z]` +/// → a fixed `geometry_dim` (default 64) vector. +#[pyclass(name = "GeometryEncoder")] +pub struct PyGeometryEncoder { + inner: GeometryEncoder, + geometry_dim: usize, +} + +#[pymethods] +impl PyGeometryEncoder { + #[new] + #[pyo3(signature = (config=None))] + fn new(config: Option) -> Self { + let cfg = config.map(|c| c.inner).unwrap_or_default(); + let geometry_dim = cfg.geometry_dim; + Self { + inner: GeometryEncoder::new(&cfg), + geometry_dim, + } + } + + /// Encode AP positions (a non-empty list of `[x, y, z]`) into a + /// `geometry_dim`-length vector. Raises `ValueError` if the list is + /// empty or any position is not exactly 3 coordinates. GIL released. + fn encode(&self, py: Python<'_>, ap_positions: Vec>) -> PyResult> { + if ap_positions.is_empty() { + return Err(PyValueError::new_err( + "ap_positions must contain at least one [x, y, z] position", + )); + } + let mut coords: Vec<[f32; 3]> = Vec::with_capacity(ap_positions.len()); + for (i, p) in ap_positions.iter().enumerate() { + if p.len() != 3 { + return Err(PyValueError::new_err(format!( + "ap_positions[{i}] must have exactly 3 coordinates, got {}", + p.len() + ))); + } + coords.push([p[0], p[1], p[2]]); + } + Ok(py.allow_threads(|| self.inner.encode(&coords))) + } + + #[getter] + fn geometry_dim(&self) -> usize { + self.geometry_dim + } + + fn __repr__(&self) -> String { + format!("GeometryEncoder(geometry_dim={})", self.geometry_dim) + } +} + +// ─── RapidAdaptation / AdaptationResult ────────────────────────────── + +/// Result of `RapidAdaptation.adapt()`. +#[pyclass(frozen, name = "AdaptationResult")] +pub struct PyAdaptationResult { + inner: AdaptationResult, +} + +#[pymethods] +impl PyAdaptationResult { + #[getter] + fn lora_weights(&self) -> Vec { + self.inner.lora_weights.clone() + } + #[getter] + fn final_loss(&self) -> f32 { + self.inner.final_loss + } + #[getter] + fn frames_used(&self) -> usize { + self.inner.frames_used + } + #[getter] + fn adaptation_epochs(&self) -> usize { + self.inner.adaptation_epochs + } + + fn __repr__(&self) -> String { + format!( + "AdaptationResult(final_loss={:.6}, frames_used={}, adaptation_epochs={})", + self.inner.final_loss, self.inner.frames_used, self.inner.adaptation_epochs, + ) + } +} + +/// Few-shot test-time adaptation: accumulate unlabeled CSI frames, then +/// `adapt()` to produce LoRA weight deltas that minimize a self-supervised +/// proxy loss. +/// +/// Scope caveat (from the Rust module, kept honest): this minimizes a +/// self-supervised proxy over a tiny LoRA bottleneck; it is NOT wired to +/// the pose model and there is no measured end-to-end PCK gain from this +/// path — do not cite a PCK improvement from `adapt()`. +#[pyclass(name = "RapidAdaptation")] +pub struct PyRapidAdaptation { + inner: RapidAdaptation, +} + +#[pymethods] +impl PyRapidAdaptation { + /// Build an adaptation engine. `loss_kind` is one of + /// `"contrastive"`, `"entropy"`, `"combined"` (default). `lambda_ent` + /// is used only by `"combined"`. + #[new] + #[pyo3(signature = ( + min_calibration_frames, + lora_rank, + loss_kind="combined", + epochs=5, + lr=0.001, + lambda_ent=0.5 + ))] + fn new( + min_calibration_frames: usize, + lora_rank: usize, + loss_kind: &str, + epochs: usize, + lr: f32, + lambda_ent: f32, + ) -> PyResult { + let loss = match loss_kind { + "contrastive" => AdaptationLoss::ContrastiveTTT { epochs, lr }, + "entropy" => AdaptationLoss::EntropyMin { epochs, lr }, + "combined" => AdaptationLoss::Combined { + epochs, + lr, + lambda_ent, + }, + other => { + return Err(PyValueError::new_err(format!( + "unknown loss_kind '{other}'; expected 'contrastive', 'entropy', or 'combined'" + ))) + } + }; + Ok(Self { + inner: RapidAdaptation::new(min_calibration_frames, lora_rank, loss), + }) + } + + /// Push a single unlabeled CSI frame into the calibration buffer. + fn push_frame(&mut self, frame: Vec) { + self.inner.push_frame(&frame); + } + + /// True once at least `min_calibration_frames` have been buffered. + fn is_ready(&self) -> bool { + self.inner.is_ready() + } + + #[getter] + fn buffer_len(&self) -> usize { + self.inner.buffer_len() + } + + /// Run test-time adaptation over the buffered frames. Raises + /// `ValueError` if the buffer is empty or `lora_rank == 0`. GIL + /// released during the finite-difference optimization. + fn adapt(&self, py: Python<'_>) -> PyResult { + py.allow_threads(|| self.inner.adapt()) + .map(|inner| PyAdaptationResult { inner }) + .map_err(|e| PyValueError::new_err(e.to_string())) + } + + fn __repr__(&self) -> String { + format!("RapidAdaptation(buffered={})", self.inner.buffer_len()) + } +} + +// ─── CrossDomainEvaluator ──────────────────────────────────────────── + +/// Cross-domain pose-accuracy evaluator (MPJPE + domain-gap ratio). +#[pyclass(name = "CrossDomainEvaluator")] +pub struct PyCrossDomainEvaluator { + inner: CrossDomainEvaluator, +} + +#[pymethods] +impl PyCrossDomainEvaluator { + /// Create an evaluator for `n_joints` (e.g. 17 for COCO). + #[new] + fn new(n_joints: usize) -> Self { + Self { + inner: CrossDomainEvaluator::new(n_joints), + } + } + + /// Evaluate `predictions` (a list of `(pred, gt)` flat `n_joints*3` + /// vectors) grouped by `domain_labels` (0 = in-domain). Returns a + /// dict of the six cross-domain metrics. Raises `ValueError` on a + /// length mismatch. GIL released. + fn evaluate( + &self, + py: Python<'_>, + predictions: Vec<(Vec, Vec)>, + domain_labels: Vec, + ) -> PyResult> { + if predictions.len() != domain_labels.len() { + return Err(PyValueError::new_err(format!( + "predictions ({}) and domain_labels ({}) must have equal length", + predictions.len(), + domain_labels.len() + ))); + } + let m = py.allow_threads(|| self.inner.evaluate(&predictions, &domain_labels)); + let mut out = HashMap::with_capacity(6); + out.insert("in_domain_mpjpe".to_string(), m.in_domain_mpjpe); + out.insert("cross_domain_mpjpe".to_string(), m.cross_domain_mpjpe); + out.insert("few_shot_mpjpe".to_string(), m.few_shot_mpjpe); + out.insert("cross_hardware_mpjpe".to_string(), m.cross_hardware_mpjpe); + out.insert("domain_gap_ratio".to_string(), m.domain_gap_ratio); + out.insert("adaptation_speedup".to_string(), m.adaptation_speedup); + Ok(out) + } + + fn __repr__(&self) -> String { + "CrossDomainEvaluator()".to_string() + } +} + +/// Mean Per Joint Position Error between flat `[n_joints*3]` pose vectors. +#[pyfunction] +fn mpjpe(pred: Vec, gt: Vec, n_joints: usize) -> f32 { + rust_mpjpe(&pred, >, n_joints) +} + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_function(wrap_pyfunction!(mpjpe, m)?)?; + Ok(()) +} diff --git a/python/src/lib.rs b/python/src/lib.rs index 520007cd..53a962a8 100644 --- a/python/src/lib.rs +++ b/python/src/lib.rs @@ -20,6 +20,8 @@ mod bindings { #[cfg(feature = "aether")] pub mod aether; pub mod bfld; + #[cfg(feature = "meridian")] + pub mod meridian; pub mod keypoint; pub mod pose; pub mod privacy_gate; @@ -47,6 +49,8 @@ fn build_features() -> Vec<&'static str> { feats.push("p3.5-bfld-bindings"); // BfldFrame + BfldReport + BfldKind (stub Rust) #[cfg(feature = "aether")] feats.push("p6-aether-bindings"); // ADR-185 P1 — AETHER contrastive embeddings + #[cfg(feature = "meridian")] + feats.push("p6-meridian-bindings"); // ADR-185 P2 — MERIDIAN domain generalization feats } @@ -96,5 +100,11 @@ fn wifi_densepose_native(m: &Bound<'_, PyModule>) -> PyResult<()> { #[cfg(feature = "aether")] bindings::aether::register(m)?; + // ADR-185 P2 — MERIDIAN cross-environment domain-generalization + // bindings (hardware normalization, geometry encoding, rapid + // adaptation, cross-domain eval). Gated behind `meridian`; tch-free. + #[cfg(feature = "meridian")] + bindings::meridian::register(m)?; + Ok(()) } diff --git a/python/tests/golden/meridian_input.json b/python/tests/golden/meridian_input.json new file mode 100644 index 00000000..33f7cd78 --- /dev/null +++ b/python/tests/golden/meridian_input.json @@ -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]]} \ No newline at end of file diff --git a/python/tests/golden/meridian_output.sha256 b/python/tests/golden/meridian_output.sha256 new file mode 100644 index 00000000..b6d414ca --- /dev/null +++ b/python/tests/golden/meridian_output.sha256 @@ -0,0 +1 @@ +0486402d5a860f459a319cd779ca44a112d8543442ae9ce9eb7b1a01780aee4b \ No newline at end of file diff --git a/python/tests/meridian_parity.rs b/python/tests/meridian_parity.rs new file mode 100644 index 00000000..a5565618 --- /dev/null +++ b/python/tests/meridian_parity.rs @@ -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 { + v[key] + .as_array() + .unwrap() + .iter() + .map(|x| x.as_f64().unwrap()) + .collect() +} + +fn f32_frames(v: &Value, key: &str) -> Vec> { + 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 { + let mut out: Vec = Vec::new(); + + // 1–4: 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."); + } + } +} diff --git a/python/tests/test_meridian.py b/python/tests/test_meridian.py new file mode 100644 index 00000000..a1cd3ff9 --- /dev/null +++ b/python/tests/test_meridian.py @@ -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(" None: + src = Path(mer.__file__).read_text() + assert "pip install wifi-densepose[meridian]" in src diff --git a/python/wifi_densepose/meridian.py b/python/wifi_densepose/meridian.py new file mode 100644 index 00000000..cf197c23 --- /dev/null +++ b/python/wifi_densepose/meridian.py @@ -0,0 +1,60 @@ +"""MERIDIAN — cross-environment domain generalization (ADR-027, ADR-185 P2). + +Hardware-invariant CSI normalization, geometry-conditioned deployment, +few-shot room adaptation, and cross-domain evaluation — the tch-free +inference/adaptation path of Project MERIDIAN, computed by the Rust core. + +Available **only** when the wheel was built with the ``[meridian]`` extra:: + + pip install wifi-densepose[meridian] + +Quick start:: + + from wifi_densepose.meridian import HardwareNormalizer, HardwareType + + norm = HardwareNormalizer() # canonical 56 subcarriers + hw = HardwareType.detect(64) # -> HardwareType.Esp32S3 + frame = norm.normalize(amplitude, phase, hw) # -> CanonicalCsiFrame + print(len(frame.amplitude), frame.hardware_type) + +Note (honest scope, ADR-185 §3.3): the ADR's ``RapidAdaptation.calibrate`` +/ ``AdaptationResult.converged`` do not exist in the Rust core — use +``push_frame(...)`` then ``adapt()``; the result exposes ``final_loss``, +``frames_used``, ``adaptation_epochs``. Training-time types +(DomainFactorizer, GradientReversalLayer, VirtualDomainAugmentor) are +out of scope for P6 (they need the deferred libtorch training tier). +""" + +from __future__ import annotations + +from wifi_densepose import _native + +# MERIDIAN symbols are compiled into `_native` only under the Rust +# `meridian` feature; absent in a base wheel (ADR-185 §6 acceptance). +if not hasattr(_native, "HardwareNormalizer"): + raise ImportError( + "wifi_densepose.meridian is not available in this wheel. " + "It requires the 'meridian' extra: pip install wifi-densepose[meridian]" + ) + +HardwareType = _native.HardwareType +CanonicalCsiFrame = _native.CanonicalCsiFrame +HardwareNormalizer = _native.HardwareNormalizer +MeridianGeometryConfig = _native.MeridianGeometryConfig +GeometryEncoder = _native.GeometryEncoder +RapidAdaptation = _native.RapidAdaptation +AdaptationResult = _native.AdaptationResult +CrossDomainEvaluator = _native.CrossDomainEvaluator +mpjpe = _native.mpjpe + +__all__ = [ + "HardwareType", + "CanonicalCsiFrame", + "HardwareNormalizer", + "MeridianGeometryConfig", + "GeometryEncoder", + "RapidAdaptation", + "AdaptationResult", + "CrossDomainEvaluator", + "mpjpe", +] diff --git a/python/wifi_densepose/meridian.pyi b/python/wifi_densepose/meridian.pyi new file mode 100644 index 00000000..f3473404 --- /dev/null +++ b/python/wifi_densepose/meridian.pyi @@ -0,0 +1,105 @@ +"""Type stubs for the MERIDIAN bindings (ADR-185 P2). + +Present only when the wheel is built with the ``[meridian]`` extra. +""" + +from __future__ import annotations + +import enum + +class HardwareType(enum.Enum): + Esp32S3 = 0 + Intel5300 = 1 + Atheros = 2 + Generic = 3 + @staticmethod + def detect(subcarrier_count: int) -> HardwareType: ... + @property + def subcarrier_count(self) -> int: ... + @property + def mimo_streams(self) -> int: ... + def __repr__(self) -> str: ... + +class CanonicalCsiFrame: + @property + def amplitude(self) -> list[float]: ... + @property + def phase(self) -> list[float]: ... + @property + def hardware_type(self) -> HardwareType: ... + def __repr__(self) -> str: ... + +class HardwareNormalizer: + def __init__(self, canonical_subcarriers: int = ...) -> None: ... + @staticmethod + def detect_hardware(subcarrier_count: int) -> HardwareType: ... + @property + def canonical_subcarriers(self) -> int: ... + def normalize( + self, amplitude: list[float], phase: list[float], hardware: HardwareType + ) -> CanonicalCsiFrame: ... + def __repr__(self) -> str: ... + +class MeridianGeometryConfig: + def __init__( + self, + n_frequencies: int = ..., + scale: float = ..., + geometry_dim: int = ..., + seed: int = ..., + ) -> None: ... + @property + def n_frequencies(self) -> int: ... + @property + def scale(self) -> float: ... + @property + def geometry_dim(self) -> int: ... + @property + def seed(self) -> int: ... + def __repr__(self) -> str: ... + +class GeometryEncoder: + def __init__(self, config: MeridianGeometryConfig | None = ...) -> None: ... + def encode(self, ap_positions: list[list[float]]) -> list[float]: ... + @property + def geometry_dim(self) -> int: ... + def __repr__(self) -> str: ... + +class AdaptationResult: + @property + def lora_weights(self) -> list[float]: ... + @property + def final_loss(self) -> float: ... + @property + def frames_used(self) -> int: ... + @property + def adaptation_epochs(self) -> int: ... + def __repr__(self) -> str: ... + +class RapidAdaptation: + def __init__( + self, + min_calibration_frames: int, + lora_rank: int, + loss_kind: str = ..., + epochs: int = ..., + lr: float = ..., + lambda_ent: float = ..., + ) -> None: ... + def push_frame(self, frame: list[float]) -> None: ... + def is_ready(self) -> bool: ... + @property + def buffer_len(self) -> int: ... + def adapt(self) -> AdaptationResult: ... + def __repr__(self) -> str: ... + +class CrossDomainEvaluator: + def __init__(self, n_joints: int) -> None: ... + def evaluate( + self, + predictions: list[tuple[list[float], list[float]]], + domain_labels: list[int], + ) -> dict[str, float]: ... + def __repr__(self) -> str: ... + +def mpjpe(pred: list[float], gt: list[float], n_joints: int) -> float: ...