diff --git a/v2/crates/wifi-densepose-hardware/src/ieee80211bf/tests.rs b/v2/crates/wifi-densepose-hardware/src/ieee80211bf/tests.rs index 4585c9ed..48d23719 100644 --- a/v2/crates/wifi-densepose-hardware/src/ieee80211bf/tests.rs +++ b/v2/crates/wifi-densepose-hardware/src/ieee80211bf/tests.rs @@ -88,6 +88,13 @@ fn serde_rejects_out_of_range_setup_id() { assert!(serde_json::from_str::("127").is_ok()); } +#[test] +fn serde_rejects_out_of_range_threshold_params() { + assert!(serde_json::from_str::(r#"{"delta_percent":255}"#).is_err()); + let ok = serde_json::from_str::(r#"{"delta_percent":100}"#).unwrap(); + assert_eq!(ok.delta_percent(), 100); +} + // ---------- validation, no panics ---------- #[test] diff --git a/v2/crates/wifi-densepose-hardware/src/ieee80211bf/types.rs b/v2/crates/wifi-densepose-hardware/src/ieee80211bf/types.rs index 15e216bd..9801ad76 100644 --- a/v2/crates/wifi-densepose-hardware/src/ieee80211bf/types.rs +++ b/v2/crates/wifi-densepose-hardware/src/ieee80211bf/types.rs @@ -216,11 +216,30 @@ pub fn bandwidth_mhz(bw: Bandwidth) -> u16 { /// Threshold-based reporting parameters: a report is generated only when the /// measurement changes by at least `delta_percent` relative to the last /// reported measurement (normalized-change trigger). +/// +/// Deserialization validates through [`ThresholdParams::new`] so the +/// `delta_percent <= 100` invariant holds on every construction path, +/// including untrusted wire/persisted payloads (same convention as +/// [`MeasurementSetupId`]). #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(try_from = "RawThresholdParams")] pub struct ThresholdParams { delta_percent: u8, } +#[derive(Deserialize)] +struct RawThresholdParams { + delta_percent: u8, +} + +impl TryFrom for ThresholdParams { + type Error = BfError; + + fn try_from(raw: RawThresholdParams) -> Result { + Self::new(raw.delta_percent) + } +} + impl ThresholdParams { pub fn new(delta_percent: u8) -> Result { if delta_percent > 100 { diff --git a/v2/crates/wifi-densepose-train/src/wiflow_std/layers.rs b/v2/crates/wifi-densepose-train/src/wiflow_std/layers.rs index d363f3f1..5c514030 100644 --- a/v2/crates/wifi-densepose-train/src/wiflow_std/layers.rs +++ b/v2/crates/wifi-densepose-train/src/wiflow_std/layers.rs @@ -6,6 +6,17 @@ use tch::{nn, nn::Module, Tensor}; use super::config::{CONV_BLOCK_DROPOUT, TCN_KERNEL}; +/// BatchNorm config matching the reference: gamma = 1 (PyTorch default; the +/// reference additionally pins BatchNorm1d weight=1/bias=0). tch-0.24's +/// `BatchNormConfig::default()` would draw gamma from Uniform(0,1), silently +/// halving activations on average in from-scratch training. +pub(super) fn bn_cfg() -> nn::BatchNormConfig { + nn::BatchNormConfig { + ws_init: nn::Init::Const(1.0), + ..Default::default() + } +} + // --------------------------------------------------------------------------- // GroupedTemporalBlock (TCN level) // --------------------------------------------------------------------------- @@ -53,19 +64,19 @@ impl GroupedTemporalBlock { }; let conv1_group = nn::conv1d(&vs / "conv1_group", c_in, c_in, k, grouped_cfg(groups)); - let bn1_group = nn::batch_norm1d(&vs / "bn1_group", c_in, Default::default()); + let bn1_group = nn::batch_norm1d(&vs / "bn1_group", c_in, bn_cfg()); let conv1_pw = nn::conv1d(&vs / "conv1_pw", c_in, c_out, 1, pointwise_cfg); - let bn1_pw = nn::batch_norm1d(&vs / "bn1_pw", c_out, Default::default()); + let bn1_pw = nn::batch_norm1d(&vs / "bn1_pw", c_out, bn_cfg()); let conv2_group = nn::conv1d(&vs / "conv2_group", c_out, c_out, k, grouped_cfg(groups)); - let bn2_group = nn::batch_norm1d(&vs / "bn2_group", c_out, Default::default()); + let bn2_group = nn::batch_norm1d(&vs / "bn2_group", c_out, bn_cfg()); let conv2_pw = nn::conv1d(&vs / "conv2_pw", c_out, c_out, 1, pointwise_cfg); - let bn2_pw = nn::batch_norm1d(&vs / "bn2_pw", c_out, Default::default()); + let bn2_pw = nn::batch_norm1d(&vs / "bn2_pw", c_out, bn_cfg()); let downsample = (c_in != c_out).then(|| { ( nn::conv1d(&vs / "ds_conv", c_in, c_out, 1, pointwise_cfg), - nn::batch_norm1d(&vs / "ds_bn", c_out, Default::default()), + nn::batch_norm1d(&vs / "ds_bn", c_out, bn_cfg()), ) }); @@ -145,11 +156,11 @@ impl ConvBlock { ..Default::default() }; let conv1 = nn::conv(&vs / "conv1", c_in, c_out, [1, 3], asym(stride_w)); - let bn1 = nn::batch_norm2d(&vs / "bn1", c_out, Default::default()); + let bn1 = nn::batch_norm2d(&vs / "bn1", c_out, bn_cfg()); let conv2 = nn::conv(&vs / "conv2", c_out, c_out, [1, 3], asym(1)); - let bn2 = nn::batch_norm2d(&vs / "bn2", c_out, Default::default()); + let bn2 = nn::batch_norm2d(&vs / "bn2", c_out, bn_cfg()); let conv3 = nn::conv(&vs / "conv3", c_out, c_out, [1, 3], asym(1)); - let bn3 = nn::batch_norm2d(&vs / "bn3", c_out, Default::default()); + let bn3 = nn::batch_norm2d(&vs / "bn3", c_out, bn_cfg()); let ds_conv = nn::conv( &vs / "ds_conv", @@ -162,7 +173,7 @@ impl ConvBlock { ..Default::default() }, ); - let ds_bn = nn::batch_norm2d(&vs / "ds_bn", c_out, Default::default()); + let ds_bn = nn::batch_norm2d(&vs / "ds_bn", c_out, bn_cfg()); ConvBlock { conv1, @@ -228,9 +239,9 @@ impl AxialAttention { ..Default::default() }, ); - let bn_qkv = nn::batch_norm1d(&vs / "bn_qkv", planes * 3, Default::default()); - let bn_similarity = nn::batch_norm2d(&vs / "bn_similarity", groups, Default::default()); - let bn_output = nn::batch_norm1d(&vs / "bn_output", planes, Default::default()); + let bn_qkv = nn::batch_norm1d(&vs / "bn_qkv", planes * 3, bn_cfg()); + let bn_similarity = nn::batch_norm2d(&vs / "bn_similarity", groups, bn_cfg()); + let bn_output = nn::batch_norm1d(&vs / "bn_output", planes, bn_cfg()); AxialAttention { qkv, diff --git a/v2/crates/wifi-densepose-train/src/wiflow_std/model.rs b/v2/crates/wifi-densepose-train/src/wiflow_std/model.rs index 0ede0632..9e2665b3 100644 --- a/v2/crates/wifi-densepose-train/src/wiflow_std/model.rs +++ b/v2/crates/wifi-densepose-train/src/wiflow_std/model.rs @@ -2,8 +2,14 @@ //! //! Idiomatic reimplementation of the DY2434 reference (Apache-2.0); see the //! [module docs](crate::wiflow_std) for provenance and the evidence grade. -//! Weights are initialised from scratch (tch defaults; the axial-attention -//! qkv conv mirrors the reference's `N(0, sqrt(1/in_planes))` init). The +//! From-scratch init: BN gamma is pinned to 1 (see `layers::bn_cfg`); the +//! axial-attention qkv conv uses `N(0, sqrt(1/in_planes))` per the +//! reference's `attention.py` intent (note the reference's *effective* init +//! differs — its `_initialize_weights` re-inits every `nn.Conv1d`, qkv +//! included, with `kaiming_normal(fan_out)`); conv weights keep tch defaults +//! (kaiming-uniform fan_in), which differ in scale from PyTorch's defaults. +//! These divergences affect from-scratch training dynamics only — BN absorbs +//! them at init, and loaded checkpoints overwrite everything. The //! retrained PyTorch checkpoint loads via [`WiFlowStdModel::load`] after //! key-remapped safetensors export //! (`benchmarks/wiflow-std/export_to_safetensors.py`); numerical parity with @@ -99,9 +105,9 @@ impl WiFlowStdModel { ..Default::default() }, ); - let dec_bn1 = nn::batch_norm2d(&root / "dec_bn1", mid, Default::default()); + let dec_bn1 = nn::batch_norm2d(&root / "dec_bn1", mid, super::layers::bn_cfg()); let dec_conv2 = nn::conv2d(&root / "dec_conv2", mid, 2, 1, Default::default()); - let dec_bn2 = nn::batch_norm2d(&root / "dec_bn2", 2, Default::default()); + let dec_bn2 = nn::batch_norm2d(&root / "dec_bn2", 2, super::layers::bn_cfg()); Ok(WiFlowStdModel { vs,