From b9e9a1b5fd04ada989696a6c8d34473ee99e3e1f Mon Sep 17 00:00:00 2001 From: ruv Date: Thu, 11 Jun 2026 13:36:02 -0400 Subject: [PATCH] fix: resolve all 10 confirmed code-review findings (7-angle review, 20/20 verified) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit wiflow_std: min_feature_width (default 15) replaces the keypoints->stride coupling — for_keypoints(17) now provably builds the trained [2,2,2,2] graph and pools 15->17, matching the validated Python protocol (pinned by tests); param_count() total on invalid configs; random_mask returns Result and rejects non-finite/out-of-range ratios; trainer checkpoints switched to safetensors (.pt VarStore roundtrip broken on Windows torch 2.11). ieee80211bf: SBP proxy now re-triggers instances and relays reports via Action::RelaySbpReport -> SensingFrame::SbpReport (clients consume via their existing path); missed_instances reset on success = consecutive semantics; SessionTable gains a guarded SBP entry point + unknown-id drop counter; initiator-role sessions reject inbound setup/SBP requests (RejectedNotSupported) closing the idle hijack; StartSetup/StartSbp outside Idle return InvalidStateForCommand; SBP validation unified through evaluate_setup with a 1:1 SetupStatus->SbpStatus mapping. events.rs split out to honor the 500-line cap. calibration/cli: enrollment geometry now actually reaches trained banks — both production call sites attach .with_geometry; --geometry flag on train-room and POST /enroll/geometry + train-body geometry on calibrate-serve give production a recording surface; geometry-free banks log the ADR-152 §2.1.2 note. benchmarks: corruption masks committed as ground truth (unregenerable after in-place cleaning; verified bit-identical regeneration from the pristine copy) + generate_corruption_masks.py producer; _bench_common.py dedups the 5x-copied shim/evaluate/seed/remap (post-refactor PCK@20 re-verified equal to the last digit); remote scripts get the mmap patch; tiny_edge --calib validated multiple-of-64; onnx_bench --help no longer executes (and overwrote) the export — artifact restored byte-exact. Workspace: 2,963 tests passed, 0 failed; Python proof PASS. Co-Authored-By: claude-flow --- benchmarks/wiflow-std/.gitignore | 7 + benchmarks/wiflow-std/RESULTS.md | 13 + benchmarks/wiflow-std/_bench_common.py | 200 +++++++++++ benchmarks/wiflow-std/eval_ort_accuracy.py | 30 +- benchmarks/wiflow-std/eval_repro.py | 83 +---- .../wiflow-std/export_to_safetensors.py | 21 +- .../wiflow-std/generate_corruption_masks.py | 148 ++++++++ benchmarks/wiflow-std/onnx_bench.py | 34 +- benchmarks/wiflow-std/quantize_bench.py | 72 +--- .../wiflow-std/remote/eval_retrained.py | 19 + .../wiflow-std/remote/measb/train_measb.py | 5 + .../wiflow-std/remote/sweep/run_sweep.py | 19 + .../wiflow-std/results/big_windows_mask.npy | Bin 0 -> 360128 bytes .../wiflow-std/results/nan_windows_mask.npy | Bin 0 -> 360128 bytes benchmarks/wiflow-std/ruvector.db | Bin 0 -> 1589248 bytes benchmarks/wiflow-std/static_ptq_bench.py | 3 +- benchmarks/wiflow-std/tiny_edge_bench.py | 10 +- .../wifi-densepose-cli/src/calibrate_api.rs | 111 +++++- v2/crates/wifi-densepose-cli/src/room.rs | 174 ++++++++- .../src/ieee80211bf/events.rs | 108 ++++++ .../src/ieee80211bf/messages.rs | 26 ++ .../src/ieee80211bf/mod.rs | 11 +- .../src/ieee80211bf/session.rs | 218 +++++------ .../src/ieee80211bf/table.rs | 75 +++- .../src/ieee80211bf/tests_fsm.rs | 176 +++++---- .../src/ieee80211bf/tests_sbp.rs | 338 ++++++++++++++++++ .../src/ieee80211bf/transport.rs | 8 + .../src/ieee80211bf/types.rs | 4 + v2/crates/wifi-densepose-train/src/error.rs | 12 + v2/crates/wifi-densepose-train/src/mae.rs | 25 +- v2/crates/wifi-densepose-train/src/model.rs | 17 +- v2/crates/wifi-densepose-train/src/trainer.rs | 14 +- .../src/wiflow_std/config.rs | 131 ++++++- .../src/wiflow_std/model.rs | 14 +- .../wifi-densepose-train/tests/test_mae.rs | 49 ++- 35 files changed, 1751 insertions(+), 424 deletions(-) create mode 100644 benchmarks/wiflow-std/_bench_common.py create mode 100644 benchmarks/wiflow-std/generate_corruption_masks.py create mode 100644 benchmarks/wiflow-std/results/big_windows_mask.npy create mode 100644 benchmarks/wiflow-std/results/nan_windows_mask.npy create mode 100644 benchmarks/wiflow-std/ruvector.db create mode 100644 v2/crates/wifi-densepose-hardware/src/ieee80211bf/events.rs create mode 100644 v2/crates/wifi-densepose-hardware/src/ieee80211bf/tests_sbp.rs diff --git a/benchmarks/wiflow-std/.gitignore b/benchmarks/wiflow-std/.gitignore index 56133dbc..5244b54e 100644 --- a/benchmarks/wiflow-std/.gitignore +++ b/benchmarks/wiflow-std/.gitignore @@ -17,3 +17,10 @@ downloads/ results/parity_fixture.json __pycache__/ *.onnx + +# Committed ground truth: corruption masks for the pristine Kaggle download. +# remote/clean_v2.py zeroes the corrupted source windows IN PLACE, so these +# masks CANNOT be regenerated from a cleaned copy (generate_corruption_masks.py +# documents the criteria and reproduces them only from a fresh download). +!results/nan_windows_mask.npy +!results/big_windows_mask.npy diff --git a/benchmarks/wiflow-std/RESULTS.md b/benchmarks/wiflow-std/RESULTS.md index 85820340..52306f22 100644 --- a/benchmarks/wiflow-std/RESULTS.md +++ b/benchmarks/wiflow-std/RESULTS.md @@ -66,6 +66,19 @@ mostly would-be *training* data — so this is not a split mismatch): Kaggle upload. Window masks: `results/nan_windows_mask.npy`, `results/big_windows_mask.npy`. +### Reproducing the corruption masks + +The two mask files (9,070 NaN/Inf windows, 9,072 with |amplitude| > 1.5; +union 9,072, all in dataset files 487–499) are **committed ground truth** +(gitignore-negated, ~352 KB each). They can only be regenerated from a +**pristine** Kaggle download: `remote/clean_v2.py` repairs the dataset by +zeroing the corrupted windows in place, after which the corruption evidence +is gone and a rescan returns all-False. `generate_corruption_masks.py` +re-derives them (chunked scan, criteria: any non-finite value OR +max |finite| > 1.5 per 540×20 window) and refuses to write all-False masks, +which indicate a cleaned copy. Verified 2026-06-11: a regeneration from the +local pristine download is bit-identical to the committed masks. + ### Retraining result (MEASURED, 2026-06-10): claims APPROXIMATELY REPRODUCED Since the shipped checkpoint is unusable, measurement (a) fell back to retraining diff --git a/benchmarks/wiflow-std/_bench_common.py b/benchmarks/wiflow-std/_bench_common.py new file mode 100644 index 00000000..f6a67bf0 --- /dev/null +++ b/benchmarks/wiflow-std/_bench_common.py @@ -0,0 +1,200 @@ +"""Shared infrastructure for the LOCAL wiflow-std benchmark scripts (ADR-152). + +This module is the single canonical implementation of the helpers that were +previously copy-pasted across eval_repro.py / quantize_bench.py / +onnx_bench.py / eval_ort_accuracy.py / export_to_safetensors.py: + + - ``import_upstream()`` -- sys.path setup + the models-package stub that + works around the upstream import bug, plus the >1GB np.load mmap patch + - ``install_np_load_mmap_patch()`` -- the mmap patch on its own + - ``remap_legacy_keys()`` / ``load_remapped_state()`` -- checkpoint + key remap for the pre-rename released checkpoint + - ``load_wiflow_model()`` -- WiFlowPoseModel from a checkpoint, eval mode + - ``set_seed()`` -- mirrors upstream run.py seeding exactly + - ``evaluate()`` -- THE canonical batch-weighted PCK/MPJPE evaluation loop + (thresholds 0.1-0.5, upstream utils/metrics.py math); accepts either a + torch nn.Module or an onnxruntime InferenceSession + +The scripts under remote/ deploy to ruvultra as standalone single files and +therefore intentionally inline private copies of these helpers; when editing +them, treat this module as the reference implementation and keep the copies +in sync. +""" + +import os +import random +import sys +import time +import types + +import numpy as np +import torch + +HERE = os.path.dirname(os.path.abspath(__file__)) +UPSTREAM = os.path.join(HERE, "upstream") +RESULTS = os.path.join(HERE, "results") + +DEFAULT_THRESHOLDS = (0.1, 0.2, 0.3, 0.4, 0.5) + +# --------------------------------------------------------------------------- +# >1GB np.load mmap patch +# --------------------------------------------------------------------------- + +# csi_windows.npy is ~13 GB; mmap large arrays instead of loading into RAM +# (loading it eagerly needs ~15 GB). +_np_load = np.load + + +def _np_load_mmap(path, *a, **kw): + if (isinstance(path, str) and path.endswith(".npy") + and os.path.getsize(path) > 1 << 30 and "mmap_mode" not in kw): + kw["mmap_mode"] = "r" + return _np_load(path, *a, **kw) + + +def install_np_load_mmap_patch(): + """Globally patch np.load so .npy files >1GB are mmap'd read-only. + + Idempotent. Patching the numpy module attribute is equivalent to the + historical ``upstream_dataset.np.load = _np_load_mmap`` (dataset.np IS + the numpy module), but works regardless of import order. + """ + np.load = _np_load_mmap + + +# --------------------------------------------------------------------------- +# upstream import shim +# --------------------------------------------------------------------------- + +def import_upstream(mmap_patch=True): + """Make the upstream WiFlow-STD clone importable; returns its path. + + Upstream bug: models/__init__.py imports TemporalConvNet, which + models/tcn.py does not define -- the package fails to import as + published. Register a stub package so the broken __init__ never + executes; submodules (models.pose_model etc.) still resolve via + __path__. Idempotent. + """ + if UPSTREAM not in sys.path: + sys.path.insert(0, UPSTREAM) + if "models" not in sys.modules: + _models_pkg = types.ModuleType("models") + _models_pkg.__path__ = [os.path.join(UPSTREAM, "models")] + sys.modules["models"] = _models_pkg + if mmap_patch: + install_np_load_mmap_patch() + return UPSTREAM + + +# --------------------------------------------------------------------------- +# checkpoint loading +# --------------------------------------------------------------------------- + +# The released checkpoint predates the published code: modules were renamed +# att -> attention, final_conv -> decoder (param count identical, 2.23M). +LEGACY_RENAMES = {"att.": "attention.", "final_conv.": "decoder."} + + +def remap_legacy_keys(state): + """Remap pre-rename state_dict keys; no-op for already-new-style keys.""" + return {next((new + k[len(old):] for old, new in LEGACY_RENAMES.items() + if k.startswith(old)), k): v + for k, v in state.items()} + + +def load_remapped_state(path, map_location="cpu"): + """torch.load (weights_only) + legacy key remap.""" + state = torch.load(path, map_location=map_location, weights_only=True) + return remap_legacy_keys(state) + + +def load_wiflow_model(checkpoint, map_location="cpu", dropout=0.5): + """Full-size WiFlowPoseModel from a checkpoint, strict load, eval mode.""" + import_upstream() + from models.pose_model import WiFlowPoseModel + model = WiFlowPoseModel(dropout=dropout) + model.load_state_dict(load_remapped_state(checkpoint, map_location), + strict=True) + model.eval() + return model + + +# --------------------------------------------------------------------------- +# seeding +# --------------------------------------------------------------------------- + +def set_seed(seed=42): + # mirror upstream run.py exactly + random.seed(seed) + np.random.seed(seed) + torch.manual_seed(seed) + if torch.cuda.is_available(): + torch.cuda.manual_seed(seed) + torch.cuda.manual_seed_all(seed) + torch.backends.cudnn.deterministic = True + torch.backends.cudnn.benchmark = False + + +# --------------------------------------------------------------------------- +# THE canonical evaluation loop +# --------------------------------------------------------------------------- + +def evaluate(model, loader, device=None, dtype=None, label="", + thresholds=DEFAULT_THRESHOLDS, progress_every=50): + """Batch-weighted PCK/MPJPE over a DataLoader (upstream metrics math). + + ``model`` may be a torch nn.Module (optionally evaluated on ``device`` + with inputs cast to ``dtype``) or an onnxruntime InferenceSession. + Per-threshold PCK values are independent in upstream calculate_pck, so + evaluating a superset of thresholds never changes any individual value. + + Returns {"samples", "mpjpe", "pck@10".."pck@50", "wall_seconds"}. + """ + import_upstream() + from utils.metrics import calculate_mpjpe, calculate_pck + + is_ort = hasattr(model, "get_inputs") # onnxruntime InferenceSession + if is_ort: + inp = model.get_inputs()[0].name + + def forward(bx): + return torch.from_numpy(model.run(None, {inp: bx.numpy()})[0]) + else: + model.eval() + + def forward(bx): + if device is not None: + bx = bx.to(device) + if dtype is not None: + bx = bx.to(dtype) + return model(bx).float() + + thresholds = list(thresholds) + totals = {t: 0.0 for t in thresholds} + total_mpe, n = 0.0, 0 + t0 = time.time() + with torch.no_grad(): + for batch_idx, (bx, by) in enumerate(loader): + out = forward(bx) + if device is not None and not is_ort: + by = by.to(device) + mpe = calculate_mpjpe(out, by) + pck = calculate_pck(out, by, thresholds=thresholds) + bs = by.size(0) + total_mpe += mpe * bs + for t in totals: + totals[t] += pck[t] * bs + n += bs + if batch_idx % progress_every == 0: + tag = f"[{label}] " if label else "" + pck20 = totals.get(0.2) + pck20_str = f"pck20={pck20 / n:.4f} " if pck20 is not None else "" + print(f" {tag}batch {batch_idx}: n={n} {pck20_str}" + f"mpjpe={total_mpe / n:.4f} ({time.time() - t0:.0f}s)", + flush=True) + return { + "samples": n, + "mpjpe": total_mpe / n, + **{f"pck@{int(t * 100)}": totals[t] / n for t in thresholds}, + "wall_seconds": time.time() - t0, + } diff --git a/benchmarks/wiflow-std/eval_ort_accuracy.py b/benchmarks/wiflow-std/eval_ort_accuracy.py index 280e627f..94f7016e 100644 --- a/benchmarks/wiflow-std/eval_ort_accuracy.py +++ b/benchmarks/wiflow-std/eval_ort_accuracy.py @@ -17,41 +17,17 @@ import argparse import json import os import sys -import time - -import numpy as np -import torch HERE = os.path.dirname(os.path.abspath(__file__)) -RESULTS = os.path.join(HERE, "results") sys.path.insert(0, HERE) +from _bench_common import RESULTS, evaluate # noqa: E402 from quantize_bench import build_test_subset # noqa: E402 (sets up upstream imports) -sys.path.insert(0, os.path.join(HERE, "upstream")) -from utils.metrics import calculate_mpjpe, calculate_pck # noqa: E402 - def evaluate_ort(sess, loader, label): - inp = sess.get_inputs()[0].name - totals = {0.2: 0.0, 0.5: 0.0} - total_mpe, n = 0.0, 0 - t0 = time.time() - for batch_idx, (bx, by) in enumerate(loader): - out = torch.from_numpy(sess.run(None, {inp: bx.numpy()})[0]) - pck = calculate_pck(out, by, thresholds=[0.2, 0.5]) - mpe = calculate_mpjpe(out, by) - bs = by.size(0) - total_mpe += mpe * bs - for t in totals: - totals[t] += pck[t] * bs - n += bs - if batch_idx % 50 == 0: - print(f" [{label}] batch {batch_idx}: n={n} " - f"pck20={totals[0.2]/n:.4f} mpjpe={total_mpe/n:.4f} " - f"({time.time()-t0:.0f}s)", flush=True) - return {"samples": n, "pck@20": totals[0.2] / n, "pck@50": totals[0.5] / n, - "mpjpe": total_mpe / n, "wall_seconds": time.time() - t0} + """ORT-session evaluation via the canonical _bench_common.evaluate loop.""" + return evaluate(sess, loader, label=label) def main(): diff --git a/benchmarks/wiflow-std/eval_repro.py b/benchmarks/wiflow-std/eval_repro.py index 7758c74a..6a35ea18 100644 --- a/benchmarks/wiflow-std/eval_repro.py +++ b/benchmarks/wiflow-std/eval_repro.py @@ -15,56 +15,18 @@ Usage: import argparse import json import os -import random import sys -import time -import numpy as np import torch from torch.utils.data import DataLoader -UPSTREAM = os.path.join(os.path.dirname(os.path.abspath(__file__)), "upstream") -sys.path.insert(0, UPSTREAM) +from _bench_common import (UPSTREAM, evaluate, import_upstream, + load_remapped_state, set_seed) -# Upstream bug: models/__init__.py imports TemporalConvNet, which models/tcn.py -# does not define (it defines TemporalBlock) — the package fails to import as -# published. Register a stub package so the broken __init__ never executes; -# submodules (models.pose_model etc.) still resolve via __path__. -import types # noqa: E402 +import_upstream() # sys.path + models stub + >1GB np.load mmap patch -_models_pkg = types.ModuleType("models") -_models_pkg.__path__ = [os.path.join(UPSTREAM, "models")] -sys.modules["models"] = _models_pkg - -import dataset as upstream_dataset # noqa: E402 from dataset import PreprocessedCSIKeypointsDataset, create_preprocessed_train_val_test_loaders # noqa: E402 from models.pose_model import WiFlowPoseModel # noqa: E402 -from utils.metrics import calculate_pck, calculate_mpjpe # noqa: E402 - -# csi_windows.npy is ~13 GB; mmap large arrays instead of loading into RAM. -_np_load = np.load - - -def _np_load_mmap(path, *a, **kw): - if (isinstance(path, str) and path.endswith(".npy") - and os.path.getsize(path) > 1 << 30 and "mmap_mode" not in kw): - kw["mmap_mode"] = "r" - return _np_load(path, *a, **kw) - - -upstream_dataset.np.load = _np_load_mmap - - -def set_seed(seed=42): - # mirror upstream run.py exactly - random.seed(seed) - np.random.seed(seed) - torch.manual_seed(seed) - if torch.cuda.is_available(): - torch.cuda.manual_seed(seed) - torch.cuda.manual_seed_all(seed) - torch.backends.cudnn.deterministic = True - torch.backends.cudnn.benchmark = False def find_data_dir(root): @@ -74,35 +36,6 @@ def find_data_dir(root): return None -def evaluate(model, loader, device): - model.eval() - totals = {t: 0.0 for t in (0.1, 0.2, 0.3, 0.4, 0.5)} - total_mpe = 0.0 - n = 0 - t0 = time.time() - with torch.no_grad(): - for batch_idx, (batch_x, batch_y) in enumerate(loader): - batch_x = batch_x.to(device) - batch_y = batch_y.to(device) - outputs = model(batch_x) - mpe = calculate_mpjpe(outputs, batch_y) - pck = calculate_pck(outputs, batch_y, thresholds=[0.1, 0.2, 0.3, 0.4, 0.5]) - bs = batch_y.size(0) - total_mpe += mpe * bs - for t in totals: - totals[t] += pck[t] * bs - n += bs - if batch_idx % 50 == 0: - print(f" batch {batch_idx}: n={n} pck20={totals[0.2]/n:.4f} " - f"mpjpe={total_mpe/n:.4f} ({time.time()-t0:.0f}s)", flush=True) - return { - "samples": n, - "mpjpe": total_mpe / n, - **{f"pck@{int(t*100)}": totals[t] / n for t in totals}, - "wall_seconds": time.time() - t0, - } - - def main(): parser = argparse.ArgumentParser() parser.add_argument("--data-dir", required=True, @@ -134,13 +67,9 @@ def main(): dataset=dataset, batch_size=args.batch_size, num_workers=0, random_seed=42) model = WiFlowPoseModel(dropout=0.5).to(device) - state = torch.load(args.checkpoint, map_location=device, weights_only=True) # released checkpoint predates the published code: modules were renamed # att -> attention, final_conv -> decoder (param count identical, 2.23M) - renames = {"att.": "attention.", "final_conv.": "decoder."} - state = {next((new + k[len(old):] for old, new in renames.items() - if k.startswith(old)), k): v - for k, v in state.items()} + state = load_remapped_state(args.checkpoint, map_location=device) model.load_state_dict(state, strict=True) n_params = sum(p.numel() for p in model.parameters()) print(f"checkpoint: {args.checkpoint} ({n_params/1e6:.2f}M params)") @@ -154,13 +83,13 @@ def main(): "device": str(device)} print("=== test set (full, drop_last=False) ===") - results["test_full"] = evaluate(model, test_loader, device) + results["test_full"] = evaluate(model, test_loader, device=device) print(json.dumps(results["test_full"], indent=2)) test_loader_dl = DataLoader(test_loader.dataset, batch_size=args.batch_size, shuffle=False, drop_last=True) print("=== test set (drop_last=True, as upstream train.py) ===") - results["test_drop_last"] = evaluate(model, test_loader_dl, device) + results["test_drop_last"] = evaluate(model, test_loader_dl, device=device) print(json.dumps(results["test_drop_last"], indent=2)) os.makedirs(os.path.dirname(args.out), exist_ok=True) diff --git a/benchmarks/wiflow-std/export_to_safetensors.py b/benchmarks/wiflow-std/export_to_safetensors.py index b5c9d4d5..75e3eded 100644 --- a/benchmarks/wiflow-std/export_to_safetensors.py +++ b/benchmarks/wiflow-std/export_to_safetensors.py @@ -41,24 +41,14 @@ Usage: import json import os import re -import sys import numpy as np import torch from safetensors.torch import save_file -HERE = os.path.dirname(os.path.abspath(__file__)) -UPSTREAM = os.path.join(HERE, "upstream") -RESULTS = os.path.join(HERE, "results") -sys.path.insert(0, UPSTREAM) +from _bench_common import RESULTS, import_upstream, remap_legacy_keys -# Upstream models/__init__.py is broken as published (imports a name tcn.py -# does not define); register a stub package so it never executes. -import types # noqa: E402 - -_models_pkg = types.ModuleType("models") -_models_pkg.__path__ = [os.path.join(UPSTREAM, "models")] -sys.modules["models"] = _models_pkg +import_upstream() # sys.path + models stub from models.pose_model import WiFlowPoseModel # noqa: E402 @@ -125,11 +115,8 @@ def main(): state = state[wrapper] break - # Legacy upstream names predate the published code (eval_repro.py). - renames = {"att.": "attention.", "final_conv.": "decoder."} - state = {next((new + k[len(old):] for old, new in renames.items() - if k.startswith(old)), k): v - for k, v in state.items()} + # Legacy upstream names predate the published code (_bench_common). + state = remap_legacy_keys(state) mapped = {} dropped = 0 diff --git a/benchmarks/wiflow-std/generate_corruption_masks.py b/benchmarks/wiflow-std/generate_corruption_masks.py new file mode 100644 index 00000000..2ab82c04 --- /dev/null +++ b/benchmarks/wiflow-std/generate_corruption_masks.py @@ -0,0 +1,148 @@ +"""Regenerate results/nan_windows_mask.npy + results/big_windows_mask.npy by +scanning a PRISTINE kagglehub download of the WiFlow-STD dataset +(kaka2434/wiflow-dataset v1, csi_windows.npy, 360,000 windows of 540x20). + +============================ READ THIS FIRST =============================== +This script MUST be run against an UNCLEANED copy of the dataset. + +remote/clean_v2.py (and its predecessor clean_nan.py) repair the dataset by +zeroing the corrupted windows IN PLACE, with no backup. A cleaned copy +contains no non-finite values and no out-of-range amplitudes, so on a cleaned +copy this scan produces ALL-FALSE masks -- silently wrong ground truth. The +script errors out loudly in that case (see the sanity check in main()). + +That irreversibility is exactly why the two committed mask files under +results/ (gitignore-negated) are the canonical ground truth: once a download +has been cleaned, the masks can NEVER be regenerated from it. Only run this +on a fresh `kagglehub.dataset_download("kaka2434/wiflow-dataset")`. +============================================================================ + +Criteria (per window; mirrors the original 2026-06-10 scan and the +remote/clean_v2.py repair criteria): + + nan mask: any non-finite value (NaN/Inf) anywhere in the 540x20 window + big mask: max |finite value| > 1.5 (the data is otherwise [0,1]-normalized; + the corrupted files contain garbage up to 3.4e38, float32 max) + +Expected result on the pristine Kaggle download (RESULTS.md defect 5): + nan: 9,070 True | big: 9,072 True | union: 9,072 -- all windows in dataset + files 487-499 (the final 13 files), window indices 350,922-359,999. + +Usage: + PYTHONUTF8=1 .venv/Scripts/python.exe generate_corruption_masks.py \ + [--data-dir ] [--out-dir results] +""" + +import argparse +import os +import sys + +import numpy as np + +HERE = os.path.dirname(os.path.abspath(__file__)) +RESULTS = os.path.join(HERE, "results") + +EXPECTED = {"nan": 9070, "big": 9072, "union": 9072, + "files": (487, 499), "windows": (350922, 359999)} + + +def scan(csi_path, chunk=4000): + """Chunked scan of the (mmap'd) windows array; returns (nan_mask, big_mask).""" + csi = np.load(csi_path, mmap_mode="r") + n = len(csi) + nan_mask = np.zeros(n, dtype=bool) + big_mask = np.zeros(n, dtype=bool) + for i in range(0, n, chunk): + block = np.asarray(csi[i:i + chunk]) + finite = np.isfinite(block) + nan_mask[i:i + chunk] = (~finite).any(axis=(1, 2)) + big_mask[i:i + chunk] = ( + np.abs(np.where(finite, block, 0)).max(axis=(1, 2)) > 1.5) + if (i // chunk) % 10 == 0: + print(f" scanned {min(i + chunk, n):,}/{n:,} windows " + f"(nan={int(nan_mask.sum()):,} big={int(big_mask.sum()):,})", + flush=True) + return nan_mask, big_mask + + +def describe_files(data_dir, mask): + """Map marked windows to dataset file indices via window_info.npz.""" + info = os.path.join(data_dir, "window_info.npz") + if not os.path.exists(info): + return None + w2f = np.load(info)["window_to_file"] + return np.unique(w2f[mask]) + + +def main(): + parser = argparse.ArgumentParser( + description="Regenerate the corruption masks from a PRISTINE " + "(uncleaned) kagglehub download. See module docstring.") + parser.add_argument("--data-dir", default=os.path.join( + os.path.expanduser("~"), ".cache", "kagglehub", "datasets", "kaka2434", + "wiflow-dataset", "versions", "1", "preprocessed_csi_data"), + help="Directory containing csi_windows.npy (PRISTINE copy)") + parser.add_argument("--out-dir", default=RESULTS, + help="Where to write the two .npy masks") + parser.add_argument("--chunk", type=int, default=4000, + help="Windows per scan chunk (memory/speed tradeoff)") + args = parser.parse_args() + + csi_path = os.path.join(args.data_dir, "csi_windows.npy") + if not os.path.exists(csi_path): + sys.exit(f"csi_windows.npy not found in {args.data_dir}") + + print(f"scanning {csi_path} (chunk={args.chunk}) ...") + nan_mask, big_mask = scan(csi_path, args.chunk) + union = nan_mask | big_mask + print(f"nan: {int(nan_mask.sum()):,} | big: {int(big_mask.sum()):,} | " + f"union: {int(union.sum()):,} of {len(union):,} windows") + + # ---- sanity check: an all-False result means a CLEANED copy ------------ + if not union.any(): + sys.exit( + "ERROR: scan found ZERO corrupted windows.\n" + "\n" + "The pristine Kaggle download (kaka2434/wiflow-dataset v1) is " + "known to contain\n" + "9,072 corrupted windows (NaN/Inf + amplitudes up to 3.4e38) in " + "dataset files\n" + "487-499 (RESULTS.md, reproducibility defect 5). Finding none " + "means this copy\n" + "has almost certainly already been repaired by remote/clean_v2.py " + "(or clean_nan.py),\n" + "which zeroes the corrupted windows IN PLACE -- after that the " + "corruption evidence\n" + "is gone and the masks CANNOT be regenerated from this copy.\n" + "\n" + "Refusing to overwrite the committed ground-truth masks with " + "all-False ones.\n" + "Re-download the dataset (kagglehub.dataset_download(" + "'kaka2434/wiflow-dataset'))\n" + "and point --data-dir at the fresh, uncleaned copy.") + + files = describe_files(args.data_dir, union) + if files is not None: + print(f"marked windows span dataset files {files.min()}-{files.max()}: " + f"{files.tolist()}") + lo, hi = EXPECTED["files"] + if files.min() != lo or files.max() != hi: + print(f"WARNING: expected marked files exactly {lo}-{hi} " + f"(the pristine v1 download); got {files.min()}-{files.max()}. " + f"Different dataset version, or a partially cleaned copy?") + for name, mask, exp in (("nan", nan_mask, EXPECTED["nan"]), + ("big", big_mask, EXPECTED["big"])): + if int(mask.sum()) != exp: + print(f"WARNING: {name} mask has {int(mask.sum()):,} True windows; " + f"the pristine v1 download yields {exp:,}.") + + os.makedirs(args.out_dir, exist_ok=True) + for name, mask in (("nan_windows_mask.npy", nan_mask), + ("big_windows_mask.npy", big_mask)): + out = os.path.join(args.out_dir, name) + np.save(out, mask) + print(f"wrote {out} ({int(mask.sum()):,} True)") + + +if __name__ == "__main__": + main() diff --git a/benchmarks/wiflow-std/onnx_bench.py b/benchmarks/wiflow-std/onnx_bench.py index 5e4c7c14..f9285f66 100644 --- a/benchmarks/wiflow-std/onnx_bench.py +++ b/benchmarks/wiflow-std/onnx_bench.py @@ -21,40 +21,22 @@ import json import os import platform import statistics -import sys import time import traceback import numpy as np import torch -HERE = os.path.dirname(os.path.abspath(__file__)) -UPSTREAM = os.path.join(HERE, "upstream") -RESULTS = os.path.join(HERE, "results") -sys.path.insert(0, UPSTREAM) +from _bench_common import RESULTS, import_upstream, load_wiflow_model -import types # noqa: E402 - -_models_pkg = types.ModuleType("models") -_models_pkg.__path__ = [os.path.join(UPSTREAM, "models")] -sys.modules["models"] = _models_pkg - -from models.pose_model import WiFlowPoseModel # noqa: E402 +import_upstream() # sys.path + models stub + >1GB np.load mmap patch CHECKPOINT = os.path.join(RESULTS, "retrained_best_pose_model.pth") OUT_JSON = os.path.join(RESULTS, "edge_optimization.json") def load_fp32_model(): - state = torch.load(CHECKPOINT, map_location="cpu", weights_only=True) - renames = {"att.": "attention.", "final_conv.": "decoder."} - state = {next((new + k[len(old):] for old, new in renames.items() - if k.startswith(old)), k): v - for k, v in state.items()} - model = WiFlowPoseModel(dropout=0.5) - model.load_state_dict(state, strict=True) - model.eval() - return model + return load_wiflow_model(CHECKPOINT) def try_export(model, path, batch, dynamic, opset=17): @@ -115,6 +97,16 @@ def bench_ort(sess, batch, n_runs): def main(): + import argparse + parser = argparse.ArgumentParser( + description="ONNX export + onnxruntime CPU benchmark for the " + "retrained WiFlow-STD checkpoint (no options; see " + "module docstring). NB: the published " + "retrained_fp32_dynamic.onnx came from the TorchScript " + "exporter; on newer torch the dynamo attempt may succeed " + "first and produce a different (external-data) artifact.") + parser.parse_args() + import onnxruntime model = load_fp32_model() results = { diff --git a/benchmarks/wiflow-std/quantize_bench.py b/benchmarks/wiflow-std/quantize_bench.py index 70939fc6..80d842e1 100644 --- a/benchmarks/wiflow-std/quantize_bench.py +++ b/benchmarks/wiflow-std/quantize_bench.py @@ -28,7 +28,6 @@ import json import os import platform import statistics -import sys import time import numpy as np @@ -36,55 +35,21 @@ import torch import torch.nn as nn from torch.utils.data import DataLoader -HERE = os.path.dirname(os.path.abspath(__file__)) -UPSTREAM = os.path.join(HERE, "upstream") -RESULTS = os.path.join(HERE, "results") -sys.path.insert(0, UPSTREAM) +from _bench_common import HERE, RESULTS, evaluate, import_upstream, load_wiflow_model -# Upstream models/__init__.py is broken as published (imports a name tcn.py -# does not define); register a stub package so it never executes. -import types # noqa: E402 +import_upstream() # sys.path + models stub + >1GB np.load mmap patch -_models_pkg = types.ModuleType("models") -_models_pkg.__path__ = [os.path.join(UPSTREAM, "models")] -sys.modules["models"] = _models_pkg - -import dataset as upstream_dataset # noqa: E402 from dataset import ( # noqa: E402 PreprocessedCSIKeypointsDataset, create_preprocessed_train_val_test_loaders, ) -from models.pose_model import WiFlowPoseModel # noqa: E402 -from utils.metrics import calculate_mpjpe, calculate_pck # noqa: E402 CHECKPOINT = os.path.join(RESULTS, "retrained_best_pose_model.pth") -# csi_windows.npy is ~13 GB; mmap large arrays instead of loading into RAM -# (same trick as eval_repro.py). -_np_load = np.load - - -def _np_load_mmap(path, *a, **kw): - if (isinstance(path, str) and path.endswith(".npy") - and os.path.getsize(path) > 1 << 30 and "mmap_mode" not in kw): - kw["mmap_mode"] = "r" - return _np_load(path, *a, **kw) - - -upstream_dataset.np.load = _np_load_mmap - def load_fp32_model(): - state = torch.load(CHECKPOINT, map_location="cpu", weights_only=True) - # legacy upstream names, harmless no-op on the retrained checkpoint - renames = {"att.": "attention.", "final_conv.": "decoder."} - state = {next((new + k[len(old):] for old, new in renames.items() - if k.startswith(old)), k): v - for k, v in state.items()} - model = WiFlowPoseModel(dropout=0.5) - model.load_state_dict(state, strict=True) - model.eval() - return model + # legacy upstream key remap inside is a harmless no-op on this checkpoint + return load_wiflow_model(CHECKPOINT) def state_dict_size_bytes(model, path): @@ -138,33 +103,6 @@ def build_test_subset(data_dir, subset_size, batch_size=64): return loader, len(clean) -def evaluate(model, loader, dtype=torch.float32, label=""): - totals = {0.2: 0.0, 0.5: 0.0} - total_mpe, n = 0.0, 0 - t0 = time.time() - with torch.no_grad(): - for batch_idx, (bx, by) in enumerate(loader): - out = model(bx.to(dtype)).float() - pck = calculate_pck(out, by, thresholds=[0.2, 0.5]) - mpe = calculate_mpjpe(out, by) - bs = by.size(0) - total_mpe += mpe * bs - for t in totals: - totals[t] += pck[t] * bs - n += bs - if batch_idx % 50 == 0: - print(f" [{label}] batch {batch_idx}: n={n} " - f"pck20={totals[0.2]/n:.4f} mpjpe={total_mpe/n:.4f} " - f"({time.time()-t0:.0f}s)", flush=True) - return { - "samples": n, - "pck@20": totals[0.2] / n, - "pck@50": totals[0.5] / n, - "mpjpe": total_mpe / n, - "wall_seconds": time.time() - t0, - } - - def quantize_int8_dynamic(fp32_model): """torch.ao.quantization.quantize_dynamic on Linear/Conv where supported. Returns (model, report) where report documents what actually quantized.""" @@ -272,7 +210,7 @@ def main(): for name, (model, dtype, _f) in variants.items(): print(f"\n=== accuracy: {name} ===") results["variants"][name]["accuracy"] = evaluate( - model, loader, dtype, label=name) + model, loader, dtype=dtype, label=name) print(json.dumps(results["variants"][name]["accuracy"], indent=2)) # ---- merge into edge_optimization.json --------------------------------- diff --git a/benchmarks/wiflow-std/remote/eval_retrained.py b/benchmarks/wiflow-std/remote/eval_retrained.py index 92b1b17a..7940184d 100644 --- a/benchmarks/wiflow-std/remote/eval_retrained.py +++ b/benchmarks/wiflow-std/remote/eval_retrained.py @@ -3,6 +3,11 @@ Scores the model produced by run.py (train_output/best_pose_model.pth or similar) on the seed-42 test split: full test set AND NaN-free subset (excluding windows that were zero-filled by clean_nan.py — file indices 487-499). + +NOTE: deployed to ruvultra (~/wiflow-std-bench) as a standalone single file, +so it deliberately inlines its helpers. The reference implementations (upstream +import shim, >1GB np.load mmap patch, key-remap loader, canonical evaluate +loop) live in benchmarks/wiflow-std/_bench_common.py — keep copies in sync. """ import json, os, random, sys @@ -10,6 +15,20 @@ import numpy as np import torch from torch.utils.data import DataLoader, Subset +# csi_windows.npy is ~13 GB; mmap large arrays instead of eagerly loading +# ~15 GB into RAM (same patch as _bench_common._np_load_mmap). +_np_load = np.load + + +def _np_load_mmap(path, *a, **kw): + if (isinstance(path, str) and path.endswith('.npy') + and os.path.getsize(path) > 1 << 30 and 'mmap_mode' not in kw): + kw['mmap_mode'] = 'r' + return _np_load(path, *a, **kw) + + +np.load = _np_load_mmap + sys.path.insert(0, os.path.expanduser('~/wiflow-std-bench/upstream')) from dataset import PreprocessedCSIKeypointsDataset, create_preprocessed_train_val_test_loaders from models.pose_model import WiFlowPoseModel diff --git a/benchmarks/wiflow-std/remote/measb/train_measb.py b/benchmarks/wiflow-std/remote/measb/train_measb.py index 5789836f..11555854 100644 --- a/benchmarks/wiflow-std/remote/measb/train_measb.py +++ b/benchmarks/wiflow-std/remote/measb/train_measb.py @@ -54,6 +54,11 @@ Pre-registered protocol (followed exactly): Usage (on ruvultra): nice -n 10 nohup ~/wiflow-std-bench/venv/bin/python train_measb.py > train_measb.log 2>&1 & + +NOTE: deployed to ruvultra as a standalone single file, so it deliberately +inlines its helpers. The reference implementations (upstream import shim, +np.load mmap patch, key-remap loader, canonical evaluate loop) live in +benchmarks/wiflow-std/_bench_common.py — keep copies in sync. """ import json diff --git a/benchmarks/wiflow-std/remote/sweep/run_sweep.py b/benchmarks/wiflow-std/remote/sweep/run_sweep.py index b0312d31..ab406d83 100644 --- a/benchmarks/wiflow-std/remote/sweep/run_sweep.py +++ b/benchmarks/wiflow-std/remote/sweep/run_sweep.py @@ -17,6 +17,11 @@ Usage: nohup venv/bin/python sweep/run_sweep.py > sweep/sweep.log 2>&1 & Idempotent: variants already present in sweep/results.jsonl are skipped. + +NOTE: deployed to ruvultra (~/wiflow-std-bench/sweep) as a standalone file, so +it deliberately inlines its helpers. The reference implementations (upstream +import shim, >1GB np.load mmap patch, key-remap loader, canonical evaluate +loop) live in benchmarks/wiflow-std/_bench_common.py — keep copies in sync. """ import argparse import copy @@ -30,6 +35,20 @@ import numpy as np import torch from torch.utils.data import DataLoader, Subset +# csi_windows.npy is ~13 GB; mmap large arrays instead of eagerly loading +# ~15 GB into RAM (same patch as _bench_common._np_load_mmap). +_np_load = np.load + + +def _np_load_mmap(path, *a, **kw): + if (isinstance(path, str) and path.endswith('.npy') + and os.path.getsize(path) > 1 << 30 and 'mmap_mode' not in kw): + kw['mmap_mode'] = 'r' + return _np_load(path, *a, **kw) + + +np.load = _np_load_mmap + BENCH = os.path.expanduser('~/wiflow-std-bench') SWEEP = os.path.join(BENCH, 'sweep') sys.path.insert(0, os.path.join(BENCH, 'upstream')) diff --git a/benchmarks/wiflow-std/results/big_windows_mask.npy b/benchmarks/wiflow-std/results/big_windows_mask.npy new file mode 100644 index 0000000000000000000000000000000000000000..56a70171ae0ba1abd85793b5d662532723d39769 GIT binary patch literal 360128 zcmeI$u@1pt6b0aR^%Vd1lXM^wg9oq?i@_wN6@#d>V!=~*VHGbRlE(6#?VjNb-*}&k z)qJ^5lWTH|McM4?I1TZ>9mOofeN|uT{Isd+qI~zW{MeLj-yHID+1~x}WY{JfWZ@Bh zOD_=u1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7e?*8-^!AV7cs0RjXF5FkK+009C72y`UydC_z< aZk7N60t5&UAV7cs0RjXF5FkL{uL3WsF+W)V literal 0 HcmV?d00001 diff --git a/benchmarks/wiflow-std/results/nan_windows_mask.npy b/benchmarks/wiflow-std/results/nan_windows_mask.npy new file mode 100644 index 0000000000000000000000000000000000000000..da5e52442cd08c1f6528f95b7f104871480ffe29 GIT binary patch literal 360128 zcmeI$u@1pt6b0aR^%Vd1lXM^wg9oq?i@_wN6@#d>V!=~*VKvxA(pbK;-7}ow8}D7)j9RTpRA0^wOmTeQHQW?oHrj*JGsp?9Wm4l%X3Q~l6Kx&`?v-WP270$YN z*U(Z5Zcn{Y36P3Zi3rgHhzkeA1tc!qiW<=zdgMm02nlgWQ~qP`i${$U+bfj%<2TaZ z@9mrSX5Rbl$l5nEJG;_pFFbV5Z|=RPno??}^hzY_nfP0&6n~1>w_d92l~Sq=tNiG< zpE`g1`R3Kvp8mx*UjN+7f6C=~GygqTee<#IAD^mycKScBy<9z$*WY>%8*65U6;2%g z{6Am1^6(?4e)Z6~YmfiAeW&*S#Bl#F{Nbyge)Rb#KXds@R~MR3{O#-8_1k&^1PBly zK!5-N0t5&UAg~t%ih23vqm6Vd(qbxqtx-)g_tw(+pVZRYm0FtoS1r9-si(K@uBUWg zJ&iT%Y3BZVx*BovfqGhscs=5G5r2ue8u4DlwTR}?dYX)Q35E# znSUQir)rHf_whzL{fS08bAKb9eV~!9L>!C0%tV}xI2X~5crN0H5wAx4ETZUd^d^l* zoQ^otNa<|!yC}QHT<#;y2uj2s;&GIu6>L2|x z5>l#oM!~*1qI8x30RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5(bEs*a~_;%cz z(2g4v;-?!nf5)M^ncuqg_N8epxyN98CN&5UAV7cs0RjXF5FkK+z`hsA=NRVV+`{QN z!4O}4*gVG&zaN=@H}X~kP0`SADc(v)IROF$2oN~90{Jt<$@uIr9Uma#(!`C=4Qr)* z7ASs@+GmEWAtiA51d9I=4@QhdW-ih!P<#k>TL5y zxD?MU$ZLv;d(MAUY3|$k=vQ&CLheWLfgtBcP4l)-#deEx6nQ)Gn1mboT=wBKx1EZ1 zMLw@zE9G-pY%U#(m@BQzW#e0vmEVJ0uRQi#R_=2yEB7Ur6>08E@ebb?uWz>$^(_Pl z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&g7JI_=#)}Zyh&f;KsrC*DcyIqQjOc8e#>BIfO%KD!@x20!R zXTG`Hp{Y-R009C72oNAZfB*pk1oneKG5B%VryZk(|@5q=v*4sEtT(lieI^LaWtPSD9%b$Ryys4+1anH zw0iy4;-I_S`%3%i?%?86tqa{=t9U)E5e1#I-TvU1PBlyK!5-N0t5&UAV6TZ z0+qNhU(B?B9x*?^vbu4Vf4;pizj5_{e*VRYcK1@J*N@3|e`5CWndzx0=nq=G#m;=( z8?e${oS2<>a=G8_bta}J`f-my%*EqWz~Ew(pZG%FiAr@ynU9 zzn>Vp``tJ1I{ux`p$fYMc3C(H&3pZ8UOj+(r`^t(D`A+h_u#wQ{_18%] [--subset 10000] [--calib 500] + [--data-dir ] [--subset 10000] [--calib 512] + (--calib must be a multiple of 64; see step 4 above) Writes/merges into results/edge_optimization.json under key "tiny_variant". """ @@ -211,6 +212,13 @@ def main(): parser.add_argument("--out", default=os.path.join(RESULTS, "edge_optimization.json")) args = parser.parse_args() + if args.calib % 64 != 0: + parser.error( + f"--calib must be a multiple of 64 (got {args.calib}): ORT 1.26's " + f"histogram calibration collector np.asarray()'s the per-batch " + f"maxima and crashes on a ragged final batch (calibration batch " + f"size is 64)") + model = load_tiny_model() info = describe(model) print(f"tiny model: {info['params']:,} params, tcn_groups={info['tcn_groups_per_block']}, " diff --git a/v2/crates/wifi-densepose-cli/src/calibrate_api.rs b/v2/crates/wifi-densepose-cli/src/calibrate_api.rs index a8a57180..8be6fb03 100644 --- a/v2/crates/wifi-densepose-cli/src/calibrate_api.rs +++ b/v2/crates/wifi-densepose-cli/src/calibrate_api.rs @@ -39,7 +39,8 @@ use tokio::sync::{mpsc, oneshot, RwLock}; use tower_http::cors::CorsLayer; use wifi_densepose_calibration::extract::{AnchorFeature, Features}; use wifi_densepose_calibration::{ - AnchorLabel, AnchorQualityGate, AnchorRecorder, MixtureOfSpecialists, SpecialistBank, + AnchorLabel, AnchorQualityGate, AnchorRecorder, MixtureOfSpecialists, NodeGeometry, + SpecialistBank, }; use wifi_densepose_core::types::CsiFrame; use wifi_densepose_signal::{BaselineCalibration, CalibrationRecorder}; @@ -207,6 +208,9 @@ struct RoomEnroll { baseline_id: String, fs_hz: f32, anchors: Vec, + /// Transceiver geometry recorded via `POST /enroll/geometry` (ADR-152 + /// §2.1.1); latest recording wins. Snapshotted into the bank at train time. + geometry: Vec, } /// Result of capturing one anchor (`POST /enroll/anchor`). @@ -299,6 +303,7 @@ fn build_router(state: ApiState) -> Router { .route("/api/v1/room/state", get(room_state)) .route("/api/v1/room/train", post(train_room)) .route("/api/v1/enroll/anchor", post(enroll_anchor)) + .route("/api/v1/enroll/geometry", post(enroll_geometry)) .route("/api/v1/enroll/status", get(enroll_status)) .layer(CorsLayer::permissive()) .with_state(state) @@ -670,8 +675,9 @@ async fn descriptor() -> impl IntoResponse { "GET /api/v1/calibration/result": "last finalized baseline summary", "GET /api/v1/calibration/baselines": "list persisted baseline files", "GET /api/v1/room/state?bank=": "live mixture-of-specialists RoomState over the CSI window", - "POST /api/v1/room/train": "{ room_id, baseline_id, anchors[]? } → train + persist a specialist bank (anchors[] optional if enrolled in-server)", + "POST /api/v1/room/train": "{ room_id, baseline_id, anchors[]?, geometry[]? } → train + persist a specialist bank (anchors[]/geometry[] optional if enrolled in-server)", "POST /api/v1/enroll/anchor": "{ room_id, baseline, label, duration_s? } → capture one guided anchor (blocks for the capture)", + "POST /api/v1/enroll/geometry": "{ room_id, geometry: [NodeGeometry…] } → record transceiver geometry for the room (ADR-152 §2.1.1; latest wins)", "GET /api/v1/enroll/status?room=": "enrollment progress (accepted anchors, next, complete)" } })) @@ -740,11 +746,18 @@ struct TrainRequest { baseline_id: String, #[serde(default)] anchors: Vec, + /// Optional transceiver geometry (ADR-152 §2.1.1). Falls back to the + /// geometry recorded in-server via `POST /enroll/geometry`; absent both, + /// the bank trains geometry-free (valid, but no geometry conditioning). + #[serde(default)] + geometry: Vec, } /// Train a per-room specialist bank and persist it as `/.json` /// (the name `room-state` reads back). Uses the posted `anchors` if present, else /// falls back to the in-server enrollment accumulated via `POST /enroll/anchor`. +/// The enrollment's transceiver-geometry snapshot (posted `geometry` or the +/// `POST /enroll/geometry` record) is threaded into the bank (ADR-152 §2.1.1). async fn train_room(State(st): State, Json(req): Json) -> impl IntoResponse { let (anchors, baseline_id) = if !req.anchors.is_empty() { (req.anchors.clone(), req.baseline_id.clone()) @@ -756,11 +769,25 @@ async fn train_room(State(st): State, Json(req): Json) - } } }; + let geometry = if !req.geometry.is_empty() { + req.geometry.clone() + } else { + st.enroll.read().await.get(&req.room_id).map(|re| re.geometry.clone()).unwrap_or_default() + }; let at = (unix_ms() / 1000) as i64; let bank = match SpecialistBank::train(&req.room_id, &baseline_id, &anchors, at) { Ok(b) => b, Err(e) => return (StatusCode::BAD_REQUEST, Json(serde_json::json!({"error": format!("training failed: {e}")}))).into_response(), }; + let bank = if geometry.is_empty() { + eprintln!( + "[calibrate-serve] no transceiver geometry recorded for room '{}' — bank will not support geometry conditioning (ADR-152 §2.1.2)", + req.room_id + ); + bank + } else { + bank.with_geometry(geometry) + }; let name = sanitize_room_id(&req.room_id); let dir = { st.status.read().await.output_dir.clone() }; let path = format!("{dir}/{name}.json"); @@ -777,10 +804,37 @@ async fn train_room(State(st): State, Json(req): Json) - "bank": name, // pass as ?bank= to /room/state "anchor_count": bank.anchor_count, "specialists": kinds, + "geometry_nodes": bank.geometry.len(), "path": path, }))).into_response() } +/// Body for `POST /api/v1/enroll/geometry`. +#[derive(Deserialize)] +struct EnrollGeometryBody { + room_id: String, + /// Per-node transceiver geometry records (ADR-152 §2.1.1). + geometry: Vec, +} + +/// Record the room's transceiver geometry (ADR-152 §2.1.1) into the in-server +/// enrollment; the next `POST /room/train` snapshots it into the bank. A later +/// POST supersedes an earlier one (latest wins), mirroring +/// `EnrollmentSession::record_geometry`. +async fn enroll_geometry(State(st): State, Json(b): Json) -> impl IntoResponse { + if b.geometry.is_empty() { + return (StatusCode::BAD_REQUEST, Json(serde_json::json!({"error":"geometry must be a non-empty array of NodeGeometry records"}))).into_response(); + } + let nodes = b.geometry.len(); + { + let mut map = st.enroll.write().await; + let re = map.entry(b.room_id.clone()).or_insert_with(RoomEnroll::default); + re.geometry = b.geometry; + } + eprintln!("[calibrate-serve] enroll geometry room={} nodes={nodes}", b.room_id); + (StatusCode::OK, Json(serde_json::json!({"room_id": b.room_id, "geometry_nodes": nodes}))).into_response() +} + /// Body for `POST /api/v1/enroll/anchor`. #[derive(Deserialize)] struct EnrollAnchorBody { @@ -1086,6 +1140,59 @@ mod tests { ); } + /// ADR-152 §2.1.1: geometry threads into the trained bank through both API + /// paths — inline in the train request, or recorded via /enroll/geometry — + /// and a geometry-free train still produces a valid (unconditioned) bank. + #[tokio::test] + async fn train_threads_geometry_into_bank() { + let dir = tempfile::tempdir().unwrap(); + let app = build_router(test_state(dir.path().to_str().unwrap())); + let anchors = r#"[ + {"room_id":"g","label":"empty","features":{"mean":1.0,"variance":1.0,"motion":0.1,"breathing_score":0.0,"breathing_hz":0.0,"heart_score":0.0,"heart_hz":0.0}}, + {"room_id":"g","label":"stand_still","features":{"mean":1.0,"variance":10.0,"motion":0.2,"breathing_score":0.0,"breathing_hz":0.0,"heart_score":0.0,"heart_hz":0.0}} + ]"#; + let load_bank = |name: &str| { + let raw = std::fs::read_to_string(dir.path().join(format!("{name}.json"))).unwrap(); + SpecialistBank::from_json(&raw).unwrap() + }; + + // (1) geometry inline in the train request. + let body = format!( + r#"{{"room_id":"g1","baseline_id":"b","anchors":{anchors}, + "geometry":[{{"node_id":1,"position":{{"x_m":0.0,"y_m":0.0,"z_m":1.0}},"method":"tape-measure"}},{{"node_id":2}}]}}"# + ); + assert_eq!(req(app.clone(), "POST", "/api/v1/room/train", Some(&body)).await, StatusCode::OK); + let bank = load_bank("g1"); + assert_eq!(bank.geometry.len(), 2); + assert_eq!(bank.geometry[0].method, "tape-measure"); + assert_eq!(bank.geometry[1].node_id, 2); + + // (2) geometry recorded via /enroll/geometry; train body omits it. + assert_eq!( + req(app.clone(), "POST", "/api/v1/enroll/geometry", + Some(r#"{"room_id":"g2","geometry":[{"node_id":7,"method":"floor-plan"}]}"#)).await, + StatusCode::OK + ); + let body2 = format!(r#"{{"room_id":"g2","baseline_id":"b","anchors":{anchors}}}"#); + assert_eq!(req(app.clone(), "POST", "/api/v1/room/train", Some(&body2)).await, StatusCode::OK); + let bank2 = load_bank("g2"); + assert_eq!(bank2.geometry.len(), 1); + assert_eq!(bank2.geometry[0].node_id, 7); + + // (3) no geometry anywhere → valid geometry-free bank (note logged). + let body3 = format!(r#"{{"room_id":"g3","baseline_id":"b","anchors":{anchors}}}"#); + assert_eq!(req(app.clone(), "POST", "/api/v1/room/train", Some(&body3)).await, StatusCode::OK); + let bank3 = load_bank("g3"); + assert!(bank3.geometry.is_empty()); + assert!(bank3.presence.is_some(), "bank still trains without geometry"); + + // (4) empty geometry array is rejected. + assert_eq!( + req(app, "POST", "/api/v1/enroll/geometry", Some(r#"{"room_id":"g4","geometry":[]}"#)).await, + StatusCode::BAD_REQUEST + ); + } + #[tokio::test] async fn enroll_status_empty_and_bad_label() { let dir = tempfile::tempdir().unwrap(); diff --git a/v2/crates/wifi-densepose-cli/src/room.rs b/v2/crates/wifi-densepose-cli/src/room.rs index 1f010eae..bf60e4b6 100644 --- a/v2/crates/wifi-densepose-cli/src/room.rs +++ b/v2/crates/wifi-densepose-cli/src/room.rs @@ -11,7 +11,7 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use tokio::net::UdpSocket; use wifi_densepose_calibration::{ Anchor, AnchorLabel, AnchorQualityGate, AnchorRecorder, EnrollmentEvent, EnrollmentSession, - MixtureOfSpecialists, MultiNodeMixture, SpecialistBank, + MixtureOfSpecialists, MultiNodeMixture, NodeGeometry, SpecialistBank, }; use wifi_densepose_calibration::extract::{AnchorFeature, Features}; use wifi_densepose_core::types::CsiFrame; @@ -226,20 +226,50 @@ pub struct TrainRoomArgs { /// Output specialist-bank file. #[arg(long, default_value = "./room-bank.json")] pub output: String, + /// Optional transceiver-geometry file: a JSON array of `NodeGeometry` + /// records (ADR-152 §2.1.1). Recorded into the enrollment session before + /// training so the bank carries the layout it was trained under. + #[arg(long)] + pub geometry: Option, } /// Execute `train-room`. +/// +/// If the enrollment session carries a transceiver-geometry snapshot (recorded +/// at enroll time or supplied here via `--geometry`), it is threaded into the +/// bank (ADR-152 §2.1.1); a geometry-free enrollment still trains a valid bank. pub async fn train_room(args: TrainRoomArgs) -> Result<()> { let raw = std::fs::read_to_string(&args.enrollment) .map_err(|e| anyhow::anyhow!("cannot read {}: {e} — run `enroll` first", args.enrollment))?; - let data: EnrollmentData = + let mut data: EnrollmentData = serde_json::from_str(&raw).map_err(|e| anyhow::anyhow!("invalid enrollment: {e}"))?; if data.anchors.is_empty() { bail!("no accepted anchors in {} — re-run enroll", args.enrollment); } - let bank = SpecialistBank::train(&data.room_id, &data.baseline_id, &data.anchors, now_unix()) + if let Some(path) = &args.geometry { + let graw = std::fs::read_to_string(path) + .map_err(|e| anyhow::anyhow!("cannot read geometry {path}: {e}"))?; + let geometry: Vec = serde_json::from_str(&graw).map_err(|e| { + anyhow::anyhow!("invalid geometry {path}: {e} (expected a JSON array of NodeGeometry records)") + })?; + data.session.record_geometry(geometry, now_unix()); + } + + let mut bank = SpecialistBank::train(&data.room_id, &data.baseline_id, &data.anchors, now_unix()) .map_err(|e| anyhow::anyhow!("training failed: {e}"))?; + match data.session.geometry() { + Some(g) if !g.is_empty() => { + bank = bank.with_geometry(g.to_vec()); + eprintln!( + "[train-room] geometry: {} node(s) snapshotted into the bank (ADR-152 §2.1.1)", + bank.geometry.len() + ); + } + _ => eprintln!( + "[train-room] no transceiver geometry recorded — bank will not support geometry conditioning (ADR-152 §2.1.2)" + ), + } std::fs::write(&args.output, bank.to_json().map_err(|e| anyhow::anyhow!("{e}"))?) .map_err(|e| anyhow::anyhow!("cannot write {}: {e}", args.output))?; @@ -456,3 +486,141 @@ async fn room_watch_multi(args: RoomWatchArgs) -> Result<()> { } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + fn feature(label: AnchorLabel, variance: f32, motion: f32) -> AnchorFeature { + AnchorFeature { + room_id: "t".into(), + label, + features: Features { + mean: 1.0, + variance, + motion, + breathing_score: 0.0, + breathing_hz: 0.0, + heart_score: 0.0, + heart_hz: 0.0, + }, + } + } + + /// Write a minimal valid enrollment file (two anchors, no geometry event). + fn write_enrollment(dir: &std::path::Path) -> String { + let data = EnrollmentData { + room_id: "t".into(), + baseline_id: "base-1".into(), + fs_hz: 15.0, + anchors: vec![ + feature(AnchorLabel::Empty, 1.0, 0.1), + feature(AnchorLabel::StandStill, 10.0, 0.2), + ], + session: EnrollmentSession::new("t", "base-1", 1000), + }; + let path = dir.join("enrollment.json"); + std::fs::write(&path, serde_json::to_string(&data).unwrap()).unwrap(); + path.to_string_lossy().into_owned() + } + + fn trained_bank(out: &std::path::Path) -> SpecialistBank { + SpecialistBank::from_json(&std::fs::read_to_string(out).unwrap()).unwrap() + } + + /// ADR-152 §2.1.1: `--geometry` records into the session and the bank + /// snapshots it — enrollment geometry reaches the trained bank. + #[tokio::test] + async fn train_room_threads_geometry_when_provided() { + let dir = tempfile::tempdir().unwrap(); + let enrollment = write_enrollment(dir.path()); + let geometry = vec![ + NodeGeometry::new(1, "tape-measure").with_position(0.0, 0.0, 1.0), + NodeGeometry::unknown(2), + ]; + let gpath = dir.path().join("geometry.json"); + std::fs::write(&gpath, serde_json::to_string(&geometry).unwrap()).unwrap(); + let out = dir.path().join("bank.json"); + + train_room(TrainRoomArgs { + enrollment, + output: out.to_string_lossy().into_owned(), + geometry: Some(gpath.to_string_lossy().into_owned()), + }) + .await + .unwrap(); + + assert_eq!(trained_bank(&out).geometry, geometry); + } + + /// A geometry-free enrollment still trains a valid bank (optional by + /// design) — it just carries no snapshot. + #[tokio::test] + async fn train_room_without_geometry_yields_geometry_free_bank() { + let dir = tempfile::tempdir().unwrap(); + let enrollment = write_enrollment(dir.path()); + let out = dir.path().join("bank.json"); + + train_room(TrainRoomArgs { + enrollment, + output: out.to_string_lossy().into_owned(), + geometry: None, + }) + .await + .unwrap(); + + let bank = trained_bank(&out); + assert!(bank.geometry.is_empty()); + assert!(bank.presence.is_some(), "bank still trains without geometry"); + } + + /// Geometry recorded at enroll time (in the session event log) is picked up + /// without the `--geometry` flag. + #[tokio::test] + async fn train_room_uses_session_geometry() { + let dir = tempfile::tempdir().unwrap(); + let geometry = vec![NodeGeometry::new(3, "floor-plan").with_position(1.0, 2.0, 1.5)]; + let mut session = EnrollmentSession::new("t", "base-1", 1000); + session.record_geometry(geometry.clone(), 1000); + let data = EnrollmentData { + room_id: "t".into(), + baseline_id: "base-1".into(), + fs_hz: 15.0, + anchors: vec![ + feature(AnchorLabel::Empty, 1.0, 0.1), + feature(AnchorLabel::StandStill, 10.0, 0.2), + ], + session, + }; + let epath = dir.path().join("enrollment.json"); + std::fs::write(&epath, serde_json::to_string(&data).unwrap()).unwrap(); + let out = dir.path().join("bank.json"); + + train_room(TrainRoomArgs { + enrollment: epath.to_string_lossy().into_owned(), + output: out.to_string_lossy().into_owned(), + geometry: None, + }) + .await + .unwrap(); + + assert_eq!(trained_bank(&out).geometry, geometry); + } + + #[tokio::test] + async fn train_room_rejects_invalid_geometry_file() { + let dir = tempfile::tempdir().unwrap(); + let enrollment = write_enrollment(dir.path()); + let gpath = dir.path().join("geometry.json"); + std::fs::write(&gpath, r#"{"not":"an array"}"#).unwrap(); + + let err = train_room(TrainRoomArgs { + enrollment, + output: dir.path().join("bank.json").to_string_lossy().into_owned(), + geometry: Some(gpath.to_string_lossy().into_owned()), + }) + .await + .unwrap_err(); + assert!(err.to_string().contains("invalid geometry"), "{err}"); + } +} diff --git a/v2/crates/wifi-densepose-hardware/src/ieee80211bf/events.rs b/v2/crates/wifi-densepose-hardware/src/ieee80211bf/events.rs new file mode 100644 index 00000000..c366e52d --- /dev/null +++ b/v2/crates/wifi-densepose-hardware/src/ieee80211bf/events.rs @@ -0,0 +1,108 @@ +//! Session FSM I/O types for the 802.11bf sensing model: events in +//! ([`SessionEvent`]), actions out ([`Action`]), close reasons, static +//! configuration, and the state enum. +//! +//! Split from [`super::session`] to keep each file under the ADR-153 +//! 500-line maintainability cap; the canonical public path re-exports +//! these from [`super::session`]. + +use super::messages::{ + CsiReportPayload, SbpRequest, SbpResponse, SbpStatus, SensingMeasurementInstance, + SensingMeasurementReport, SensingMeasurementSetupRequest, SensingMeasurementSetupResponse, + SensingSessionTermination, TerminationReason, +}; +use super::types::{MeasurementInstanceId, SensingCapabilities, SetupStatus, SpecProfile}; + +/// Session FSM states. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SessionState { + Idle, + SetupNegotiating, + Active, + Terminating, +} + +/// Inputs to the session FSM. `Start*` are local commands; `*Received` are +/// frames from the peer; `Timeout`/`InstanceElapsed` are scheduler ticks. +#[derive(Debug, Clone, PartialEq)] +pub enum SessionEvent { + /// Local command (initiator): begin setup negotiation. + StartSetup(SensingMeasurementSetupRequest), + /// Local command (initiator): request sensing-by-proxy from an AP. + StartSbp(SbpRequest), + SetupRequestReceived(SensingMeasurementSetupRequest), + SetupResponseReceived(SensingMeasurementSetupResponse), + SbpRequestReceived(SbpRequest), + SbpResponseReceived(SbpResponse), + /// Scheduler tick: the negotiated periodicity elapsed (the + /// measurement-driving endpoint — initiator or SBP proxy — emits the + /// next measurement-instance trigger). + InstanceElapsed, + /// A sensing receiver captured a measurement for an instance (payload is + /// fed by the transport/bridge — see `OpportunisticCsiBridge`). + MeasurementCaptured { + instance_id: MeasurementInstanceId, + payload: CsiReportPayload, + }, + ReportReceived(SensingMeasurementReport), + /// Generic timeout tick for the current state. + Timeout, + /// Local command: terminate the session. + Terminate(TerminationReason), + TerminationReceived(SensingSessionTermination), +} + +/// Outputs of the session FSM. `Send*`/`TriggerInstance`/`RelaySbpReport` +/// go to the transport; `DeliverReport`/`SessionClosed` go to the local +/// consumer. +#[derive(Debug, Clone, PartialEq)] +pub enum Action { + SendSetupRequest(SensingMeasurementSetupRequest), + SendSetupResponse(SensingMeasurementSetupResponse), + SendSbpRequest(SbpRequest), + SendSbpResponse(SbpResponse), + TriggerInstance(SensingMeasurementInstance), + SendReport(SensingMeasurementReport), + DeliverReport(SensingMeasurementReport), + /// SBP proxy mode: forward a report received from the sensing responder + /// to the SBP client. The transport maps this to a frame toward the + /// client (`SensingFrame::SbpReport`), distinct from `SendReport`, + /// which travels toward the sensing initiator. + RelaySbpReport(SensingMeasurementReport), + SendTermination(SensingSessionTermination), + SessionClosed(CloseReason), +} + +/// Why a session returned to Idle. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum CloseReason { + SetupRejected(SetupStatus), + SbpRejected(SbpStatus), + Terminated(TerminationReason), + /// Terminating-state quiescence completed (no peer echo required). + Completed, +} + +/// Static configuration for a sensing session. +#[derive(Debug, Clone, PartialEq)] +pub struct SessionConfig { + /// Spec profile this endpoint advertises/accepts. + pub profile: SpecProfile, + /// Capability set used to evaluate inbound setups. + pub capabilities: SensingCapabilities, + /// Consecutive negotiation timeouts before aborting to Idle. + pub max_setup_timeouts: u8, + /// Consecutive missed instances (Active timeouts) before terminating. + pub max_missed_instances: u8, +} + +impl Default for SessionConfig { + fn default() -> Self { + Self { + profile: SpecProfile::Ieee80211Bf2025, + capabilities: SensingCapabilities::sim_full(), + max_setup_timeouts: 3, + max_missed_instances: 5, + } + } +} diff --git a/v2/crates/wifi-densepose-hardware/src/ieee80211bf/messages.rs b/v2/crates/wifi-densepose-hardware/src/ieee80211bf/messages.rs index 4b4cab98..b78231ad 100644 --- a/v2/crates/wifi-densepose-hardware/src/ieee80211bf/messages.rs +++ b/v2/crates/wifi-densepose-hardware/src/ieee80211bf/messages.rs @@ -125,12 +125,38 @@ impl SbpRequest { } /// Status carried by an SBP response. +/// +/// Mirrors [`SetupStatus`] 1:1 (see the `From` impl): an SBP +/// request is validated through the same chain as a direct setup, so every +/// rejection class must survive the proxy translation. +/// `RejectedNotSupported` additionally covers a proxy that lacks the SBP +/// capability itself. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum SbpStatus { Accepted, RejectedNotSupported, RejectedUnsupportedParams, + RejectedSetupIdCollision, + RejectedIncompatibleProfile, RejectedByPolicy, + RejectedCapacity, +} + +impl From for SbpStatus { + /// 1:1 mapping from the direct-setup status space, keeping the SBP path + /// on the single `evaluate_setup` validation chain (no SBP-only policy + /// drift or bypass). + fn from(status: SetupStatus) -> Self { + match status { + SetupStatus::Accepted => SbpStatus::Accepted, + SetupStatus::RejectedNotSupported => SbpStatus::RejectedNotSupported, + SetupStatus::RejectedUnsupportedParams => SbpStatus::RejectedUnsupportedParams, + SetupStatus::RejectedSetupIdCollision => SbpStatus::RejectedSetupIdCollision, + SetupStatus::RejectedIncompatibleProfile => SbpStatus::RejectedIncompatibleProfile, + SetupStatus::RejectedByPolicy => SbpStatus::RejectedByPolicy, + SetupStatus::RejectedCapacity => SbpStatus::RejectedCapacity, + } + } } /// Sensing-by-Proxy (SBP) response (proxy AP → requesting STA). diff --git a/v2/crates/wifi-densepose-hardware/src/ieee80211bf/mod.rs b/v2/crates/wifi-densepose-hardware/src/ieee80211bf/mod.rs index 9845dee4..0e23bdc5 100644 --- a/v2/crates/wifi-densepose-hardware/src/ieee80211bf/mod.rs +++ b/v2/crates/wifi-densepose-hardware/src/ieee80211bf/mod.rs @@ -33,12 +33,17 @@ //! measurement instance, CSI-variant report, SBP exchange, termination). //! - [`session`] — deterministic event-driven session FSM: //! `Idle → SetupNegotiating → Active → Terminating → Idle`, with explicit -//! rejection paths and timeout handling. No async, no clocks. +//! rejection paths, timeout handling, single-role enforcement, and the +//! first-class SBP proxy mode. No async, no clocks. +//! - [`events`] — the FSM I/O types ([`events::SessionEvent`], +//! [`events::Action`], close reasons, configuration), re-exported via +//! [`session`]. //! - [`table`] — responder-side setup registry (setup-ID collision and -//! capacity rejection paths). +//! capacity rejection paths, for direct setups and SBP alike). //! - [`transport`] — the [`transport::SensingTransport`] seam, the //! [`transport::SimTransport`] test double, and the ESP32 bridge. +pub mod events; pub mod messages; pub mod session; pub mod table; @@ -68,4 +73,6 @@ mod tests; #[cfg(test)] mod tests_fsm; #[cfg(test)] +mod tests_sbp; +#[cfg(test)] mod testutil; diff --git a/v2/crates/wifi-densepose-hardware/src/ieee80211bf/session.rs b/v2/crates/wifi-densepose-hardware/src/ieee80211bf/session.rs index 4b6f92a7..d6376e9d 100644 --- a/v2/crates/wifi-densepose-hardware/src/ieee80211bf/session.rs +++ b/v2/crates/wifi-densepose-hardware/src/ieee80211bf/session.rs @@ -12,103 +12,38 @@ //! (responder responds with a rejected setup status), setup-ID collision //! ([`super::table::SessionTable`]), and negotiation timeout (typed //! [`BfError::NegotiationTimeout`] + reset to Idle). +//! +//! **Single-role design:** a session is constructed as initiator or responder +//! and keeps that role for its whole lifetime. An initiator-role session +//! receiving a peer's setup or SBP request answers `RejectedNotSupported` +//! instead of accepting — a peer must never be able to hijack a session out +//! of its configured role. Endpoints that play both roles run one session per +//! role (or a [`super::table::SessionTable`] for the responder side). +//! +//! **SBP proxy mode:** a responder session that accepts an SBP request +//! becomes a first-class proxy ([`SensingSession::is_sbp_proxy`]): it drives +//! the standard initiator path toward the actual sensing responder — +//! including re-triggering measurement instances on +//! [`SessionEvent::InstanceElapsed`] — and relays every received report to +//! the SBP client via [`Action::RelaySbpReport`], in addition to local +//! [`Action::DeliverReport`] delivery. +//! +//! Local `Start*` commands issued outside Idle are caller bugs and surface +//! as typed [`BfError::InvalidStateForCommand`]; genuinely ignorable stray +//! frames/ticks remain silent no-ops. The FSM I/O types live in +//! [`super::events`] and are re-exported here. use super::messages::{ - CsiReportPayload, SbpRequest, SbpResponse, SbpStatus, SensingMeasurementInstance, - SensingMeasurementReport, SensingMeasurementSetupRequest, SensingMeasurementSetupResponse, - SensingSessionTermination, TerminationReason, + SbpRequest, SbpResponse, SbpStatus, SensingMeasurementInstance, SensingMeasurementReport, + SensingMeasurementSetupRequest, SensingMeasurementSetupResponse, SensingSessionTermination, + TerminationReason, }; use super::types::{ BfError, MeasurementInstanceId, MeasurementSetupId, MeasurementSetupParams, ReportingConfig, - SensingCapabilities, SensingRole, SetupStatus, SpecProfile, + SensingRole, SetupStatus, }; -/// Session FSM states. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum SessionState { - Idle, - SetupNegotiating, - Active, - Terminating, -} - -/// Inputs to the session FSM. `Start*` are local commands; `*Received` are -/// frames from the peer; `Timeout`/`InstanceElapsed` are scheduler ticks. -#[derive(Debug, Clone, PartialEq)] -pub enum SessionEvent { - /// Local command (initiator): begin setup negotiation. - StartSetup(SensingMeasurementSetupRequest), - /// Local command (initiator): request sensing-by-proxy from an AP. - StartSbp(SbpRequest), - SetupRequestReceived(SensingMeasurementSetupRequest), - SetupResponseReceived(SensingMeasurementSetupResponse), - SbpRequestReceived(SbpRequest), - SbpResponseReceived(SbpResponse), - /// Scheduler tick: the negotiated periodicity elapsed (initiator emits - /// the next measurement-instance trigger). - InstanceElapsed, - /// A sensing receiver captured a measurement for an instance (payload is - /// fed by the transport/bridge — see `OpportunisticCsiBridge`). - MeasurementCaptured { - instance_id: MeasurementInstanceId, - payload: CsiReportPayload, - }, - ReportReceived(SensingMeasurementReport), - /// Generic timeout tick for the current state. - Timeout, - /// Local command: terminate the session. - Terminate(TerminationReason), - TerminationReceived(SensingSessionTermination), -} - -/// Outputs of the session FSM. `Send*`/`TriggerInstance` go to the transport; -/// `DeliverReport`/`SessionClosed` go to the local consumer. -#[derive(Debug, Clone, PartialEq)] -pub enum Action { - SendSetupRequest(SensingMeasurementSetupRequest), - SendSetupResponse(SensingMeasurementSetupResponse), - SendSbpRequest(SbpRequest), - SendSbpResponse(SbpResponse), - TriggerInstance(SensingMeasurementInstance), - SendReport(SensingMeasurementReport), - DeliverReport(SensingMeasurementReport), - SendTermination(SensingSessionTermination), - SessionClosed(CloseReason), -} - -/// Why a session returned to Idle. -#[derive(Debug, Clone, Copy, PartialEq)] -pub enum CloseReason { - SetupRejected(SetupStatus), - SbpRejected(SbpStatus), - Terminated(TerminationReason), - /// Terminating-state quiescence completed (no peer echo required). - Completed, -} - -/// Static configuration for a sensing session. -#[derive(Debug, Clone, PartialEq)] -pub struct SessionConfig { - /// Spec profile this endpoint advertises/accepts. - pub profile: SpecProfile, - /// Capability set used to evaluate inbound setups. - pub capabilities: SensingCapabilities, - /// Consecutive negotiation timeouts before aborting to Idle. - pub max_setup_timeouts: u8, - /// Consecutive missed instances (Active timeouts) before terminating. - pub max_missed_instances: u8, -} - -impl Default for SessionConfig { - fn default() -> Self { - Self { - profile: SpecProfile::Ieee80211Bf2025, - capabilities: SensingCapabilities::sim_full(), - max_setup_timeouts: 3, - max_missed_instances: 5, - } - } -} +pub use super::events::{Action, CloseReason, SessionConfig, SessionEvent, SessionState}; /// One sensing session (one measurement setup) on one endpoint. #[derive(Debug, Clone)] @@ -122,6 +57,10 @@ pub struct SensingSession { setup: Option<(MeasurementSetupId, MeasurementSetupParams)>, /// True when this session awaits proxied sensing (SBP client). sbp_client: bool, + /// True when this responder-role session proxies sensing for an SBP + /// client: it drives the initiator path toward the sensing responder + /// and relays received reports back to the client. + sbp_proxy: bool, setup_timeouts: u8, missed_instances: u8, instance_counter: u32, @@ -146,6 +85,7 @@ impl SensingSession { pending_request: None, setup: None, sbp_client: false, + sbp_proxy: false, setup_timeouts: 0, missed_instances: 0, instance_counter: 0, @@ -161,13 +101,20 @@ impl SensingSession { self.role } + /// True when this session is acting as an SBP proxy (accepted via + /// [`SessionEvent::SbpRequestReceived`]); cleared on reset to Idle. + pub fn is_sbp_proxy(&self) -> bool { + self.sbp_proxy + } + pub fn setup_id(&self) -> Option { self.setup.as_ref().map(|(id, _)| *id) } /// Drive the FSM with one event. Protocol-level rejections surface as - /// `Ok` actions (responses to the peer); malformed/adversarial input and - /// negotiation timeout surface as typed `Err` (never a panic). + /// `Ok` actions (responses to the peer); malformed/adversarial input, + /// out-of-state local commands, and negotiation timeout surface as typed + /// `Err` (never a panic). pub fn handle(&mut self, event: SessionEvent) -> Result, BfError> { match self.state { SessionState::Idle => self.handle_idle(event), @@ -212,6 +159,13 @@ impl SensingSession { status, }) }; + // Single-role design (module docs): an initiator-role + // session never accepts a peer's setup request — accepting + // here would let a peer hijack the session into the + // responder path. + if self.role != SensingRole::Responder { + return Ok(vec![response(SetupStatus::RejectedNotSupported)]); + } match self.evaluate_setup(&req) { SetupStatus::Accepted => { self.setup = Some((req.setup_id, req.params.clone())); @@ -223,7 +177,16 @@ impl SensingSession { status => Ok(vec![response(status)]), } } - SessionEvent::SbpRequestReceived(sbp) => Ok(self.handle_sbp_request(sbp)), + SessionEvent::SbpRequestReceived(sbp) => { + // Single-role design: only responder-role sessions proxy. + if self.role != SensingRole::Responder { + return Ok(vec![Action::SendSbpResponse(SbpResponse { + proxy_setup_id: sbp.proxy_setup_id, + status: SbpStatus::RejectedNotSupported, + })]); + } + Ok(self.handle_sbp_request(sbp)) + } // Stray frames/ticks in Idle are ignored, not errors. _ => Ok(vec![]), } @@ -232,6 +195,12 @@ impl SensingSession { /// SBP proxy path: accept the request, then run the *standard initiator /// path* toward the actual sensing responder. No direct sensor coupling — /// the proxied setup is an ordinary `SendSetupRequest` on the transport. + /// + /// Validation is the single [`Self::evaluate_setup`] chain: the proxied + /// setup request is built first and evaluated exactly as a direct setup + /// would be, with the resulting [`SetupStatus`] mapped 1:1 onto + /// [`SbpStatus`] — no SBP-only re-implementation that could drift from + /// (or bypass) the setup policy. fn handle_sbp_request(&mut self, sbp: SbpRequest) -> Vec { let respond = |status| { Action::SendSbpResponse(SbpResponse { @@ -239,29 +208,22 @@ impl SensingSession { status, }) }; + // SBP-specific capability gate; everything else is the setup chain. if !self.config.capabilities.sensing_by_proxy { return vec![respond(SbpStatus::RejectedNotSupported)]; } - if !self.config.profile.accepts(&sbp.profile) { - return vec![respond(SbpStatus::RejectedUnsupportedParams)]; - } - match sbp.validate() { - Err(BfError::SensingDisabledByPolicy) => { - return vec![respond(SbpStatus::RejectedByPolicy)]; - } - Err(_) => return vec![respond(SbpStatus::RejectedUnsupportedParams)], - Ok(()) => {} - } - if self.config.capabilities.evaluate(&sbp.params).is_err() { - return vec![respond(SbpStatus::RejectedUnsupportedParams)]; - } let req = SensingMeasurementSetupRequest { profile: sbp.profile.clone(), setup_id: sbp.proxy_setup_id, params: sbp.params.clone(), }; + match self.evaluate_setup(&req) { + SetupStatus::Accepted => {} + status => return vec![respond(SbpStatus::from(status))], + } self.setup = Some((req.setup_id, req.params.clone())); self.pending_request = Some(req.clone()); + self.sbp_proxy = true; self.setup_timeouts = 0; self.state = SessionState::SetupNegotiating; vec![respond(SbpStatus::Accepted), Action::SendSetupRequest(req)] @@ -362,6 +324,13 @@ impl SensingSession { term.reason, ))]) } + // Local Start* outside Idle is a caller bug — typed error. + SessionEvent::StartSetup(_) | SessionEvent::StartSbp(_) => { + Err(BfError::InvalidStateForCommand { + state: "SetupNegotiating", + }) + } + // Genuinely ignorable stray frames/ticks are no-ops. _ => Ok(vec![]), } } @@ -369,7 +338,13 @@ impl SensingSession { fn handle_active(&mut self, event: SessionEvent) -> Result, BfError> { match event { SessionEvent::InstanceElapsed => { - if self.role == SensingRole::Initiator && !self.sbp_client { + // The measurement-driving endpoint re-triggers here: the + // initiator, or an SBP proxy running the initiator path + // toward the sensing responder. SBP *clients* only consume + // proxied reports and never trigger instances. + let drives_instances = + (self.role == SensingRole::Initiator || self.sbp_proxy) && !self.sbp_client; + if drives_instances { match self.next_instance_record() { Some(instance) => Ok(vec![Action::TriggerInstance(instance)]), None => Ok(vec![]), @@ -387,6 +362,11 @@ impl SensingSession { Some((id, p)) => (*id, p.clone()), None => return Ok(vec![]), }; + // A successful capture means this instance was not missed — + // the missed-instance budget counts *consecutive* misses, + // so it resets here even when threshold-based reporting + // suppresses the report below. + self.missed_instances = 0; let mean = payload.mean_amplitude(); let should_report = match params.reporting { ReportingConfig::EveryInstance => true, @@ -418,7 +398,16 @@ impl SensingSession { }); } self.missed_instances = 0; - Ok(vec![Action::DeliverReport(report)]) + if self.sbp_proxy { + // Proxy mode: deliver to the local consumer *and* relay + // toward the SBP client on the transport. + Ok(vec![ + Action::DeliverReport(report.clone()), + Action::RelaySbpReport(report), + ]) + } else { + Ok(vec![Action::DeliverReport(report)]) + } } SessionEvent::Timeout => { self.missed_instances = self.missed_instances.saturating_add(1); @@ -439,6 +428,12 @@ impl SensingSession { term.reason, ))]) } + // Local Start* outside Idle is a caller bug — typed error. + SessionEvent::StartSetup(_) | SessionEvent::StartSbp(_) => { + Err(BfError::InvalidStateForCommand { state: "Active" }) + } + // Genuinely ignorable stray frames (duplicate setup/SBP traffic) + // are no-ops. _ => Ok(vec![]), } } @@ -456,6 +451,12 @@ impl SensingSession { self.reset(); Ok(vec![Action::SessionClosed(CloseReason::Completed)]) } + // Local Start* outside Idle is a caller bug — typed error. + SessionEvent::StartSetup(_) | SessionEvent::StartSbp(_) => { + Err(BfError::InvalidStateForCommand { + state: "Terminating", + }) + } _ => Ok(vec![]), } } @@ -489,6 +490,7 @@ impl SensingSession { self.pending_request = None; self.setup = None; self.sbp_client = false; + self.sbp_proxy = false; self.setup_timeouts = 0; self.missed_instances = 0; self.instance_counter = 0; diff --git a/v2/crates/wifi-densepose-hardware/src/ieee80211bf/table.rs b/v2/crates/wifi-densepose-hardware/src/ieee80211bf/table.rs index 8b9967c4..064145b5 100644 --- a/v2/crates/wifi-densepose-hardware/src/ieee80211bf/table.rs +++ b/v2/crates/wifi-densepose-hardware/src/ieee80211bf/table.rs @@ -1,10 +1,16 @@ //! Responder-side setup registry for the 802.11bf sensing model — enforces //! the setup-ID-collision and capacity rejection paths a single session //! cannot see on its own (ADR-153 acceptance: duplicate setup ID rejected). +//! Both entry points — direct setups ([`SessionTable::handle_setup_request`]) +//! and sensing-by-proxy ([`SessionTable::handle_sbp_request`]) — share the +//! same guards and the same per-setup session storage. use std::collections::BTreeMap; -use super::messages::{SensingMeasurementSetupRequest, SensingMeasurementSetupResponse}; +use super::messages::{ + SbpRequest, SbpResponse, SbpStatus, SensingMeasurementSetupRequest, + SensingMeasurementSetupResponse, +}; use super::session::{Action, SensingSession, SessionConfig, SessionEvent, SessionState}; use super::types::{BfError, MeasurementSetupId, SetupStatus}; @@ -16,6 +22,9 @@ use super::types::{BfError, MeasurementSetupId, SetupStatus}; pub struct SessionTable { config: SessionConfig, sessions: BTreeMap, + /// Events dropped because no session owned the setup ID (see + /// [`Self::handle_for`]). + unknown_setup_drops: u64, } impl SessionTable { @@ -23,6 +32,7 @@ impl SessionTable { Self { config, sessions: BTreeMap::new(), + unknown_setup_drops: 0, } } @@ -38,6 +48,13 @@ impl SessionTable { self.sessions.get(&setup_id.value()) } + /// Count of events dropped by [`Self::handle_for`] because the setup ID + /// was unknown — lets an AP spot peers addressing setups it never + /// accepted without turning stray frames into errors. + pub fn unknown_setup_drops(&self) -> u64 { + self.unknown_setup_drops + } + /// Route an inbound setup request, rejecting setup-ID collisions and /// capacity overruns before delegating to a responder session. pub fn handle_setup_request( @@ -49,12 +66,10 @@ impl SessionTable { SensingMeasurementSetupResponse { setup_id, status }, )]) }; - if let Some(existing) = self.sessions.get(&req.setup_id.value()) { - if existing.state() != SessionState::Idle { - return reject(req.setup_id, SetupStatus::RejectedSetupIdCollision); - } + if self.is_collision(req.setup_id) { + return reject(req.setup_id, SetupStatus::RejectedSetupIdCollision); } - if self.active_setups() >= self.config.capabilities.max_active_setups as usize { + if self.at_capacity() { return reject(req.setup_id, SetupStatus::RejectedCapacity); } let key = req.setup_id.value(); @@ -64,8 +79,35 @@ impl SessionTable { Ok(actions) } - /// Route any other event to the session owning `setup_id` (no-op if the - /// setup is unknown — stray frames are ignored, not errors). + /// Route an inbound SBP request, rejecting proxy-setup-ID collisions and + /// capacity overruns before delegating to a (new) proxy session — the + /// SBP mirror of [`Self::handle_setup_request`], so a table-driven AP + /// accepts SBP end-to-end instead of silently dropping it. + pub fn handle_sbp_request(&mut self, sbp: SbpRequest) -> Result, BfError> { + let reject = |proxy_setup_id, status| { + Ok(vec![Action::SendSbpResponse(SbpResponse { + proxy_setup_id, + status, + })]) + }; + if self.is_collision(sbp.proxy_setup_id) { + return reject(sbp.proxy_setup_id, SbpStatus::RejectedSetupIdCollision); + } + if self.at_capacity() { + return reject(sbp.proxy_setup_id, SbpStatus::RejectedCapacity); + } + let key = sbp.proxy_setup_id.value(); + let mut session = SensingSession::new_responder(self.config.clone()); + let actions = session.handle(SessionEvent::SbpRequestReceived(sbp))?; + self.sessions.insert(key, session); + Ok(actions) + } + + /// Route any other event to the session owning `setup_id`. + /// + /// Frames addressing an unknown setup are dropped *by design* (stray + /// frames are ignored, not errors), but the drop is observable through + /// [`Self::unknown_setup_drops`]. pub fn handle_for( &mut self, setup_id: MeasurementSetupId, @@ -73,7 +115,22 @@ impl SessionTable { ) -> Result, BfError> { match self.sessions.get_mut(&setup_id.value()) { Some(session) => session.handle(event), - None => Ok(vec![]), + None => { + self.unknown_setup_drops = self.unknown_setup_drops.saturating_add(1); + Ok(vec![]) + } } } + + /// A non-Idle session already owns this setup ID. + fn is_collision(&self, setup_id: MeasurementSetupId) -> bool { + self.sessions + .get(&setup_id.value()) + .is_some_and(|existing| existing.state() != SessionState::Idle) + } + + /// The active-setup budget is exhausted. + fn at_capacity(&self) -> bool { + self.active_setups() >= self.config.capabilities.max_active_setups as usize + } } diff --git a/v2/crates/wifi-densepose-hardware/src/ieee80211bf/tests_fsm.rs b/v2/crates/wifi-densepose-hardware/src/ieee80211bf/tests_fsm.rs index 443d5397..ae698d63 100644 --- a/v2/crates/wifi-densepose-hardware/src/ieee80211bf/tests_fsm.rs +++ b/v2/crates/wifi-densepose-hardware/src/ieee80211bf/tests_fsm.rs @@ -1,7 +1,8 @@ //! ADR-153 acceptance tests — session FSM full cycle, rejection paths, -//! timeout handling, threshold-based reporting, SBP flows, and adversarial -//! no-panic coverage. Type/serde/transport/bridge tests live in -//! [`super::tests`]. All tests are hardware-free (simulation only). +//! timeout handling, threshold-based reporting, single-role enforcement, +//! and adversarial no-panic coverage. SBP flows live in [`super::tests_sbp`]; +//! type/serde/transport/bridge tests in [`super::tests`]. All tests are +//! hardware-free (simulation only). use super::messages::*; use super::session::{ @@ -300,77 +301,72 @@ fn threshold_report_emitted_only_when_threshold_crossed() { assert!(responder.handle(capture(125.0)).unwrap().is_empty()); } -// ---------- SBP ---------- +// ---------- consecutive missed-instance semantics ---------- #[test] -fn sbp_proxy_request_maps_to_standard_responder_path() { - // Proxy AP: accepts the SBP request and initiates an ordinary setup - // toward the sensing responder — no direct sensor coupling. - let mut proxy = SensingSession::new_responder(SessionConfig::default()); - let sbp = SbpRequest { - profile: SpecProfile::Ieee80211Bf2025, - proxy_setup_id: MeasurementSetupId::new(11).unwrap(), - params: params(), +fn missed_instance_budget_is_consecutive_not_cumulative() { + // Review finding 2: a successful measurement must reset the + // missed-instance counter — `max_missed_instances` bounds *consecutive* + // misses (as documented on SessionConfig), not cumulative ones. + let mut responder = SensingSession::new_responder(SessionConfig::default()); // 5 missed max + responder + .handle(SessionEvent::SetupRequestReceived(setup_request(2))) + .unwrap(); + assert_eq!(responder.state(), SessionState::Active); + let capture = || SessionEvent::MeasurementCaptured { + instance_id: MeasurementInstanceId::new(0), + payload: payload(10.0), }; - let actions = proxy.handle(SessionEvent::SbpRequestReceived(sbp)).unwrap(); - let forwarded = match &actions[..] { - [Action::SendSbpResponse(SbpResponse { - status: SbpStatus::Accepted, - .. - }), Action::SendSetupRequest(req)] => req.clone(), - other => panic!("expected SBP accept + setup request, got {other:?}"), - }; - assert_eq!(proxy.state(), SessionState::SetupNegotiating); - assert_eq!(forwarded.setup_id.value(), 11); - // The forwarded request drives a *normal* responder session. - let mut responder = SensingSession::new_responder(SessionConfig::default()); - let actions = responder - .handle(SessionEvent::SetupRequestReceived(forwarded)) - .unwrap(); - let resp = match &actions[..] { - [Action::SendSetupResponse(r)] => *r, - other => panic!("expected accept, got {other:?}"), - }; - assert_eq!(resp.status, SetupStatus::Accepted); - proxy - .handle(SessionEvent::SetupResponseReceived(resp)) - .unwrap(); - assert_eq!(proxy.state(), SessionState::Active); + // Miss 4, then succeed once... + for _ in 0..4 { + assert!(responder.handle(SessionEvent::Timeout).unwrap().is_empty()); + } + let actions = responder.handle(capture()).unwrap(); + assert!(matches!(actions[..], [Action::SendReport(_)])); + + // ...so 4 more misses still leave the session alive. + for _ in 0..4 { + assert!(responder.handle(SessionEvent::Timeout).unwrap().is_empty()); + assert_eq!(responder.state(), SessionState::Active); + } + // The 5th consecutive miss terminates. + let actions = responder.handle(SessionEvent::Timeout).unwrap(); + assert!(matches!( + actions[..], + [Action::SendTermination(SensingSessionTermination { + reason: TerminationReason::Timeout, + .. + })] + )); + assert_eq!(responder.state(), SessionState::Terminating); } +// ---------- single-role enforcement & out-of-state commands ---------- + #[test] -fn sbp_client_flow_and_rejections() { - let mut client = SensingSession::new_initiator(SessionConfig::default()); +fn initiator_role_session_rejects_inbound_setup_and_sbp_requests() { + // Review finding 4a: single-role design — a peer must not be able to + // hijack an initiator-role session into the responder path. + let mut initiator = SensingSession::new_initiator(SessionConfig::default()); + let actions = initiator + .handle(SessionEvent::SetupRequestReceived(setup_request(3))) + .unwrap(); + assert!(matches!( + actions[..], + [Action::SendSetupResponse(SensingMeasurementSetupResponse { + status: SetupStatus::RejectedNotSupported, + .. + })] + )); + assert_eq!(initiator.state(), SessionState::Idle); + let sbp = SbpRequest { profile: SpecProfile::Ieee80211Bf2025, - proxy_setup_id: MeasurementSetupId::new(12).unwrap(), + proxy_setup_id: MeasurementSetupId::new(4).unwrap(), params: params(), }; - let actions = client.handle(SessionEvent::StartSbp(sbp.clone())).unwrap(); - assert!(matches!(actions[..], [Action::SendSbpRequest(_)])); - let accept = SbpResponse { - proxy_setup_id: sbp.proxy_setup_id, - status: SbpStatus::Accepted, - }; - client - .handle(SessionEvent::SbpResponseReceived(accept)) - .unwrap(); - assert_eq!(client.state(), SessionState::Active); - // Proxied report is delivered to the local consumer. - let report = SensingMeasurementReport { - setup_id: sbp.proxy_setup_id, - instance_id: MeasurementInstanceId::new(0), - payload: payload(1.0), - }; - let actions = client.handle(SessionEvent::ReportReceived(report)).unwrap(); - assert!(matches!(actions[..], [Action::DeliverReport(_)])); - - // A proxy without SBP capability rejects. - let mut cfg = SessionConfig::default(); - cfg.capabilities.sensing_by_proxy = false; - let mut no_sbp = SensingSession::new_responder(cfg); - let actions = no_sbp + let actions = initiator .handle(SessionEvent::SbpRequestReceived(sbp)) .unwrap(); assert!(matches!( @@ -380,7 +376,59 @@ fn sbp_client_flow_and_rejections() { .. })] )); - assert_eq!(no_sbp.state(), SessionState::Idle); + assert_eq!(initiator.state(), SessionState::Idle); + assert!(!initiator.is_sbp_proxy()); +} + +#[test] +fn local_start_commands_error_outside_idle() { + // Review finding 4b: StartSetup/StartSbp outside Idle are caller bugs + // and must surface as typed errors, not silent no-ops. + let sbp = SbpRequest { + profile: SpecProfile::Ieee80211Bf2025, + proxy_setup_id: MeasurementSetupId::new(13).unwrap(), + params: params(), + }; + let start_err = |s: &mut SensingSession, expected: SessionState| { + assert!(matches!( + s.handle(SessionEvent::StartSetup(setup_request(8))), + Err(BfError::InvalidStateForCommand { .. }) + )); + assert!(matches!( + s.handle(SessionEvent::StartSbp(sbp.clone())), + Err(BfError::InvalidStateForCommand { .. }) + )); + // The rejected commands must not disturb the session. + assert_eq!(s.state(), expected); + }; + + let mut s = SensingSession::new_initiator(SessionConfig::default()); + s.handle(SessionEvent::StartSetup(setup_request(7))) + .unwrap(); + start_err(&mut s, SessionState::SetupNegotiating); + + s.handle(SessionEvent::SetupResponseReceived( + SensingMeasurementSetupResponse { + setup_id: MeasurementSetupId::new(7).unwrap(), + status: SetupStatus::Accepted, + }, + )) + .unwrap(); + start_err(&mut s, SessionState::Active); + // Genuinely ignorable stray frames remain no-ops in Active. + assert!(s + .handle(SessionEvent::SbpResponseReceived(SbpResponse { + proxy_setup_id: MeasurementSetupId::new(7).unwrap(), + status: SbpStatus::Accepted, + })) + .unwrap() + .is_empty()); + + s.handle(SessionEvent::Terminate( + TerminationReason::InitiatorRequested, + )) + .unwrap(); + start_err(&mut s, SessionState::Terminating); } // ---------- adversarial: no panics anywhere ---------- diff --git a/v2/crates/wifi-densepose-hardware/src/ieee80211bf/tests_sbp.rs b/v2/crates/wifi-densepose-hardware/src/ieee80211bf/tests_sbp.rs new file mode 100644 index 00000000..a80623ca --- /dev/null +++ b/v2/crates/wifi-densepose-hardware/src/ieee80211bf/tests_sbp.rs @@ -0,0 +1,338 @@ +//! ADR-153 sensing-by-proxy (SBP) acceptance tests — proxy lifecycle +//! (re-triggering + report relay), client flow, table-driven AP entry +//! point, and the single-validation-path status mapping. Other FSM tests +//! live in [`super::tests_fsm`]; type/serde/transport/bridge tests in +//! [`super::tests`]. All tests are hardware-free (simulation only). + +use super::messages::*; +use super::session::{ + Action, CloseReason, SensingSession, SessionConfig, SessionEvent, SessionState, +}; +use super::table::SessionTable; +use super::testutil::{params, payload}; +use super::transport::{action_to_frame, frame_to_event, SensingFrame}; +use super::types::*; +use crate::csi_frame::Bandwidth; + +fn sbp_request(id: u8) -> SbpRequest { + SbpRequest { + profile: SpecProfile::Ieee80211Bf2025, + proxy_setup_id: MeasurementSetupId::new(id).unwrap(), + params: params(), + } +} + +#[test] +fn sbp_proxy_request_maps_to_standard_responder_path() { + // Proxy AP: accepts the SBP request and initiates an ordinary setup + // toward the sensing responder — no direct sensor coupling. + let mut proxy = SensingSession::new_responder(SessionConfig::default()); + let actions = proxy + .handle(SessionEvent::SbpRequestReceived(sbp_request(11))) + .unwrap(); + let forwarded = match &actions[..] { + [Action::SendSbpResponse(SbpResponse { + status: SbpStatus::Accepted, + .. + }), Action::SendSetupRequest(req)] => req.clone(), + other => panic!("expected SBP accept + setup request, got {other:?}"), + }; + assert_eq!(proxy.state(), SessionState::SetupNegotiating); + assert_eq!(forwarded.setup_id.value(), 11); + + // The forwarded request drives a *normal* responder session. + let mut responder = SensingSession::new_responder(SessionConfig::default()); + let actions = responder + .handle(SessionEvent::SetupRequestReceived(forwarded)) + .unwrap(); + let resp = match &actions[..] { + [Action::SendSetupResponse(r)] => *r, + other => panic!("expected accept, got {other:?}"), + }; + assert_eq!(resp.status, SetupStatus::Accepted); + proxy + .handle(SessionEvent::SetupResponseReceived(resp)) + .unwrap(); + assert_eq!(proxy.state(), SessionState::Active); +} + +#[test] +fn sbp_client_flow_and_rejections() { + let mut client = SensingSession::new_initiator(SessionConfig::default()); + let sbp = sbp_request(12); + let actions = client.handle(SessionEvent::StartSbp(sbp.clone())).unwrap(); + assert!(matches!(actions[..], [Action::SendSbpRequest(_)])); + let accept = SbpResponse { + proxy_setup_id: sbp.proxy_setup_id, + status: SbpStatus::Accepted, + }; + client + .handle(SessionEvent::SbpResponseReceived(accept)) + .unwrap(); + assert_eq!(client.state(), SessionState::Active); + // Proxied report is delivered to the local consumer. + let report = SensingMeasurementReport { + setup_id: sbp.proxy_setup_id, + instance_id: MeasurementInstanceId::new(0), + payload: payload(1.0), + }; + let actions = client.handle(SessionEvent::ReportReceived(report)).unwrap(); + assert!(matches!(actions[..], [Action::DeliverReport(_)])); + + // A proxy without SBP capability rejects. + let mut cfg = SessionConfig::default(); + cfg.capabilities.sensing_by_proxy = false; + let mut no_sbp = SensingSession::new_responder(cfg); + let actions = no_sbp + .handle(SessionEvent::SbpRequestReceived(sbp)) + .unwrap(); + assert!(matches!( + actions[..], + [Action::SendSbpResponse(SbpResponse { + status: SbpStatus::RejectedNotSupported, + .. + })] + )); + assert_eq!(no_sbp.state(), SessionState::Idle); +} + +#[test] +fn sbp_proxy_full_lifecycle_retriggers_and_relays() { + // Review finding 1: the SBP proxy is a first-class mode — after the + // proxied setup is accepted it keeps driving measurement instances on + // InstanceElapsed (like an initiator) and relays every received report + // to the SBP client in addition to local delivery. + let mut proxy = SensingSession::new_responder(SessionConfig::default()); + + // Accept: SBP response to the client + proxied setup to the responder. + let actions = proxy + .handle(SessionEvent::SbpRequestReceived(sbp_request(21))) + .unwrap(); + let forwarded = match &actions[..] { + [Action::SendSbpResponse(SbpResponse { + status: SbpStatus::Accepted, + .. + }), Action::SendSetupRequest(req)] => req.clone(), + other => panic!("expected SBP accept + setup request, got {other:?}"), + }; + assert!(proxy.is_sbp_proxy()); + + // Responder accepts → proxy Active, instance 0 triggered. + let actions = proxy + .handle(SessionEvent::SetupResponseReceived( + SensingMeasurementSetupResponse { + setup_id: forwarded.setup_id, + status: SetupStatus::Accepted, + }, + )) + .unwrap(); + assert_eq!(proxy.state(), SessionState::Active); + match &actions[..] { + [Action::TriggerInstance(i)] => assert_eq!(i.instance_id.value(), 0), + other => panic!("expected instance 0 trigger, got {other:?}"), + } + + // InstanceElapsed re-triggers instance 1+ (proxy drives the schedule). + let actions = proxy.handle(SessionEvent::InstanceElapsed).unwrap(); + match &actions[..] { + [Action::TriggerInstance(i)] => assert_eq!(i.instance_id.value(), 1), + other => panic!("expected instance 1 trigger, got {other:?}"), + } + + // A report from the sensing responder is delivered locally AND relayed. + let report = SensingMeasurementReport { + setup_id: forwarded.setup_id, + instance_id: MeasurementInstanceId::new(1), + payload: payload(5.0), + }; + let actions = proxy + .handle(SessionEvent::ReportReceived(report.clone())) + .unwrap(); + assert_eq!( + actions, + vec![ + Action::DeliverReport(report.clone()), + Action::RelaySbpReport(report.clone()), + ] + ); + // The relay action maps to a frame toward the SBP client, which + // consumes it through the standard report path. + let frame = action_to_frame(&Action::RelaySbpReport(report.clone())).unwrap(); + assert_eq!(frame, SensingFrame::SbpReport(report.clone())); + assert_eq!( + frame_to_event(frame), + Some(SessionEvent::ReportReceived(report)) + ); + + // Terminate cleanly: notify the responder, quiesce back to Idle. + let actions = proxy + .handle(SessionEvent::Terminate( + TerminationReason::InitiatorRequested, + )) + .unwrap(); + assert!(matches!(actions[..], [Action::SendTermination(_)])); + assert_eq!(proxy.state(), SessionState::Terminating); + let actions = proxy.handle(SessionEvent::Timeout).unwrap(); + assert!(matches!( + actions[..], + [Action::SessionClosed(CloseReason::Completed)] + )); + assert_eq!(proxy.state(), SessionState::Idle); + assert!(!proxy.is_sbp_proxy()); +} + +#[test] +fn session_table_routes_sbp_end_to_end() { + // Review finding 3: the table has a first-class SBP entry point with + // the same collision/capacity guards as direct setups — a table-driven + // AP accepts SBP instead of silently dropping it. + let mut table = SessionTable::new(SessionConfig::default()); + let actions = table.handle_sbp_request(sbp_request(31)).unwrap(); + let forwarded = match &actions[..] { + [Action::SendSbpResponse(SbpResponse { + status: SbpStatus::Accepted, + .. + }), Action::SendSetupRequest(req)] => req.clone(), + other => panic!("expected SBP accept + setup request, got {other:?}"), + }; + let setup_id = forwarded.setup_id; + assert_eq!(table.active_setups(), 1); + assert!(table.session(setup_id).unwrap().is_sbp_proxy()); + + // Proxy-setup-ID collision while the first proxy is live. + let actions = table.handle_sbp_request(sbp_request(31)).unwrap(); + assert!(matches!( + actions[..], + [Action::SendSbpResponse(SbpResponse { + status: SbpStatus::RejectedSetupIdCollision, + .. + })] + )); + + // Drive the proxied negotiation to Active through the table. + let actions = table + .handle_for( + setup_id, + SessionEvent::SetupResponseReceived(SensingMeasurementSetupResponse { + setup_id, + status: SetupStatus::Accepted, + }), + ) + .unwrap(); + assert!(matches!(actions[..], [Action::TriggerInstance(_)])); + assert_eq!( + table.session(setup_id).unwrap().state(), + SessionState::Active + ); + + // Reports relay to the SBP client through the table-owned proxy. + let report = SensingMeasurementReport { + setup_id, + instance_id: MeasurementInstanceId::new(0), + payload: payload(2.0), + }; + let actions = table + .handle_for(setup_id, SessionEvent::ReportReceived(report.clone())) + .unwrap(); + assert!(actions.contains(&Action::RelaySbpReport(report))); + + // Capacity guard mirrors the direct-setup path. + let mut cfg = SessionConfig::default(); + cfg.capabilities.max_active_setups = 1; + let mut small = SessionTable::new(cfg); + small.handle_sbp_request(sbp_request(1)).unwrap(); + let actions = small.handle_sbp_request(sbp_request(2)).unwrap(); + assert!(matches!( + actions[..], + [Action::SendSbpResponse(SbpResponse { + status: SbpStatus::RejectedCapacity, + .. + })] + )); + + // Unknown-setup drops are observable, not silent (finding 3). + assert_eq!(table.unknown_setup_drops(), 0); + let actions = table + .handle_for(MeasurementSetupId::new(99).unwrap(), SessionEvent::Timeout) + .unwrap(); + assert!(actions.is_empty()); + assert_eq!(table.unknown_setup_drops(), 1); +} + +#[test] +fn sbp_validation_shares_setup_chain_with_one_to_one_status_mapping() { + // Review finding 5: SBP requests are validated by building the proxied + // setup request first and running it through the single evaluate_setup + // chain — statuses map 1:1, so no rejection class is folded away and no + // setup policy can be bypassed via SBP. + + // Incompatible profile now surfaces as its own status (the old + // duplicated SBP chain folded it into RejectedUnsupportedParams). + let mut cfg = SessionConfig::default(); + cfg.profile = SpecProfile::VendorExtension("acme".into()); + let mut proxy = SensingSession::new_responder(cfg); + let actions = proxy + .handle(SessionEvent::SbpRequestReceived(sbp_request(41))) + .unwrap(); + assert!(matches!( + actions[..], + [Action::SendSbpResponse(SbpResponse { + status: SbpStatus::RejectedIncompatibleProfile, + .. + })] + )); + + // Consent policy rejection passes through unchanged. + let mut proxy = SensingSession::new_responder(SessionConfig::default()); + let mut sbp = sbp_request(42); + sbp.params.consent = ConsentMode::Disabled; + let actions = proxy.handle(SessionEvent::SbpRequestReceived(sbp)).unwrap(); + assert!(matches!( + actions[..], + [Action::SendSbpResponse(SbpResponse { + status: SbpStatus::RejectedByPolicy, + .. + })] + )); + + // Capability rejection (bandwidth beyond the advertised maximum). + let mut cfg = SessionConfig::default(); + cfg.capabilities.max_bandwidth_mhz = 40; + let mut proxy = SensingSession::new_responder(cfg); + let mut sbp = sbp_request(43); + sbp.params.bandwidth = Bandwidth::Bw80; + let actions = proxy.handle(SessionEvent::SbpRequestReceived(sbp)).unwrap(); + assert!(matches!( + actions[..], + [Action::SendSbpResponse(SbpResponse { + status: SbpStatus::RejectedUnsupportedParams, + .. + })] + )); + + // The status translation itself is exhaustive and 1:1. + let pairs = [ + (SetupStatus::Accepted, SbpStatus::Accepted), + ( + SetupStatus::RejectedNotSupported, + SbpStatus::RejectedNotSupported, + ), + ( + SetupStatus::RejectedUnsupportedParams, + SbpStatus::RejectedUnsupportedParams, + ), + ( + SetupStatus::RejectedSetupIdCollision, + SbpStatus::RejectedSetupIdCollision, + ), + ( + SetupStatus::RejectedIncompatibleProfile, + SbpStatus::RejectedIncompatibleProfile, + ), + (SetupStatus::RejectedByPolicy, SbpStatus::RejectedByPolicy), + (SetupStatus::RejectedCapacity, SbpStatus::RejectedCapacity), + ]; + for (setup, sbp) in pairs { + assert_eq!(SbpStatus::from(setup), sbp); + } +} diff --git a/v2/crates/wifi-densepose-hardware/src/ieee80211bf/transport.rs b/v2/crates/wifi-densepose-hardware/src/ieee80211bf/transport.rs index b57a3652..07c014b4 100644 --- a/v2/crates/wifi-densepose-hardware/src/ieee80211bf/transport.rs +++ b/v2/crates/wifi-densepose-hardware/src/ieee80211bf/transport.rs @@ -39,6 +39,10 @@ pub enum SensingFrame { Report(SensingMeasurementReport), SbpRequest(SbpRequest), SbpResponse(SbpResponse), + /// Proxied measurement report forwarded by an SBP proxy toward its SBP + /// client ([`Action::RelaySbpReport`]) — distinct from [`Self::Report`], + /// which travels toward the sensing initiator. + SbpReport(SensingMeasurementReport), Termination(SensingSessionTermination), } @@ -106,6 +110,7 @@ pub fn action_to_frame(action: &Action) -> Option { Action::SendSbpResponse(resp) => Some(SensingFrame::SbpResponse(*resp)), Action::TriggerInstance(instance) => Some(SensingFrame::InstanceTrigger(*instance)), Action::SendReport(report) => Some(SensingFrame::Report(report.clone())), + Action::RelaySbpReport(report) => Some(SensingFrame::SbpReport(report.clone())), Action::SendTermination(term) => Some(SensingFrame::Termination(*term)), Action::DeliverReport(_) | Action::SessionClosed(_) => None, } @@ -122,6 +127,9 @@ pub fn frame_to_event(frame: SensingFrame) -> Option Some(E::SetupRequestReceived(req)), SensingFrame::SetupResponse(resp) => Some(E::SetupResponseReceived(resp)), SensingFrame::Report(report) => Some(E::ReportReceived(report)), + // The SBP client consumes proxied reports through the standard + // report path (its session is in sbp_client mode). + SensingFrame::SbpReport(report) => Some(E::ReportReceived(report)), SensingFrame::SbpRequest(req) => Some(E::SbpRequestReceived(req)), SensingFrame::SbpResponse(resp) => Some(E::SbpResponseReceived(resp)), SensingFrame::Termination(term) => Some(E::TerminationReceived(term)), diff --git a/v2/crates/wifi-densepose-hardware/src/ieee80211bf/types.rs b/v2/crates/wifi-densepose-hardware/src/ieee80211bf/types.rs index 9801ad76..2ec99051 100644 --- a/v2/crates/wifi-densepose-hardware/src/ieee80211bf/types.rs +++ b/v2/crates/wifi-densepose-hardware/src/ieee80211bf/types.rs @@ -386,6 +386,10 @@ impl SensingCapabilities { #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum SetupStatus { Accepted, + /// The receiving endpoint does not act as a sensing responder for this + /// request — e.g. an initiator-role session received a setup request + /// (single-role design, see [`crate::ieee80211bf::session`]). + RejectedNotSupported, RejectedUnsupportedParams, RejectedSetupIdCollision, RejectedIncompatibleProfile, diff --git a/v2/crates/wifi-densepose-train/src/error.rs b/v2/crates/wifi-densepose-train/src/error.rs index 2e44805d..3d2c2fcd 100644 --- a/v2/crates/wifi-densepose-train/src/error.rs +++ b/v2/crates/wifi-densepose-train/src/error.rs @@ -436,6 +436,18 @@ pub enum MaeError { crop: usize, }, + /// The mask ratio is not a finite value strictly inside `(0, 1)` — the + /// same rule as [`MaePretrainConfig::validate`]. A NaN ratio must never + /// silently mask zero patches, and ratios ≤ 0 / ≥ 1 degenerate to + /// all-visible / all-masked grids. + /// + /// [`MaePretrainConfig::validate`]: crate::mae::MaePretrainConfig::validate + #[error("Invalid mask ratio {ratio}: must be finite and strictly inside (0, 1)")] + InvalidMaskRatio { + /// The offending ratio. + ratio: f64, + }, + /// A NaN or ±inf CSI value was found; corrupted input must be cleaned /// upstream, never masked over. #[error("Non-finite CSI value {value} at (t={row}, sc={col})")] diff --git a/v2/crates/wifi-densepose-train/src/mae.rs b/v2/crates/wifi-densepose-train/src/mae.rs index dd026d65..366473bb 100644 --- a/v2/crates/wifi-densepose-train/src/mae.rs +++ b/v2/crates/wifi-densepose-train/src/mae.rs @@ -160,6 +160,13 @@ impl MaePretrainConfig { /// Patchify `window` and draw the deterministic random mask in one step, /// using `self.seed`. See [`patchify`] and [`random_mask`]. + /// + /// # Errors + /// + /// Everything [`patchify`] rejects, plus [`MaeError::InvalidMaskRatio`] + /// if `self.mask_ratio` is not finite or outside `(0, 1)` (the + /// [`Self::validate`] rule) — a NaN ratio must never silently mask zero + /// patches. pub fn mask_window( &self, window: &[f32], @@ -167,7 +174,7 @@ impl MaePretrainConfig { subc: usize, ) -> Result<(PatchGrid, MaskIndices), MaeError> { let grid = patchify(window, time, subc, self)?; - let mask = random_mask(grid.n_patches(), self.mask_ratio, self.seed); + let mask = random_mask(grid.n_patches(), self.mask_ratio, self.seed)?; Ok((grid, mask)) } } @@ -337,8 +344,18 @@ fn unpatchify_select(grid: &PatchGrid, keep: Option<&[usize]>, fill: f32) -> Vec /// ([`Xorshift64`]), so the same `(n_patches, mask_ratio, seed)` triple always /// yields the same mask. Both index lists are sorted ascending, disjoint, and /// together cover `0..n_patches`. -#[must_use] -pub fn random_mask(n_patches: usize, mask_ratio: f64, seed: u64) -> MaskIndices { +/// +/// # Errors +/// +/// [`MaeError::InvalidMaskRatio`] if `mask_ratio` is not finite or outside +/// the open interval `(0, 1)` — the same rule as +/// [`MaePretrainConfig::validate`]. Erroring (never clamping) keeps the +/// module's error-not-silent policy: a NaN ratio would otherwise silently +/// mask zero patches and a ratio ≥ 1 would mask everything. +pub fn random_mask(n_patches: usize, mask_ratio: f64, seed: u64) -> Result { + if !mask_ratio.is_finite() || mask_ratio <= 0.0 || mask_ratio >= 1.0 { + return Err(MaeError::InvalidMaskRatio { ratio: mask_ratio }); + } let n_masked = ((mask_ratio * n_patches as f64).round() as usize).min(n_patches); let mut order: Vec = (0..n_patches).collect(); let mut rng = Xorshift64::new(seed); @@ -350,7 +367,7 @@ pub fn random_mask(n_patches: usize, mask_ratio: f64, seed: u64) -> MaskIndices let mut visible: Vec = order[n_masked..].to_vec(); masked.sort_unstable(); visible.sort_unstable(); - MaskIndices { masked, visible } + Ok(MaskIndices { masked, visible }) } // --------------------------------------------------------------------------- diff --git a/v2/crates/wifi-densepose-train/src/model.rs b/v2/crates/wifi-densepose-train/src/model.rs index d06ebd01..ac575e6e 100644 --- a/v2/crates/wifi-densepose-train/src/model.rs +++ b/v2/crates/wifi-densepose-train/src/model.rs @@ -126,7 +126,15 @@ impl WiFiDensePoseModel { tch::no_grad(|| self.forward_impl(amplitude, phase, false)) } - /// Save model weights to a file (tch safetensors / .pt format). + /// Save model weights to a file. The tch `VarStore` dispatches the format + /// on the file extension: `.safetensors` → safetensors, anything else → + /// torch `.pt`. + /// + /// **Platform constraint:** prefer `.safetensors`. The `.pt` path + /// (`_save_parameters`/`_load_parameters`) is broken on Windows with + /// torch 2.11 (GenericDict internal assert on the load roundtrip — see + /// `wiflow_std/model.rs::save_and_load_roundtrip`), which is why + /// [`crate::trainer::Trainer`] writes `.safetensors` checkpoints. /// /// # Errors /// @@ -137,7 +145,8 @@ impl WiFiDensePoseModel { .map_err(|e| TrainError::training_step(format!("save failed: {e}"))) } - /// Load model weights from a file. + /// Load model weights from a file (format dispatched on extension; see + /// the `.pt`-on-Windows caveat on [`Self::save`]). /// /// # Errors /// @@ -983,7 +992,9 @@ mod tests { let mut model = WiFiDensePoseModel::new(&cfg, Device::Cpu); let tmp = tempdir().expect("tempdir"); - let path = tmp.path().join("weights.pt"); + // safetensors, not .pt: this torch build's .pt roundtrip is broken on + // Windows (torch 2.11 GenericDict internal assert). + let path = tmp.path().join("weights.safetensors"); model.save(&path).expect("save should succeed"); model.load(&path).expect("load should succeed"); diff --git a/v2/crates/wifi-densepose-train/src/trainer.rs b/v2/crates/wifi-densepose-train/src/trainer.rs index a022fed0..a9f8d670 100644 --- a/v2/crates/wifi-densepose-train/src/trainer.rs +++ b/v2/crates/wifi-densepose-train/src/trainer.rs @@ -286,7 +286,12 @@ impl Trainer { best_epoch = epoch; patience_counter = 0; - let ckpt_name = format!("best_epoch{epoch:04}_pck{val_pck:.4}.pt"); + // .safetensors, not .pt: VarStore dispatches the format on + // the extension, and this torch build's .pt + // _save_parameters/_load_parameters roundtrip is broken on + // Windows (torch 2.11 GenericDict internal assert — see + // wiflow_std/model.rs save_and_load_roundtrip). + let ckpt_name = format!("best_epoch{epoch:04}_pck{val_pck:.4}.safetensors"); let ckpt_path = self.config.checkpoint_dir.join(&ckpt_name); match self.model.save(&ckpt_path) { @@ -339,8 +344,8 @@ impl Trainer { } } - // Save final model regardless. - let final_ckpt = self.config.checkpoint_dir.join("final.pt"); + // Save final model regardless (.safetensors — see checkpoint note above). + let final_ckpt = self.config.checkpoint_dir.join("final.safetensors"); if let Err(e) = self.model.save(&final_ckpt) { warn!("Failed to save final model: {e}"); } @@ -413,7 +418,8 @@ impl Trainer { .load(path) .map_err(|e| TrainError::checkpoint(e.to_string(), path))?; - // Try to parse the epoch from the filename (e.g. "best_epoch0042_pck0.7842.pt"). + // Try to parse the epoch from the filename, extension-agnostic + // (e.g. "best_epoch0042_pck0.7842.safetensors"). let epoch = path .file_stem() .and_then(|s| s.to_str()) diff --git a/v2/crates/wifi-densepose-train/src/wiflow_std/config.rs b/v2/crates/wifi-densepose-train/src/wiflow_std/config.rs index 2068a47d..7393d846 100644 --- a/v2/crates/wifi-densepose-train/src/wiflow_std/config.rs +++ b/v2/crates/wifi-densepose-train/src/wiflow_std/config.rs @@ -56,6 +56,10 @@ fn default_input_pw_groups() -> usize { 1 } +fn default_min_feature_width() -> usize { + 15 +} + // --------------------------------------------------------------------------- // WiFlowStdConfig // --------------------------------------------------------------------------- @@ -114,9 +118,28 @@ pub struct WiFlowStdConfig { pub attention_groups: usize, /// Number of 2-D keypoints produced. Default: **15** (upstream skeleton); - /// use **17** for RuView's COCO-skeleton ESP32 eval set. + /// use **17** for RuView's COCO-skeleton ESP32 eval set. Only changes the + /// parameter-free final adaptive pool — never the trunk: the stride + /// schedule is governed by [`Self::min_feature_width`], so 15- and + /// 17-keypoint variants share the identical conv graph and weights + /// (matching the validated Python protocol, + /// `benchmarks/wiflow-std/remote/measb/train_measb.py`, which swaps only + /// `avg_pool` and loads the pretrained state_dict `strict=True`). pub keypoints: usize, + /// Floor for the conv encoder's width downsampling: each + /// `AsymmetricConvBlock` halves the width only while the result stays + /// ≥ this value (see [`Self::conv_strides`]). + /// + /// Default: **15** — the upstream constant. Provenance: the reference's + /// four hardcoded stride-2 blocks exist because its 240-channel TCN + /// output halves cleanly four times, 240 / 2⁴ = 15. The compact presets' + /// schedules were derived with this same floor. Override only when + /// designing a new trunk; do **not** couple it to [`Self::keypoints`] — + /// the adaptive pool maps the decoder height to any keypoint count. + #[serde(default = "default_min_feature_width")] + pub min_feature_width: usize, + /// Elementwise dropout probability inside the TCN blocks, in `[0, 1)`. /// Default: **0.5** (the value used by our verified retraining run). pub dropout: f64, @@ -134,6 +157,7 @@ impl Default for WiFlowStdConfig { conv_channels: vec![8, 16, 32, 64], attention_groups: 8, keypoints: 15, + min_feature_width: 15, dropout: 0.5, } } @@ -142,6 +166,12 @@ impl Default for WiFlowStdConfig { impl WiFlowStdConfig { /// Default architecture with a different keypoint count (e.g. 17 for the /// ESP32 COCO-skeleton eval set, ADR-152 §2.2(b)). + /// + /// The trunk is untouched: [`Self::min_feature_width`] stays at the + /// upstream floor of 15, so e.g. `for_keypoints(17)` keeps the trained + /// `[2, 2, 2, 2]` stride schedule (feature width 15) and the adaptive + /// pool maps 15 → 17 — exactly the validated Python protocol + /// (`benchmarks/wiflow-std/remote/measb/train_measb.py`). pub fn for_keypoints(keypoints: usize) -> Self { WiFlowStdConfig { keypoints, @@ -284,6 +314,12 @@ impl WiFlowStdConfig { if self.keypoints == 0 { return Err(ConfigError::invalid_value("keypoints", "must be >= 1")); } + if self.min_feature_width == 0 { + return Err(ConfigError::invalid_value( + "min_feature_width", + "must be >= 1", + )); + } if !self.dropout.is_finite() || !(0.0..1.0).contains(&self.dropout) { return Err(ConfigError::invalid_value( "dropout", @@ -316,16 +352,20 @@ impl WiFlowStdConfig { /// Width stride of each `AsymmetricConvBlock`, derived with the sweep's /// rule (`model_compact.py::compute_strides`): halve the width /// (`w → ceil(w / 2)`, the `(1,3)`-kernel stride-2 output size) only - /// while the result stays ≥ [`Self::keypoints`], so the final adaptive - /// pool never has to duplicate rows. At the upstream default - /// (240 channels, 15 keypoints) this derives `[2, 2, 2, 2]` — the - /// hardcoded upstream schedule, exactly. + /// while the result stays ≥ [`Self::min_feature_width`]. At the upstream + /// default (240 TCN channels, floor 15) this derives `[2, 2, 2, 2]` — + /// the hardcoded upstream schedule, exactly. + /// + /// Deliberately independent of [`Self::keypoints`]: the keypoint count + /// only changes the parameter-free adaptive pool, so retargeting the + /// skeleton (e.g. [`Self::for_keypoints`]`(17)`) keeps the trained graph + /// and the pool maps `feature_width() → keypoints`. pub fn conv_strides(&self) -> Vec { let mut w = self.tcn_output_channels(); let mut strides = Vec::with_capacity(self.conv_channels.len()); for _ in &self.conv_channels { let next = w.div_ceil(2); - if next >= self.keypoints { + if next >= self.min_feature_width { strides.push(2); w = next; } else { @@ -375,7 +415,16 @@ impl WiFlowStdConfig { /// /// Pins the port against the verified reference: the 15-keypoint default /// must equal **2,225,042** (`RESULTS.md` artifact verification). + /// + /// Returns **0** for any config that fails [`Self::validate`]: the + /// formula is only meaningful for buildable architectures (an invalid + /// config would otherwise index an empty `conv_channels` or divide by a + /// zero group count). Call `validate()` first when you need the reason. pub fn param_count(&self) -> usize { + if self.validate().is_err() { + return 0; + } + let mut total = 0; // TCN stack: per-conv groups follow tcn_groups_mode; only the first @@ -593,6 +642,76 @@ mod tests { assert_eq!(WiFlowStdConfig::tiny().feature_width(), 16); } + #[test] + fn for_keypoints_17_keeps_trained_trunk_and_pools_15_to_17() { + // Pin against the validated Python protocol (train_measb.py): K=17 + // swaps only the adaptive pool, never the stride schedule. A derived + // [2, 2, 2, 1]/width-30 graph here would silently diverge from the + // trained [2, 2, 2, 2]/width-15 checkpoint. + let cfg = WiFlowStdConfig::for_keypoints(17); + assert_eq!(cfg.min_feature_width, 15); + assert_eq!(cfg.conv_strides(), [2, 2, 2, 2]); + assert_eq!(cfg.feature_width(), 15); + assert_eq!(cfg.output_shape(1), (1, 17, 2)); + } + + #[test] + fn min_feature_width_override_changes_schedule_as_designed() { + // Raising the floor stops the downsampling earlier (240 → 30). + let cfg = WiFlowStdConfig { + min_feature_width: 30, + ..Default::default() + }; + cfg.validate().expect("floor 30 validates"); + assert_eq!(cfg.conv_strides(), [2, 2, 2, 1]); + assert_eq!(cfg.feature_width(), 30); + + // Lowering it lets a small trunk halve further (tiny: 32 → 8). + let cfg = WiFlowStdConfig { + min_feature_width: 8, + ..WiFlowStdConfig::tiny() + }; + cfg.validate().expect("floor 8 validates"); + assert_eq!(cfg.conv_strides(), [2, 2, 1, 1]); + assert_eq!(cfg.feature_width(), 8); + } + + #[test] + fn rejects_zero_min_feature_width() { + let cfg = WiFlowStdConfig { + min_feature_width: 0, + ..Default::default() + }; + assert!(cfg.validate().is_err()); + } + + #[test] + fn param_count_returns_zero_for_invalid_configs() { + // Documented total behavior: configs that fail validate() yield 0 + // instead of panicking (OOB index / division by zero). + for cfg in [ + WiFlowStdConfig { + conv_channels: vec![], + ..Default::default() + }, + WiFlowStdConfig { + tcn_groups: 0, + ..Default::default() + }, + WiFlowStdConfig { + input_pw_groups: 0, + ..Default::default() + }, + WiFlowStdConfig { + tcn_channels: vec![], + ..Default::default() + }, + ] { + assert!(cfg.validate().is_err(), "precondition: {cfg:?} is invalid"); + assert_eq!(cfg.param_count(), 0, "no panic, returns 0: {cfg:?}"); + } + } + #[test] fn fixed_mode_with_defaults_is_unchanged_by_new_knobs() { // The new fields default to upstream behavior: gcd(c, 20) == 20 for 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 6d0bf813..074a0f02 100644 --- a/v2/crates/wifi-densepose-train/src/wiflow_std/model.rs +++ b/v2/crates/wifi-densepose-train/src/wiflow_std/model.rs @@ -142,7 +142,16 @@ impl WiFlowStdModel { tch::no_grad(|| self.forward_impl(csi, false)) } - /// Save model weights (tch `.pt` / safetensors format). + /// Save model weights. The tch `VarStore` dispatches the format on the + /// file extension: `.safetensors` → safetensors, anything else → torch + /// `.pt`. + /// + /// **Platform constraint:** prefer `.safetensors`. The `.pt` path + /// (`_save_parameters`/`_load_parameters`) is broken on Windows with + /// torch 2.11 (GenericDict internal assert on the load roundtrip — see + /// the `save_and_load_roundtrip` test below), and the verified retrained + /// checkpoint is shipped as key-remapped safetensors anyway + /// (`benchmarks/wiflow-std/export_to_safetensors.py`). /// /// # Errors /// @@ -153,7 +162,8 @@ impl WiFlowStdModel { .map_err(|e| TrainError::training_step(format!("save failed: {e}"))) } - /// Load model weights from a file. + /// Load model weights from a file (format dispatched on extension; see + /// the `.pt`-on-Windows caveat on [`Self::save`]). /// /// # Errors /// diff --git a/v2/crates/wifi-densepose-train/tests/test_mae.rs b/v2/crates/wifi-densepose-train/tests/test_mae.rs index 01702144..7d935b95 100644 --- a/v2/crates/wifi-densepose-train/tests/test_mae.rs +++ b/v2/crates/wifi-densepose-train/tests/test_mae.rs @@ -207,21 +207,55 @@ fn mask_count_is_exact_for_default_recipe() { // 54 patches @ 0.80 → round(43.2) = 43 masked, 11 visible. let cfg = MaePretrainConfig::default(); assert_eq!(cfg.num_masked(54), 43); - let mask = random_mask(54, cfg.mask_ratio, cfg.seed); + let mask = random_mask(54, cfg.mask_ratio, cfg.seed).unwrap(); assert_eq!(mask.masked.len(), 43); assert_eq!(mask.visible.len(), 11); } #[test] fn same_seed_same_mask_different_seed_differs() { - let a = random_mask(100, 0.80, 7); - let b = random_mask(100, 0.80, 7); + let a = random_mask(100, 0.80, 7).unwrap(); + let b = random_mask(100, 0.80, 7).unwrap(); assert_eq!(a, b, "same (n, ratio, seed) must reproduce the mask"); - let c = random_mask(100, 0.80, 8); + let c = random_mask(100, 0.80, 8).unwrap(); assert_ne!(a.masked, c.masked, "different seeds must differ"); } +#[test] +fn random_mask_rejects_invalid_ratios() { + // Error-not-silent: NaN must not silently mask 0 patches; ratios outside + // (0, 1) must not degenerate to all-visible / all-masked grids. + for ratio in [ + f64::NAN, + f64::INFINITY, + f64::NEG_INFINITY, + 1.0, + 1.5, + 0.0, + -0.1, + ] { + let err = random_mask(54, ratio, 42).unwrap_err(); + assert!( + matches!(err, MaeError::InvalidMaskRatio { .. }), + "ratio {ratio} must be rejected, got {err:?}" + ); + } +} + +#[test] +fn mask_window_rejects_invalid_ratio_before_masking() { + let cfg = MaePretrainConfig { + mask_ratio: f64::NAN, + ..MaePretrainConfig::default() + }; + let buf = window(90, 54); + assert!(matches!( + cfg.mask_window(&buf, 90, 54), + Err(MaeError::InvalidMaskRatio { .. }) + )); +} + proptest! { /// Exact count, sortedness, range, disjointness, and full coverage hold /// for arbitrary grid sizes, ratios, and seeds. @@ -231,7 +265,7 @@ proptest! { ratio in 0.01f64..0.99, seed in any::(), ) { - let mask = random_mask(n, ratio, seed); + let mask = random_mask(n, ratio, seed).unwrap(); let expected_masked = ((ratio * n as f64).round() as usize).min(n); prop_assert_eq!(mask.masked.len(), expected_masked); prop_assert_eq!(mask.masked.len() + mask.visible.len(), n); @@ -254,7 +288,10 @@ proptest! { /// Determinism by seed for arbitrary inputs. #[test] fn prop_mask_deterministic(n in 1usize..400, seed in any::()) { - prop_assert_eq!(random_mask(n, 0.80, seed), random_mask(n, 0.80, seed)); + prop_assert_eq!( + random_mask(n, 0.80, seed).unwrap(), + random_mask(n, 0.80, seed).unwrap() + ); } /// Round-trip identity for arbitrary divisible window/patch geometries.