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
View File
@@ -4474,7 +4474,6 @@ dependencies = [
"tracing",
"uuid",
"wifi-densepose-core",
"wifi-densepose-nn",
"wifi-densepose-signal",
]
+18 -4
View File
@@ -12,15 +12,25 @@ categories = ["science", "algorithms"]
readme = "README.md"
[features]
default = ["std", "api", "ruvector"]
default = ["std", "api", "ruvector", "ml"]
ruvector = ["dep:ruvector-solver", "dep:ruvector-temporal-tensor"]
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
# enabled, and enables the `serde` FEATURE (not just `dep:serde`) so the
# `cfg_attr(feature = "serde", ...)` derives on domain types are actually
# active when the API is on (review finding 5: `api = ["dep:serde"]` enabled
# 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
# 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
@@ -36,14 +46,18 @@ serde = ["dep:serde", "chrono/serde", "geo/use-serde"]
# Workspace dependencies
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-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-temporal-tensor = { workspace = true, optional = true }
# Async runtime — required by the core integration layer (UDP CSI receiver,
# hardware adapter, scan loop in `DisasterResponse::start_scanning`), not just
# 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"
# Web framework (REST API) — only compiled with the `api` feature.
@@ -8,6 +8,7 @@ use super::{
MovementClassifier, MovementClassifierConfig,
};
use crate::domain::{ScanZone, VitalSignsReading};
#[cfg(feature = "ml")]
use crate::ml::{MlDetectionConfig, MlDetectionPipeline, MlDetectionResult};
use crate::{DisasterConfig, MatError};
@@ -26,9 +27,10 @@ pub struct DetectionConfig {
pub enable_heartbeat: bool,
/// Minimum overall confidence to report detection
pub min_confidence: f64,
/// Enable ML-enhanced detection
/// Enable ML-enhanced detection (requires the `ml` feature to have any effect)
pub enable_ml: bool,
/// ML detection configuration (if enabled)
#[cfg(feature = "ml")]
pub ml_config: Option<MlDetectionConfig>,
}
@@ -42,6 +44,7 @@ impl Default for DetectionConfig {
enable_heartbeat: false,
min_confidence: 0.3,
enable_ml: false,
#[cfg(feature = "ml")]
ml_config: None,
}
}
@@ -64,6 +67,7 @@ impl DetectionConfig {
}
/// Enable ML-enhanced detection with the given configuration
#[cfg(feature = "ml")]
pub fn with_ml(mut self, ml_config: MlDetectionConfig) -> Self {
self.enable_ml = true;
self.ml_config = Some(ml_config);
@@ -71,6 +75,7 @@ impl DetectionConfig {
}
/// Enable ML-enhanced detection with default configuration
#[cfg(feature = "ml")]
pub fn with_default_ml(mut self) -> Self {
self.enable_ml = true;
self.ml_config = Some(MlDetectionConfig::default());
@@ -147,12 +152,14 @@ pub struct DetectionPipeline {
movement_classifier: MovementClassifier,
data_buffer: parking_lot::RwLock<CsiDataBuffer>,
/// Optional ML detection pipeline
#[cfg(feature = "ml")]
ml_pipeline: Option<MlDetectionPipeline>,
}
impl DetectionPipeline {
/// Create a new detection pipeline
pub fn new(config: DetectionConfig) -> Self {
#[cfg(feature = "ml")]
let ml_pipeline = if config.enable_ml {
config.ml_config.clone().map(MlDetectionPipeline::new)
} else {
@@ -164,12 +171,14 @@ impl DetectionPipeline {
heartbeat_detector: HeartbeatDetector::new(config.heartbeat.clone()),
movement_classifier: MovementClassifier::new(config.movement.clone()),
data_buffer: parking_lot::RwLock::new(CsiDataBuffer::new(config.sample_rate)),
#[cfg(feature = "ml")]
ml_pipeline,
config,
}
}
/// Initialize ML models asynchronously (if enabled)
#[cfg(feature = "ml")]
pub async fn initialize_ml(&mut self) -> Result<(), MatError> {
if let Some(ref mut ml) = self.ml_pipeline {
ml.initialize().await.map_err(MatError::from)?;
@@ -178,6 +187,7 @@ impl DetectionPipeline {
}
/// Check if ML pipeline is ready
#[cfg(feature = "ml")]
pub fn ml_ready(&self) -> bool {
self.ml_pipeline.as_ref().is_none_or(|ml| ml.is_ready())
}
@@ -210,13 +220,23 @@ impl DetectionPipeline {
// `buffer` guard dropped here
};
// If ML is enabled and ready, enhance with ML predictions
let enhanced_reading = if self.config.enable_ml && self.ml_ready() {
// Snapshot the buffer under the lock, then drop the guard before await.
let buffer_snapshot = { self.data_buffer.read().clone() };
self.enhance_with_ml(reading, &buffer_snapshot).await?
} else {
reading
// If ML is enabled and ready, enhance with ML predictions (only
// 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.
let buffer_snapshot = { self.data_buffer.read().clone() };
self.enhance_with_ml(reading, &buffer_snapshot).await?
} else {
reading
}
}
#[cfg(not(feature = "ml"))]
{
reading
}
};
// Check minimum confidence
@@ -230,6 +250,7 @@ impl DetectionPipeline {
}
/// Enhance detection results with ML predictions
#[cfg(feature = "ml")]
async fn enhance_with_ml(
&self,
traditional_reading: Option<VitalSignsReading>,
@@ -262,6 +283,7 @@ impl DetectionPipeline {
}
/// Get the latest ML detection results (if ML is enabled)
#[cfg(feature = "ml")]
pub async fn get_ml_results(&self) -> Option<MlDetectionResult> {
let ml = match &self.ml_pipeline {
Some(ml) => ml,
@@ -346,6 +368,7 @@ impl DetectionPipeline {
self.movement_classifier = MovementClassifier::new(config.movement.clone());
// Update ML pipeline if configuration changed
#[cfg(feature = "ml")]
if config.enable_ml != self.config.enable_ml || config.ml_config != self.config.ml_config {
self.ml_pipeline = if config.enable_ml {
config.ml_config.clone().map(MlDetectionPipeline::new)
@@ -358,6 +381,7 @@ impl DetectionPipeline {
}
/// Get the ML pipeline (if enabled)
#[cfg(feature = "ml")]
pub fn ml_pipeline(&self) -> Option<&MlDetectionPipeline> {
self.ml_pipeline.as_ref()
}
+13 -9
View File
@@ -87,6 +87,10 @@ pub mod detection;
pub mod domain;
pub mod integration;
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 tracking;
@@ -130,6 +134,7 @@ pub use integration::{
#[cfg_attr(docsrs, doc(cfg(feature = "api")))]
pub use api::{create_router, AppState};
#[cfg(feature = "ml")]
pub use ml::{
AttenuationPrediction,
BreathingClassification,
@@ -207,6 +212,7 @@ pub enum MatError {
Io(#[from] std::io::Error),
/// Machine learning error
#[cfg(feature = "ml")]
#[error("ML error: {0}")]
Ml(#[from] ml::MlError),
}
@@ -592,8 +598,6 @@ pub mod prelude {
AssociationResult,
BreathingPattern,
Coordinates3D,
DebrisClassification,
DebrisModel,
DetectionEvent,
DetectionObservation,
// Detection
@@ -614,11 +618,6 @@ pub mod prelude {
// Localization
LocalizationService,
MatError,
MaterialType,
// ML types
MlDetectionConfig,
MlDetectionPipeline,
MlDetectionResult,
Priority,
Result,
ScanZone,
@@ -631,12 +630,17 @@ pub mod prelude {
TrackerConfig,
TrackingEvent,
TriageStatus,
UncertaintyEstimate,
VitalSignsClassifier,
VitalSignsDetector,
VitalSignsReading,
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)]