feat(adr-185): P3 MAT bindings (wifi_densepose.mat) + parity harness

Bind the ADR-024 MAT (Mass Casualty Assessment Tool) disaster-survivor
detection + START triage surface into the wheel behind a gated [mat]
extra / Cargo `mat` feature, mirroring the upstream disaster/ML gating.
Also adds the [sota] superset extra (aether+meridian+mat).

Surface (bound against the REAL code at HEAD, not the ADR wishlist):
- DisasterType (9 variants) / TriageStatus (5, START) enums
- DisasterConfig (builder-backed, continuous_monitoring forced off)
- DisasterResponse: initialize_event / add_zone / push_csi_data /
  scan_once / survivors / survivors_by_triage
- Survivor (id, triage_status, confidence, location, latest_vitals)
- VitalSignsReading (breathing/heartbeat rate, movement, confidence)
- ScanZone.rectangle / ScanZone.circle
push_csi_data + scan_once are GIL-released.

Honest deviations from ADR section 3.4 (documented in module header):
- ADR proposed adding a Rust-side sync scan_once() (section 11.3). That was
  UNNECESSARY: the public async start_scanning() runs exactly one
  scan_cycle and returns when continuous_monitoring == false. The binding
  forces that flag off and drives one cycle on a private current-thread
  tokio runtime -- NO change to wifi-densepose-mat.
- scan_cycle requires an active event + Active zone, which the ADR surface
  omitted; initialize_event + add_zone are bound as required additions.
- Survivor.vital_signs is a *history* in the real code; bound as
  Survivor.latest_vitals -> Optional[VitalSignsReading].
- DisasterType has 9 variants at HEAD (adds Landslide/MineCollapse/
  Industrial/TunnelCollapse); all bound.

Parity (section 4.1, release-blocking): committed fixture mat_input.json
(synthetic breathing-modulated CSI stream) -> native Rust reference
(tests/mat_parity.rs, drives DisasterResponse directly) locks
tests/golden/mat_result.sha256 over a canonical
`count=<K>;triage_priorities=<sorted>` string (survivor UUIDs/timestamps
excluded as non-deterministic); pytest (tests/test_mat.py) runs the same
stream through the binding and asserts the identical hash. Both detect
exactly 1 survivor, triage Delayed. Honest: synthetic fixture proves
binding==native path equality, NOT live detection accuracy.

Verified:
  cargo test --features mat --test mat_parity -> 2/2 pass
  maturin develop --features mat + pytest tests/test_mat.py -> 7/7 pass
  default cargo build clean, 0 mat/tokio refs in the default dep graph.

