fix(adr-185): feature-gate MAT's ONNX ml module to cut [mat] wheel 8.4MB->2.0MB

Root cause (investigated, not assumed): the heavy deps in a [mat] wheel
were NOT on the survivor-detection/triage path the Python binding uses.
`wifi-densepose-mat` pulled `wifi-densepose-nn` as a NON-optional dep, and
nn's default `onnx` feature drags in `ort` (ONNX Runtime) + its
download/reqwest/hyper stack. But nn is used ONLY by mat's `src/ml/`
module (debris + vital-signs ONNX classifiers), which is an OPTIONAL
runtime enhancement: `DetectionPipeline.ml_pipeline` is `Option<_>`,
created only when `DetectionConfig::enable_ml` is set, and
`from_disaster_config` (the DisasterResponse path) never enables it. So
the ONNX stack was compiled-and-linked but never executed by the binding.

Fix = feature-gating (not a hoist — a hoist was unnecessary):
- `wifi-densepose-nn` made optional; new `ml` Cargo feature gates it.
- `default = [..., "ml"]` so existing consumers are unchanged; `api`
  implies `ml` (the REST surface exposes `ml_ready`).
- `#[cfg(feature = "ml")]` on the `ml` module, its crate-root + prelude
  re-exports, the `MatError::Ml` variant, and every ml touchpoint in
  detection/pipeline.rs (ml_config/ml_pipeline fields, constructor,
  process_zone enhancement block, enhance_with_ml/get_ml_results/
  initialize_ml/ml_ready/ml_pipeline accessors, update_config).
- Fixed a latent feature-unification bug surfaced by dropping nn:
  integration/hardware_adapter.rs uses `tokio::select!`, which needs
  `tokio/macros`. That was only satisfied transitively via nn; now
  declared explicitly on mat's own tokio dep so --no-default-features
  builds compile.

The ADR-185 [mat] wheel already depended on mat with
`default-features = false, features = ["std"]`, so no python/ change was
needed — dropping `ml` from the default now actually removes ort.

Measured (maturin build --release --features mat --strip):
  BEFORE (ml/ort in wheel): 8.4 MB  (over the ADR-117 §5.4 <=5 MB budget)
  AFTER  (ml gated off):    2.0 MB  (under budget) — 76% smaller

Verified:
  cargo build -p wifi-densepose-mat                         (default+ml)  OK
  cargo build -p wifi-densepose-mat --no-default-features --features std  OK
  cargo test --features mat --test mat_parity              2/2 pass
  maturin develop --features mat + pytest tests/test_mat.py 7/7 pass
  (same detection result: 1 survivor, triage Delayed — behavior-transparent)
This commit is contained in:
ruv 2026-07-21 17:22:20 -07:00
parent 65648fe4c2
commit 7ed57f0415
4 changed files with 63 additions and 22 deletions

1
python/Cargo.lock generated
View File

@ -4474,7 +4474,6 @@ dependencies = [
"tracing", "tracing",
"uuid", "uuid",
"wifi-densepose-core", "wifi-densepose-core",
"wifi-densepose-nn",
"wifi-densepose-signal", "wifi-densepose-signal",
] ]

View File

