mirror of
https://github.com/ruvnet/RuView
synced 2026-07-23 17:33:20 +00:00
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:
@@ -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(&litudes, &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(())
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user