WHEEL-SIZE FINDING (ADR-185 section 9): default-features=false drops MAT's
`api` (axum) and `ruvector` features, but MAT still carries NON-optional
tokio (rt/sync/time), wifi-densepose-nn (ort/ONNX + reqwest/hyper),
rustfft, geo, ndarray. So a [mat] wheel exceeds the ADR-117 section 5.4
<=5 MB budget -- same leaf-crate-hoist follow-up as AETHER/MERIDIAN. The
default wheel is untouched (feature-gated).
This commit is contained in:
ruv
2026-07-21 16:49:49 -07:00
parent 189ac9dfb0
commit 1c9727f9cf
11 changed files with 1043 additions and 0 deletions
+187
View File
@@ -146,6 +146,15 @@ dependencies = [
"syn 3.0.2",
]
[[package]]
name = "atomic-polyfill"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4"
dependencies = [
"critical-section",
]
[[package]]
name = "atomic-waker"
version = "1.1.2"
@@ -553,6 +562,12 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "critical-section"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b"
[[package]]
name = "crossbeam"
version = "0.8.4"
@@ -777,6 +792,16 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "earcutr"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "79127ed59a85d7687c409e9978547cffb7dc79675355ed22da6b66fd5f6ead01"
dependencies = [
"itertools",
"num-traits",
]
[[package]]
name = "ed25519"
version = "2.2.3"
@@ -910,6 +935,12 @@ dependencies = [
"zlib-rs",
]
[[package]]
name = "float_next_after"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8bf7cc16383c4b8d58b9905a8509f02926ce3058053c056376248d958c9df1e8"
[[package]]
name = "foldhash"
version = "0.1.5"
@@ -1044,6 +1075,44 @@ dependencies = [
"version_check",
]
[[package]]
name = "geo"
version = "0.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4841b40fdbccd4b7042bd6195e4de91da54af34c50632e371bcbfcdfb558b873"
dependencies = [
"earcutr",
"float_next_after",
"geo-types",
"geographiclib-rs",
"log",
"num-traits",
"robust",
"rstar",
"spade",
]
[[package]]
name = "geo-types"
version = "0.7.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94776032c45f950d30a13af6113c2ad5625316c9abfbccee4dd5a6695f8fe0f5"
dependencies = [
"approx",
"num-traits",
"rstar",
"serde",
]
[[package]]
name = "geographiclib-rs"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c5a7f08910fd98737a6eda7568e7c5e645093e073328eeef49758cfe8b0489c7"
dependencies = [
"libm",
]
[[package]]
name = "getrandom"
version = "0.2.17"
@@ -1085,6 +1154,15 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "hash32"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67"
dependencies = [
"byteorder",
]
[[package]]
name = "hashbrown"
version = "0.14.5"
@@ -1102,12 +1180,36 @@ dependencies = [
"foldhash 0.1.5",
]
[[package]]
name = "hashbrown"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash 0.2.0",
]
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]]
name = "heapless"
version = "0.7.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f"
dependencies = [
"atomic-polyfill",
"hash32",
"rustc_version",
"spin",
"stable_deref_trait",
]
[[package]]
name = "heck"
version = "0.5.0"
@@ -1466,6 +1568,15 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "itertools"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57"
dependencies = [
"either",
]
[[package]]
name = "itoa"
version = "1.0.18"
@@ -1901,6 +2012,19 @@ dependencies = [
"tempfile",
]
[[package]]
name = "ndarray"
version = "0.15.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "adb12d4e967ec485a5f71c6311fe28158e9d6f4bc4a447b474184d0f91a8fa32"
dependencies = [
"matrixmultiply",
"num-complex",
"num-integer",
"num-traits",
"rawpointer",
]
[[package]]
name = "ndarray"
version = "0.16.1"
@@ -2807,6 +2931,23 @@ dependencies = [
"byteorder",
]
[[package]]
name = "robust"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e27ee8bb91ca0adcf0ecb116293afa12d393f9c2b9b9cd54d33e8078fe19839"
[[package]]
name = "rstar"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73111312eb7a2287d229f06c00ff35b51ddee180f017ab6dec1f69d62ac098d6"
dependencies = [
"heapless",
"num-traits",
"smallvec",
]
[[package]]
name = "rufield-core"
version = "0.1.0"
@@ -3371,6 +3512,27 @@ dependencies = [
"winapi",
]
[[package]]
name = "spade"
version = "2.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9699399fd9349b00b184f5635b074f9ec93afffef30c853f8c875b32c0f8c7fa"
dependencies = [
"hashbrown 0.16.1",
"num-traits",
"robust",
"smallvec",
]
[[package]]
name = "spin"
version = "0.9.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e"
dependencies = [
"lock_api",
]
[[package]]
name = "spki"
version = "0.7.3"
@@ -4294,6 +4456,28 @@ dependencies = [
"tracing",
]
[[package]]
name = "wifi-densepose-mat"
version = "0.3.1"
dependencies = [
"anyhow",
"async-trait",
"chrono",
"geo",
"ndarray 0.15.6",
"num-complex",
"parking_lot",
"rustfft",
"serde_json",
"thiserror 2.0.18",
"tokio",
"tracing",
"uuid",
"wifi-densepose-core",
"wifi-densepose-nn",
"wifi-densepose-signal",
]
[[package]]
name = "wifi-densepose-nn"
version = "0.3.1"
@@ -4315,12 +4499,15 @@ dependencies = [
name = "wifi-densepose-py"
version = "2.0.0-alpha.1"
dependencies = [
"geo",
"numpy",
"pyo3",
"serde_json",
"sha2",
"tokio",
"wifi-densepose-bfld",
"wifi-densepose-core",
"wifi-densepose-mat",
"wifi-densepose-sensing-server",
"wifi-densepose-signal",
"wifi-densepose-train",
+22
View File
@@ -36,6 +36,13 @@ aether = ["dep:wifi-densepose-sensing-server"]
# below). `wifi-densepose-train` is depended on WITHOUT `tch-backend`,
# so no libtorch is linked.
meridian = ["dep:wifi-densepose-train", "dep:wifi-densepose-signal"]
# ADR-185 P3 — MAT disaster-survivor detection. Mirrors the upstream
# disaster/ML gating: bound only under this extra so the default wheel
# never carries the detection stack. `tokio`/`geo` are pulled to drive a
# single-shot scan (see the wheel-size note on the deps below).
mat = ["dep:wifi-densepose-mat", "dep:tokio", "dep:geo"]
# ADR-185 §3 — convenience superset: all three SOTA subsystems.
sota = ["aether", "meridian", "mat"]
[dependencies]
# PyO3 with abi3-py310 — one compiled binary covers Python 3.10, 3.11,
@@ -97,6 +104,21 @@ wifi-densepose-sensing-server = { version = "0.3.0", path = "../v2/crates/wifi-d
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 }
# ADR-185 P3 — MAT backing crate + the tokio/geo needed to drive one scan.
#
# HONEST WHEEL-SIZE NOTE (ADR-185 §9 / §1.3): `default-features = false`
# drops MAT's `api` (axum) and `ruvector` features from the wheel, but MAT
# still carries NON-optional `tokio` (rt/sync/time), `wifi-densepose-nn`
# (which pulls `ort` / ONNX Runtime + reqwest/hyper), `rustfft`, `geo`,
# and `ndarray`. So a `[mat]` wheel exceeds the ADR-117 §5.4 ≤5 MB budget
# — same leaf-crate-hoist story as AETHER/MERIDIAN, gated the same way so
# the DEFAULT wheel is untouched. `tokio` (rt+time) and `geo` are depended
# on directly (version-matched to MAT) to build the single-shot scan
# runtime and construct the event `geo::Point` in the binding.
wifi-densepose-mat = { version = "0.3.0", path = "../v2/crates/wifi-densepose-mat", optional = true, default-features = false, features = ["std"] }
tokio = { version = "1.35", features = ["rt", "time"], optional = true }
geo = { version = "0.27", optional = true }
[dev-dependencies]
# ADR-185 §4.1 parity harness — SHA-256 the native-Rust reference
# embedding and read the committed golden fixture.
+5
View File
@@ -57,6 +57,11 @@ aether = []
# ADR-185 P2 — MERIDIAN domain generalization. Same compiled-feature
# marker pattern as `aether` (built via `maturin ... --features meridian`).
meridian = []
# ADR-185 P3 — MAT disaster-survivor detection. Same compiled-feature
# marker (built via `maturin ... --features mat`).
mat = []
# ADR-185 §3 convenience — all three SOTA subsystems at once.
sota = []
# Developer dependencies for running the test suite + lint.
dev = [
"pytest>=8.0",
+433
View File
@@ -0,0 +1,433 @@
//! ADR-185 P3 — PyO3 bindings for MAT (Mass Casualty Assessment Tool, ADR-024
//! crate table): WiFi-based disaster-survivor detection + START triage.
//!
//! Bound behind the `[mat]` extra so the disaster/ML stack never enters the
//! default wheel.
//!
//! ## Honest scope vs ADR-185 §3.4
//!
//! - **`scan_once()`** — ADR-185 §3.4/§11.3 proposed adding a sync
//! `scan_once()` wrapper Rust-side. That turned out to be unnecessary: the
//! public async `DisasterResponse::start_scanning()` runs **exactly one**
//! `scan_cycle` and returns when `continuous_monitoring == false`. So this
//! binding forces `continuous_monitoring = false` and drives one scan on a
//! private current-thread tokio runtime — no change to `wifi-densepose-mat`.
//! - **event + zone are required** — `scan_cycle` errors without an active
//! event and an Active zone. ADR-185 §3.4's surface omitted this; the real
//! pipeline needs `initialize_event(...)` + `add_zone(...)` first, so both
//! are bound (documented additions, not fabrications).
//! - **`Survivor.vital_signs`** — the ADR implies a single `VitalSignsReading`;
//! the real accessor returns a *history*. Bound here as
//! `Survivor.latest_vitals -> Optional[VitalSignsReading]`.
//! - **`DisasterType`** has 9 variants at HEAD (adds Landslide, MineCollapse,
//! Industrial, TunnelCollapse) vs the ADR's shorter list; all are bound.
//!
//! ## GIL release
//!
//! `push_csi_data` and `scan_once` release the GIL (`py.allow_threads`) — the
//! detection pipeline + ensemble classifier are the compute-heavy part and
//! touch no Python state.
use pyo3::exceptions::PyValueError;
use pyo3::prelude::*;
use wifi_densepose_mat::{
DisasterConfig, DisasterResponse, DisasterType, ScanZone, Survivor, TriageStatus,
VitalSignsReading, ZoneBounds,
};
// ─── DisasterType ────────────────────────────────────────────────────
/// Type of disaster event (shapes the debris/attenuation model).
#[pyclass(eq, eq_int, frozen, hash, name = "DisasterType")]
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
pub enum PyDisasterType {
BuildingCollapse = 0,
Earthquake = 1,
Landslide = 2,
Avalanche = 3,
Flood = 4,
MineCollapse = 5,
Industrial = 6,
TunnelCollapse = 7,
Unknown = 8,
}
impl PyDisasterType {
fn as_rust(self) -> DisasterType {
match self {
Self::BuildingCollapse => DisasterType::BuildingCollapse,
Self::Earthquake => DisasterType::Earthquake,
Self::Landslide => DisasterType::Landslide,
Self::Avalanche => DisasterType::Avalanche,
Self::Flood => DisasterType::Flood,
Self::MineCollapse => DisasterType::MineCollapse,
Self::Industrial => DisasterType::Industrial,
Self::TunnelCollapse => DisasterType::TunnelCollapse,
Self::Unknown => DisasterType::Unknown,
}
}
}
#[pymethods]
impl PyDisasterType {
fn __repr__(&self) -> String {
format!("DisasterType.{:?}", self.as_rust())
}
}
// ─── TriageStatus ────────────────────────────────────────────────────
/// START-protocol triage class.
#[pyclass(eq, eq_int, frozen, hash, name = "TriageStatus")]
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
pub enum PyTriageStatus {
Immediate = 0,
Delayed = 1,
Minor = 2,
Deceased = 3,
Unknown = 4,
}
impl PyTriageStatus {
fn as_rust(self) -> TriageStatus {
match self {
Self::Immediate => TriageStatus::Immediate,
Self::Delayed => TriageStatus::Delayed,
Self::Minor => TriageStatus::Minor,
Self::Deceased => TriageStatus::Deceased,
Self::Unknown => TriageStatus::Unknown,
}
}
fn from_rust(s: &TriageStatus) -> Self {
match s {
TriageStatus::Immediate => Self::Immediate,
TriageStatus::Delayed => Self::Delayed,
TriageStatus::Minor => Self::Minor,
TriageStatus::Deceased => Self::Deceased,
TriageStatus::Unknown => Self::Unknown,
}
}
}
#[pymethods]
impl PyTriageStatus {
/// START priority (1 = highest / Immediate ... 5 = Unknown).
#[getter]
fn priority(&self) -> u8 {
self.as_rust().priority()
}
fn __repr__(&self) -> String {
format!("TriageStatus.{:?}", self.as_rust())
}
}
// ─── VitalSignsReading ───────────────────────────────────────────────
/// A single vital-signs reading (optional breathing/heartbeat + movement).
#[pyclass(frozen, name = "VitalSignsReading")]
pub struct PyVitalSignsReading {
breathing_rate_bpm: Option<f32>,
heartbeat_rate_bpm: Option<f32>,
movement_intensity: f32,
confidence: f64,
}
impl PyVitalSignsReading {
fn from_rust(r: &VitalSignsReading) -> Self {
Self {
breathing_rate_bpm: r.breathing.as_ref().map(|b| b.rate_bpm),
heartbeat_rate_bpm: r.heartbeat.as_ref().map(|h| h.rate_bpm),
movement_intensity: r.movement.intensity,
confidence: r.confidence.value(),
}
}
}
#[pymethods]
impl PyVitalSignsReading {
#[getter]
fn breathing_rate_bpm(&self) -> Option<f32> {
self.breathing_rate_bpm
}
#[getter]
fn heartbeat_rate_bpm(&self) -> Option<f32> {
self.heartbeat_rate_bpm
}
#[getter]
fn movement_intensity(&self) -> f32 {
self.movement_intensity
}
#[getter]
fn confidence(&self) -> f64 {
self.confidence
}
fn __repr__(&self) -> String {
format!(
"VitalSignsReading(breathing={:?}, heartbeat={:?}, movement={:.3}, confidence={:.3})",
self.breathing_rate_bpm, self.heartbeat_rate_bpm, self.movement_intensity, self.confidence,
)
}
}
// ─── Survivor ────────────────────────────────────────────────────────
/// A detected survivor: id, triage class, confidence, optional 3-D location,
/// and the latest vital-signs reading.
#[pyclass(frozen, name = "Survivor")]
pub struct PySurvivor {
id: String,
triage_status: PyTriageStatus,
confidence: f64,
location: Option<(f64, f64, f64)>,
latest_vitals: Option<Py<PyVitalSignsReading>>,
}
impl PySurvivor {
fn from_rust(py: Python<'_>, s: &Survivor) -> PyResult<Self> {
let latest_vitals = match s.vital_signs().latest() {
Some(r) => Some(Py::new(py, PyVitalSignsReading::from_rust(r))?),
None => None,
};
Ok(Self {
id: s.id().as_uuid().to_string(),
triage_status: PyTriageStatus::from_rust(s.triage_status()),
confidence: s.confidence(),
location: s.location().map(|c| (c.x, c.y, c.z)),
latest_vitals,
})
}
}
#[pymethods]
impl PySurvivor {
#[getter]
fn id(&self) -> &str {
&self.id
}
#[getter]
fn triage_status(&self) -> PyTriageStatus {
self.triage_status
}
#[getter]
fn confidence(&self) -> f64 {
self.confidence
}
#[getter]
fn location(&self) -> Option<(f64, f64, f64)> {
self.location
}
#[getter]
fn latest_vitals(&self, py: Python<'_>) -> Option<Py<PyVitalSignsReading>> {
self.latest_vitals.as_ref().map(|v| v.clone_ref(py))
}
fn __repr__(&self) -> String {
format!(
"Survivor(id={}, triage={:?}, confidence={:.3})",
&self.id[..8.min(self.id.len())],
self.triage_status.as_rust(),
self.confidence,
)
}
}
// ─── DisasterConfig ──────────────────────────────────────────────────
/// Configuration for the disaster-response pipeline.
///
/// Note: the Python binding always runs **single-shot** scans (`scan_once`),
/// so `continuous_monitoring` is forced off internally.
#[pyclass(frozen, name = "DisasterConfig")]
#[derive(Clone)]
pub struct PyDisasterConfig {
inner: DisasterConfig,
}
#[pymethods]
impl PyDisasterConfig {
#[new]
#[pyo3(signature = (
disaster_type,
sensitivity=0.8,
confidence_threshold=0.5,
max_depth=5.0,
scan_interval_ms=500
))]
fn new(
disaster_type: PyDisasterType,
sensitivity: f64,
confidence_threshold: f64,
max_depth: f64,
scan_interval_ms: u64,
) -> Self {
let inner = DisasterConfig::builder()
.disaster_type(disaster_type.as_rust())
.sensitivity(sensitivity)
.confidence_threshold(confidence_threshold)
.max_depth(max_depth)
.scan_interval_ms(scan_interval_ms)
.continuous_monitoring(false)
.build();
Self { inner }
}
#[getter]
fn sensitivity(&self) -> f64 {
self.inner.sensitivity
}
#[getter]
fn confidence_threshold(&self) -> f64 {
self.inner.confidence_threshold
}
#[getter]
fn max_depth(&self) -> f64 {
self.inner.max_depth
}
fn __repr__(&self) -> String {
format!(
"DisasterConfig(disaster_type={:?}, sensitivity={}, confidence_threshold={}, max_depth={})",
self.inner.disaster_type,
self.inner.sensitivity,
self.inner.confidence_threshold,
self.inner.max_depth,
)
}
}
// ─── ScanZone ────────────────────────────────────────────────────────
/// A rectangular or circular scan zone (new zones start Active).
#[pyclass(name = "ScanZone")]
#[derive(Clone)]
pub struct PyScanZone {
inner: ScanZone,
}
#[pymethods]
impl PyScanZone {
/// Rectangular zone with corner bounds (metres).
#[staticmethod]
fn rectangle(name: &str, min_x: f64, min_y: f64, max_x: f64, max_y: f64) -> Self {
Self {
inner: ScanZone::new(name, ZoneBounds::rectangle(min_x, min_y, max_x, max_y)),
}
}
/// Circular zone centred at `(center_x, center_y)` with `radius` (metres).
#[staticmethod]
fn circle(name: &str, center_x: f64, center_y: f64, radius: f64) -> Self {
Self {
inner: ScanZone::new(name, ZoneBounds::circle(center_x, center_y, radius)),
}
}
#[getter]
fn name(&self) -> &str {
self.inner.name()
}
fn __repr__(&self) -> String {
format!("ScanZone(name={:?})", self.inner.name())
}
}
// ─── DisasterResponse ────────────────────────────────────────────────
/// Main disaster-response coordinator: ingest CSI, run one scan cycle, query
/// detected survivors by START triage.
#[pyclass(name = "DisasterResponse")]
pub struct PyDisasterResponse {
inner: DisasterResponse,
rt: tokio::runtime::Runtime,
}
#[pymethods]
impl PyDisasterResponse {
#[new]
fn new(config: PyDisasterConfig) -> PyResult<Self> {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_time()
.build()
.map_err(|e| PyValueError::new_err(format!("failed to build tokio runtime: {e}")))?;
Ok(Self {
inner: DisasterResponse::new(config.inner),
rt,
})
}
/// Initialize the active disaster event at map coordinate `(x, y)`.
/// Required before `add_zone`/`scan_once`.
fn initialize_event(&mut self, x: f64, y: f64, description: &str) -> PyResult<()> {
self.inner
.initialize_event(geo::Point::new(x, y), description)
.map(|_| ())
.map_err(|e| PyValueError::new_err(e.to_string()))
}
/// Add an (Active) scan zone to the current event. Raises if no event.
fn add_zone(&mut self, zone: PyScanZone) -> PyResult<()> {
self.inner
.add_zone(zone.inner)
.map_err(|e| PyValueError::new_err(e.to_string()))
}
/// Push a raw CSI frame (equal-length `amplitudes`/`phases`) into the
/// detection pipeline. Raises on empty/mismatched input. GIL released.
fn push_csi_data(
&self,
py: Python<'_>,
amplitudes: Vec<f64>,
phases: Vec<f64>,
) -> PyResult<()> {
py.allow_threads(|| self.inner.push_csi_data(&amplitudes, &phases))
.map_err(|e| PyValueError::new_err(e.to_string()))
}
/// Run exactly one scan cycle over the buffered CSI (detection → ensemble
/// → localization → triage). Requires an initialized event with an Active
/// zone. GIL released during the scan.
fn scan_once(&mut self, py: Python<'_>) -> PyResult<()> {
let rt = &self.rt;
let inner = &mut self.inner;
py.allow_threads(|| rt.block_on(inner.start_scanning()))
.map_err(|e| PyValueError::new_err(e.to_string()))
}
/// All detected survivors.
fn survivors(&self, py: Python<'_>) -> PyResult<Vec<PySurvivor>> {
self.inner
.survivors()
.into_iter()
.map(|s| PySurvivor::from_rust(py, s))
.collect()
}
/// Survivors filtered by START triage class.
fn survivors_by_triage(
&self,
py: Python<'_>,
status: PyTriageStatus,
) -> PyResult<Vec<PySurvivor>> {
self.inner
.survivors_by_triage(status.as_rust())
.into_iter()
.map(|s| PySurvivor::from_rust(py, s))
.collect()
}
fn __repr__(&self) -> String {
"DisasterResponse()".to_string()
}
}
pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::<PyDisasterType>()?;
m.add_class::<PyTriageStatus>()?;
m.add_class::<PyVitalSignsReading>()?;
m.add_class::<PySurvivor>()?;
m.add_class::<PyDisasterConfig>()?;
m.add_class::<PyScanZone>()?;
m.add_class::<PyDisasterResponse>()?;
Ok(())
}
+9
View File
@@ -20,6 +20,8 @@ mod bindings {
#[cfg(feature = "aether")]
pub mod aether;
pub mod bfld;
#[cfg(feature = "mat")]
pub mod mat;
#[cfg(feature = "meridian")]
pub mod meridian;
pub mod keypoint;
@@ -51,6 +53,8 @@ fn build_features() -> Vec<&'static str> {
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
#[cfg(feature = "mat")]
feats.push("p6-mat-bindings"); // ADR-185 P3 — MAT disaster survivor detection
feats
}
@@ -106,5 +110,10 @@ fn wifi_densepose_native(m: &Bound<'_, PyModule>) -> PyResult<()> {
#[cfg(feature = "meridian")]
bindings::meridian::register(m)?;
// ADR-185 P3 — MAT disaster-survivor detection + START triage. Gated
// behind `mat`, mirroring the upstream disaster/ML stack gating.
#[cfg(feature = "mat")]
bindings::mat::register(m)?;
Ok(())
}
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
ae46351e28c01161c3c20f1a3134d8e32fbbef0cda2fdb9c3a27984da0ce026d
+126
View File
@@ -0,0 +1,126 @@
//! ADR-185 §4.1 — MAT bit-for-bit parity: native-Rust reference half.
//!
//! Drives the canonical `wifi-densepose-mat` `DisasterResponse` pipeline
//! DIRECTLY (no PyO3) over the committed `tests/golden/mat_input.json` CSI
//! stream and locks the SHA-256 of a canonical result string
//! (`count=<K>;triage_priorities=<sorted>`) into
//! `tests/golden/mat_result.sha256`.
//!
//! Only the survivor **count** and **triage classes** are hashed — survivor
//! UUIDs and event timestamps are non-deterministic and deliberately
//! excluded, so the hash captures exactly the "identical triage +
//! survivor count for a fixed CSI stream" invariant of ADR-185 §4.1.
//!
//! Honesty note: the fixture is a synthetic breathing-modulated stream, so
//! this proves the Python binding drives the real pipeline byte-identically
//! to native Rust — it is NOT a detection-accuracy claim on real rubble.
//!
//! Regenerate (only on an intentional Rust change): delete the .sha256 and
//! re-run `cargo test --features mat --test mat_parity`.
#![cfg(feature = "mat")]
use std::fs;
use std::path::PathBuf;
use serde_json::Value;
use sha2::{Digest, Sha256};
use wifi_densepose_mat::{DisasterConfig, DisasterResponse, DisasterType, ScanZone, ZoneBounds};
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("mat_input.json"))
.expect("read mat_input.json fixture");
serde_json::from_str(&raw).expect("parse mat_input.json")
}
/// Build the response identically to the Python binding's default
/// construction and run one scan over the fixture CSI stream. Returns the
/// canonical `count=<K>;triage_priorities=<sorted>` string.
fn mat_canonical_result(fx: &Value) -> String {
let config = DisasterConfig::builder()
.disaster_type(DisasterType::Earthquake)
.sensitivity(0.9)
.confidence_threshold(0.1)
.max_depth(5.0)
.continuous_monitoring(false)
.build();
let mut resp = DisasterResponse::new(config);
resp.initialize_event(geo::Point::new(0.0, 0.0), "parity-fixture")
.expect("initialize_event");
resp.add_zone(ScanZone::new(
"Zone A",
ZoneBounds::rectangle(0.0, 0.0, 50.0, 30.0),
))
.expect("add_zone");
for frame in fx["stream"].as_array().unwrap() {
let amp: Vec<f64> = frame["amplitude"]
.as_array()
.unwrap()
.iter()
.map(|x| x.as_f64().unwrap())
.collect();
let ph: Vec<f64> = frame["phase"]
.as_array()
.unwrap()
.iter()
.map(|x| x.as_f64().unwrap())
.collect();
resp.push_csi_data(&amp, &ph).expect("push_csi_data");
}
let rt = tokio::runtime::Builder::new_current_thread()
.enable_time()
.build()
.unwrap();
rt.block_on(resp.start_scanning()).expect("scan");
let survivors = resp.survivors();
let mut priorities: Vec<u8> = survivors
.iter()
.map(|s| s.triage_status().priority())
.collect();
priorities.sort_unstable();
format!("count={};triage_priorities={:?}", survivors.len(), priorities)
}
fn sha256_hex(s: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(s.as_bytes());
hasher
.finalize()
.iter()
.map(|b| format!("{b:02x}"))
.collect()
}
#[test]
fn native_mat_result_is_deterministic() {
let fx = fixture();
// Two independent runs must agree (survivor count + triage classes are
// deterministic; UUIDs/timestamps are excluded from the canonical form).
assert_eq!(mat_canonical_result(&fx), mat_canonical_result(&fx));
}
#[test]
fn native_mat_matches_committed_golden() {
let canon = mat_canonical_result(&fixture());
let got = sha256_hex(&canon);
let path = golden_dir().join("mat_result.sha256");
match fs::read_to_string(&path) {
Ok(expected) => assert_eq!(
got,
expected.trim(),
"native MAT result drifted from committed golden (canonical form: {canon})"
),
Err(_) => {
fs::write(&path, &got).expect("write golden sha256");
panic!("no committed golden found; wrote {got} for [{canon}]. Re-run to verify.");
}
}
}
+108
View File
@@ -0,0 +1,108 @@
"""ADR-185 P3 — MAT binding tests, incl. the §4.1 bit-for-bit parity gate.
The parity test drives the same committed CSI stream through the binding's
DisasterResponse pipeline and asserts the survivor count + triage classes
(as a SHA-256 of a canonical string) match the native-Rust golden. A
mismatch is a release blocker.
"""
from __future__ import annotations
import hashlib
import json
from pathlib import Path
import pytest
from wifi_densepose import mat
GOLDEN = Path(__file__).parent / "golden"
def fixture() -> dict:
return json.loads((GOLDEN / "mat_input.json").read_text())
def build_response() -> mat.DisasterResponse:
cfg = mat.DisasterConfig(
mat.DisasterType.Earthquake,
sensitivity=0.9,
confidence_threshold=0.1,
max_depth=5.0,
)
resp = mat.DisasterResponse(cfg)
resp.initialize_event(0.0, 0.0, "parity-fixture")
resp.add_zone(mat.ScanZone.rectangle("Zone A", 0.0, 0.0, 50.0, 30.0))
return resp
def run_scan(resp: mat.DisasterResponse) -> None:
for frame in fixture()["stream"]:
resp.push_csi_data(frame["amplitude"], frame["phase"])
resp.scan_once()
# ─── enums / config ──────────────────────────────────────────────────
def test_triage_priority_order() -> None:
assert mat.TriageStatus.Immediate.priority == 1
assert mat.TriageStatus.Delayed.priority == 2
assert mat.TriageStatus.Unknown.priority == 5
def test_disaster_config_fields() -> None:
cfg = mat.DisasterConfig(mat.DisasterType.Flood, sensitivity=1.5, confidence_threshold=0.3)
assert cfg.sensitivity == 1.0 # clamped to [0, 1]
assert abs(cfg.confidence_threshold - 0.3) < 1e-9
# ─── pipeline behaviour ──────────────────────────────────────────────
def test_scan_requires_event() -> None:
resp = mat.DisasterResponse(mat.DisasterConfig(mat.DisasterType.Unknown))
# No initialize_event / add_zone -> scan_cycle errors "No active event".
with pytest.raises(ValueError):
resp.scan_once()
def test_push_csi_rejects_mismatched_lengths() -> None:
resp = build_response()
with pytest.raises(ValueError):
resp.push_csi_data([1.0, 2.0], [1.0])
def test_scan_detects_survivor_from_breathing_stream() -> None:
resp = build_response()
run_scan(resp)
survivors = resp.survivors()
# The synthetic breathing-modulated stream trips one detection (matches
# the native-Rust reference).
assert len(survivors) == 1
s = survivors[0]
assert isinstance(s.id, str) and len(s.id) > 0
assert s.triage_status == mat.TriageStatus.Delayed
assert 0.0 <= s.confidence <= 1.0
# survivors_by_triage is consistent with the survivor's own class.
assert len(resp.survivors_by_triage(mat.TriageStatus.Delayed)) == 1
assert len(resp.survivors_by_triage(mat.TriageStatus.Immediate)) == 0
# ─── §4.1 bit-for-bit parity gate (release-blocking) ─────────────────
def test_bit_for_bit_parity_with_native_rust() -> None:
resp = build_response()
run_scan(resp)
survivors = resp.survivors()
priorities = sorted(s.triage_status.priority for s in survivors)
canon = f"count={len(survivors)};triage_priorities={priorities}"
got = hashlib.sha256(canon.encode()).hexdigest()
expected = (GOLDEN / "mat_result.sha256").read_text().strip()
assert got == expected, (
f"Python MAT result diverged from native-Rust golden "
f"(canonical form: {canon}; {got} != {expected})"
)
def test_base_wheel_import_error_message() -> None:
src = Path(mat.__file__).read_text()
assert "pip install wifi-densepose[mat]" in src
+59
View File
@@ -0,0 +1,59 @@
"""MAT — Mass Casualty Assessment Tool (ADR-024 crate, ADR-185 P3).
WiFi-based disaster-survivor detection and START-protocol triage from CSI:
ingest CSI frames, run a scan cycle, and query detected survivors by triage.
Available **only** when the wheel was built with the ``[mat]`` extra::
pip install wifi-densepose[mat]
Quick start::
from wifi_densepose.mat import DisasterConfig, DisasterResponse, DisasterType, ScanZone
cfg = DisasterConfig(DisasterType.Earthquake, sensitivity=0.9, confidence_threshold=0.1)
resp = DisasterResponse(cfg)
resp.initialize_event(0.0, 0.0, "Building A") # required before scanning
resp.add_zone(ScanZone.rectangle("North Wing", 0.0, 0.0, 50.0, 30.0))
for amp, phase in csi_stream:
resp.push_csi_data(amp, phase)
resp.scan_once() # one detection cycle
for s in resp.survivors():
print(s.id, s.triage_status, s.confidence, s.location)
Honest scope (ADR-185 §3.4): the ADR's Rust-side `scan_once()` wrapper was
unnecessary — this binding drives one cycle of the public async
`start_scanning()` (with `continuous_monitoring` forced off) on an internal
runtime. `initialize_event` + `add_zone` are required before `scan_once`.
`Survivor.latest_vitals` returns the latest reading (the Rust accessor is a
history). The detection pipeline is real but unvalidated on live rubble.
"""
from __future__ import annotations
from wifi_densepose import _native
# MAT symbols are compiled into `_native` only under the Rust `mat` feature.
if not hasattr(_native, "DisasterResponse"):
raise ImportError(
"wifi_densepose.mat is not available in this wheel. "
"It requires the 'mat' extra: pip install wifi-densepose[mat]"
)
DisasterType = _native.DisasterType
TriageStatus = _native.TriageStatus
DisasterConfig = _native.DisasterConfig
DisasterResponse = _native.DisasterResponse
ScanZone = _native.ScanZone
Survivor = _native.Survivor
VitalSignsReading = _native.VitalSignsReading
__all__ = [
"DisasterType",
"TriageStatus",
"DisasterConfig",
"DisasterResponse",
"ScanZone",
"Survivor",
"VitalSignsReading",
]
+92
View File
@@ -0,0 +1,92 @@
"""Type stubs for the MAT bindings (ADR-185 P3).
Present only when the wheel is built with the ``[mat]`` extra.
"""
from __future__ import annotations
import enum
class DisasterType(enum.Enum):
BuildingCollapse = 0
Earthquake = 1
Landslide = 2
Avalanche = 3
Flood = 4
MineCollapse = 5
Industrial = 6
TunnelCollapse = 7
Unknown = 8
def __repr__(self) -> str: ...
class TriageStatus(enum.Enum):
Immediate = 0
Delayed = 1
Minor = 2
Deceased = 3
Unknown = 4
@property
def priority(self) -> int: ...
def __repr__(self) -> str: ...
class VitalSignsReading:
@property
def breathing_rate_bpm(self) -> float | None: ...
@property
def heartbeat_rate_bpm(self) -> float | None: ...
@property
def movement_intensity(self) -> float: ...
@property
def confidence(self) -> float: ...
def __repr__(self) -> str: ...
class Survivor:
@property
def id(self) -> str: ...
@property
def triage_status(self) -> TriageStatus: ...
@property
def confidence(self) -> float: ...
@property
def location(self) -> tuple[float, float, float] | None: ...
@property
def latest_vitals(self) -> VitalSignsReading | None: ...
def __repr__(self) -> str: ...
class DisasterConfig:
def __init__(
self,
disaster_type: DisasterType,
sensitivity: float = ...,
confidence_threshold: float = ...,
max_depth: float = ...,
scan_interval_ms: int = ...,
) -> None: ...
@property
def sensitivity(self) -> float: ...
@property
def confidence_threshold(self) -> float: ...
@property
def max_depth(self) -> float: ...
def __repr__(self) -> str: ...
class ScanZone:
@staticmethod
def rectangle(
name: str, min_x: float, min_y: float, max_x: float, max_y: float
) -> ScanZone: ...
@staticmethod
def circle(name: str, center_x: float, center_y: float, radius: float) -> ScanZone: ...
@property
def name(self) -> str: ...
def __repr__(self) -> str: ...
class DisasterResponse:
def __init__(self, config: DisasterConfig) -> None: ...
def initialize_event(self, x: float, y: float, description: str) -> None: ...
def add_zone(self, zone: ScanZone) -> None: ...
def push_csi_data(self, amplitudes: list[float], phases: list[float]) -> None: ...
def scan_once(self) -> None: ...
def survivors(self) -> list[Survivor]: ...
def survivors_by_triage(self, status: TriageStatus) -> list[Survivor]: ...
def __repr__(self) -> str: ...