@ -12,15 +12,25 @@ categories = ["science", "algorithms"]
readme = "README.md" readme = "README.md"
[features] [features]
default = ["std", "api", "ruvector"] default = ["std", "api", "ruvector", "ml"]
ruvector = ["dep:ruvector-solver", "dep:ruvector-temporal-tensor"] ruvector = ["dep:ruvector-solver", "dep:ruvector-temporal-tensor"]
std = [] std = []
# ONNX-backed ML detection (debris + vital-signs classifiers). Pulls
# `wifi-densepose-nn` (and, via its default `onnx` feature, the `ort`
# ONNX Runtime + its download/reqwest stack) ONLY when enabled. The
# survivor-detection/triage pipeline works without it (ML is an optional
# enhancement, off unless `DetectionConfig::enable_ml`), so consumers that
# don't need ONNX — e.g. the ADR-185 `wifi-densepose-py` `[mat]` wheel —
# build `--no-default-features` and drop the entire ort/reqwest tree,
# keeping the wheel within the ADR-117 §5.4 budget.
ml = ["dep:wifi-densepose-nn"]
# REST/WebSocket surface. Pulls the web stack (axum, futures-util) only when # REST/WebSocket surface. Pulls the web stack (axum, futures-util) only when
# enabled, and enables the `serde` FEATURE (not just `dep:serde`) so the # enabled, and enables the `serde` FEATURE (not just `dep:serde`) so the
# `cfg_attr(feature = "serde", ...)` derives on domain types are actually # `cfg_attr(feature = "serde", ...)` derives on domain types are actually
# active when the API is on (review finding 5: `api = ["dep:serde"]` enabled # active when the API is on (review finding 5: `api = ["dep:serde"]` enabled
# the dependency but left every `feature = "serde"` cfg dead). # the dependency but left every `feature = "serde"` cfg dead).
api = ["serde", "dep:axum", "dep:futures-util"] # The REST surface exposes ML status (`ml_ready`), so `api` implies `ml`.
api = ["ml", "serde", "dep:axum", "dep:futures-util"]
# Real ESP32 serial CSI ingest. Pulls the native `serialport` crate (libudev on # Real ESP32 serial CSI ingest. Pulls the native `serialport` crate (libudev on
# Linux) only when enabled, so the default/no-default appliance build stays free # Linux) only when enabled, so the default/no-default appliance build stays free
# of native serial deps. With the feature OFF, the ESP32 serial *parser* still # of native serial deps. With the feature OFF, the ESP32 serial *parser* still
@ -36,14 +46,18 @@ serde = ["dep:serde", "chrono/serde", "geo/use-serde"]
# Workspace dependencies # Workspace dependencies
wifi-densepose-core = { version = "0.3.0", path = "../wifi-densepose-core" } wifi-densepose-core = { version = "0.3.0", path = "../wifi-densepose-core" }
wifi-densepose-signal = { version = "0.3.0", path = "../wifi-densepose-signal", default-features = false } wifi-densepose-signal = { version = "0.3.0", path = "../wifi-densepose-signal", default-features = false }
wifi-densepose-nn = { version = "0.3.0", path = "../wifi-densepose-nn" } wifi-densepose-nn = { version = "0.3.0", path = "../wifi-densepose-nn", optional = true }
ruvector-solver = { workspace = true, optional = true } ruvector-solver = { workspace = true, optional = true }
ruvector-temporal-tensor = { workspace = true, optional = true } ruvector-temporal-tensor = { workspace = true, optional = true }
# Async runtime — required by the core integration layer (UDP CSI receiver, # Async runtime — required by the core integration layer (UDP CSI receiver,
# hardware adapter, scan loop in `DisasterResponse::start_scanning`), not just # hardware adapter, scan loop in `DisasterResponse::start_scanning`), not just
# the REST API, so it is deliberately NOT gated behind `api`. # the REST API, so it is deliberately NOT gated behind `api`.
tokio = { version = "1.35", features = ["rt", "sync", "time"] } # `macros` is needed by `tokio::select!` in integration/hardware_adapter.rs.
# It was previously satisfied only by feature-unification from the (now
# optional) `wifi-densepose-nn` dep; declare it explicitly so a
# `--no-default-features` build (the ADR-185 [mat] wheel) still compiles.
tokio = { version = "1.35", features = ["rt", "sync", "time", "macros"] }
async-trait = "0.1" async-trait = "0.1"
# Web framework (REST API) — only compiled with the `api` feature. # Web framework (REST API) — only compiled with the `api` feature.

View File

@ -8,6 +8,7 @@ use super::{
MovementClassifier, MovementClassifierConfig, MovementClassifier, MovementClassifierConfig,
}; };
use crate::domain::{ScanZone, VitalSignsReading}; use crate::domain::{ScanZone, VitalSignsReading};
#[cfg(feature = "ml")]
use crate::ml::{MlDetectionConfig, MlDetectionPipeline, MlDetectionResult}; use crate::ml::{MlDetectionConfig, MlDetectionPipeline, MlDetectionResult};
use crate::{DisasterConfig, MatError}; use crate::{DisasterConfig, MatError};
@ -26,9 +27,10 @@ pub struct DetectionConfig {
pub enable_heartbeat: bool, pub enable_heartbeat: bool,
/// Minimum overall confidence to report detection /// Minimum overall confidence to report detection
pub min_confidence: f64, pub min_confidence: f64,
/// Enable ML-enhanced detection /// Enable ML-enhanced detection (requires the `ml` feature to have any effect)
pub enable_ml: bool, pub enable_ml: bool,
/// ML detection configuration (if enabled) /// ML detection configuration (if enabled)
#[cfg(feature = "ml")]
pub ml_config: Option<MlDetectionConfig>, pub ml_config: Option<MlDetectionConfig>,
} }
@ -42,6 +44,7 @@ impl Default for DetectionConfig {
enable_heartbeat: false, enable_heartbeat: false,
min_confidence: 0.3, min_confidence: 0.3,
enable_ml: false, enable_ml: false,
#[cfg(feature = "ml")]
ml_config: None, ml_config: None,
} }
} }
@ -64,6 +67,7 @@ impl DetectionConfig {
} }
/// Enable ML-enhanced detection with the given configuration /// Enable ML-enhanced detection with the given configuration
#[cfg(feature = "ml")]
pub fn with_ml(mut self, ml_config: MlDetectionConfig) -> Self { pub fn with_ml(mut self, ml_config: MlDetectionConfig) -> Self {
self.enable_ml = true; self.enable_ml = true;
self.ml_config = Some(ml_config); self.ml_config = Some(ml_config);
@ -71,6 +75,7 @@ impl DetectionConfig {
} }
/// Enable ML-enhanced detection with default configuration /// Enable ML-enhanced detection with default configuration
#[cfg(feature = "ml")]
pub fn with_default_ml(mut self) -> Self { pub fn with_default_ml(mut self) -> Self {
self.enable_ml = true; self.enable_ml = true;
self.ml_config = Some(MlDetectionConfig::default()); self.ml_config = Some(MlDetectionConfig::default());
@ -147,12 +152,14 @@ pub struct DetectionPipeline {
movement_classifier: MovementClassifier, movement_classifier: MovementClassifier,
data_buffer: parking_lot::RwLock<CsiDataBuffer>, data_buffer: parking_lot::RwLock<CsiDataBuffer>,
/// Optional ML detection pipeline /// Optional ML detection pipeline
#[cfg(feature = "ml")]
ml_pipeline: Option<MlDetectionPipeline>, ml_pipeline: Option<MlDetectionPipeline>,
} }
impl DetectionPipeline { impl DetectionPipeline {
/// Create a new detection pipeline /// Create a new detection pipeline
pub fn new(config: DetectionConfig) -> Self { pub fn new(config: DetectionConfig) -> Self {
#[cfg(feature = "ml")]
let ml_pipeline = if config.enable_ml { let ml_pipeline = if config.enable_ml {
config.ml_config.clone().map(MlDetectionPipeline::new) config.ml_config.clone().map(MlDetectionPipeline::new)
} else { } else {
@ -164,12 +171,14 @@ impl DetectionPipeline {
heartbeat_detector: HeartbeatDetector::new(config.heartbeat.clone()), heartbeat_detector: HeartbeatDetector::new(config.heartbeat.clone()),
movement_classifier: MovementClassifier::new(config.movement.clone()), movement_classifier: MovementClassifier::new(config.movement.clone()),
data_buffer: parking_lot::RwLock::new(CsiDataBuffer::new(config.sample_rate)), data_buffer: parking_lot::RwLock::new(CsiDataBuffer::new(config.sample_rate)),
#[cfg(feature = "ml")]
ml_pipeline, ml_pipeline,
config, config,
} }
} }
/// Initialize ML models asynchronously (if enabled) /// Initialize ML models asynchronously (if enabled)
#[cfg(feature = "ml")]
pub async fn initialize_ml(&mut self) -> Result<(), MatError> { pub async fn initialize_ml(&mut self) -> Result<(), MatError> {
if let Some(ref mut ml) = self.ml_pipeline { if let Some(ref mut ml) = self.ml_pipeline {
ml.initialize().await.map_err(MatError::from)?; ml.initialize().await.map_err(MatError::from)?;
@ -178,6 +187,7 @@ impl DetectionPipeline {
} }
/// Check if ML pipeline is ready /// Check if ML pipeline is ready
#[cfg(feature = "ml")]
pub fn ml_ready(&self) -> bool { pub fn ml_ready(&self) -> bool {
self.ml_pipeline.as_ref().is_none_or(|ml| ml.is_ready()) self.ml_pipeline.as_ref().is_none_or(|ml| ml.is_ready())
} }
@ -210,13 +220,23 @@ impl DetectionPipeline {
// `buffer` guard dropped here // `buffer` guard dropped here
}; };
// If ML is enabled and ready, enhance with ML predictions // If ML is enabled and ready, enhance with ML predictions (only
let enhanced_reading = if self.config.enable_ml && self.ml_ready() { // compiled under the `ml` feature; the base build is signal-only).
let enhanced_reading = {
#[cfg(feature = "ml")]
{
if self.config.enable_ml && self.ml_ready() {
// Snapshot the buffer under the lock, then drop the guard before await. // Snapshot the buffer under the lock, then drop the guard before await.
let buffer_snapshot = { self.data_buffer.read().clone() }; let buffer_snapshot = { self.data_buffer.read().clone() };
self.enhance_with_ml(reading, &buffer_snapshot).await? self.enhance_with_ml(reading, &buffer_snapshot).await?
} else { } else {
reading reading
}
}
#[cfg(not(feature = "ml"))]
{
reading
}
}; };
// Check minimum confidence // Check minimum confidence
@ -230,6 +250,7 @@ impl DetectionPipeline {
} }
/// Enhance detection results with ML predictions /// Enhance detection results with ML predictions
#[cfg(feature = "ml")]
async fn enhance_with_ml( async fn enhance_with_ml(
&self, &self,
traditional_reading: Option<VitalSignsReading>, traditional_reading: Option<VitalSignsReading>,
@ -262,6 +283,7 @@ impl DetectionPipeline {
} }
/// Get the latest ML detection results (if ML is enabled) /// Get the latest ML detection results (if ML is enabled)
#[cfg(feature = "ml")]
pub async fn get_ml_results(&self) -> Option<MlDetectionResult> { pub async fn get_ml_results(&self) -> Option<MlDetectionResult> {
let ml = match &self.ml_pipeline { let ml = match &self.ml_pipeline {
Some(ml) => ml, Some(ml) => ml,
@ -346,6 +368,7 @@ impl DetectionPipeline {
self.movement_classifier = MovementClassifier::new(config.movement.clone()); self.movement_classifier = MovementClassifier::new(config.movement.clone());
// Update ML pipeline if configuration changed // Update ML pipeline if configuration changed
#[cfg(feature = "ml")]
if config.enable_ml != self.config.enable_ml || config.ml_config != self.config.ml_config { if config.enable_ml != self.config.enable_ml || config.ml_config != self.config.ml_config {
self.ml_pipeline = if config.enable_ml { self.ml_pipeline = if config.enable_ml {
config.ml_config.clone().map(MlDetectionPipeline::new) config.ml_config.clone().map(MlDetectionPipeline::new)
@ -358,6 +381,7 @@ impl DetectionPipeline {
} }
/// Get the ML pipeline (if enabled) /// Get the ML pipeline (if enabled)
#[cfg(feature = "ml")]
pub fn ml_pipeline(&self) -> Option<&MlDetectionPipeline> { pub fn ml_pipeline(&self) -> Option<&MlDetectionPipeline> {
self.ml_pipeline.as_ref() self.ml_pipeline.as_ref()
} }

View File

@ -87,6 +87,10 @@ pub mod detection;
pub mod domain; pub mod domain;
pub mod integration; pub mod integration;
pub mod localization; pub mod localization;
/// ONNX-backed ML detection. Requires the `ml` feature (pulls
/// `wifi-densepose-nn` + `ort`). The core survivor-detection/triage
/// pipeline works without it.
#[cfg(feature = "ml")]
pub mod ml; pub mod ml;
pub mod tracking; pub mod tracking;
@ -130,6 +134,7 @@ pub use integration::{
#[cfg_attr(docsrs, doc(cfg(feature = "api")))] #[cfg_attr(docsrs, doc(cfg(feature = "api")))]
pub use api::{create_router, AppState}; pub use api::{create_router, AppState};
#[cfg(feature = "ml")]
pub use ml::{ pub use ml::{
AttenuationPrediction, AttenuationPrediction,
BreathingClassification, BreathingClassification,
@ -207,6 +212,7 @@ pub enum MatError {
Io(#[from] std::io::Error), Io(#[from] std::io::Error),
/// Machine learning error /// Machine learning error
#[cfg(feature = "ml")]
#[error("ML error: {0}")] #[error("ML error: {0}")]
Ml(#[from] ml::MlError), Ml(#[from] ml::MlError),
} }
@ -592,8 +598,6 @@ pub mod prelude {
AssociationResult, AssociationResult,
BreathingPattern, BreathingPattern,
Coordinates3D, Coordinates3D,
DebrisClassification,
DebrisModel,
DetectionEvent, DetectionEvent,
DetectionObservation, DetectionObservation,
// Detection // Detection
@ -614,11 +618,6 @@ pub mod prelude {
// Localization // Localization
LocalizationService, LocalizationService,
MatError, MatError,
MaterialType,
// ML types
MlDetectionConfig,
MlDetectionPipeline,
MlDetectionResult,
Priority, Priority,
Result, Result,
ScanZone, ScanZone,
@ -631,12 +630,17 @@ pub mod prelude {
TrackerConfig, TrackerConfig,
TrackingEvent, TrackingEvent,
TriageStatus, TriageStatus,
UncertaintyEstimate,
VitalSignsClassifier,
VitalSignsDetector, VitalSignsDetector,
VitalSignsReading, VitalSignsReading,
ZoneBounds, ZoneBounds,
}; };
// ONNX-backed ML types — only when the `ml` feature is enabled.
#[cfg(feature = "ml")]
pub use crate::{
DebrisClassification, DebrisModel, MaterialType, MlDetectionConfig, MlDetectionPipeline,
MlDetectionResult, UncertaintyEstimate, VitalSignsClassifier,
};
} }
#[cfg(test)] #[cfg(test)]