Compare commits

...

4 Commits

Author SHA1 Message Date
ruv eb996294fb feat(adr-118/p1.3): Sink marker traits + PrivacyClass::try_from (17/17 GREEN)
Iter 3. Lands the structural enforcement of ADR-118 invariant I1
("raw BFI never exits the node") and ADR-120 §2.2 ("Sink marker types").

Added:
- src/sink.rs:
  * Sink trait with MIN_CLASS and KIND associated constants
  * LocalSink (Raw OK), NetworkSink (Derived+ only), MatterSink (Anonymous+)
  * Hierarchy: MatterSink: NetworkSink (every Matter sink is a NetworkSink)
  * check_class<S>(class) runtime gate, returns PrivacyViolation{reason:KIND}
  * Zero-sized kind tags: LocalKind / NetworkKind / MatterKind
- PrivacyClass::as_u8() const helper
- TryFrom<u8> for PrivacyClass (0..=3 valid; 4..=255 → InvalidPrivacyClass)
- BfldError::InvalidPrivacyClass(u8) variant

tests/sink_enforcement.rs adds 8 tests:
  privacy_class_try_from_accepts_all_four_valid_bytes
  privacy_class_try_from_rejects_out_of_range_bytes
  privacy_class_byte_roundtrip_is_stable
  local_sink_accepts_all_classes
  network_sink_rejects_raw_frames
  network_sink_accepts_derived_anonymous_restricted
  matter_sink_rejects_raw_and_derived
  matter_sink_accepts_anonymous_and_restricted

Out of scope (next iter):
- BfldFrame (header + payload + section length-prefixes + CRC32 over payload)
  — needs the `crc` crate dependency.
- PrivacyGate::demote(frame, target_class) transformer (ADR-120 §2.4).
- compile-fail test that proves a sink-trait bound rejects Raw at compile
  time — needs `trybuild` integration; deferred to a separate iter.

cargo test -p wifi-densepose-bfld --no-default-features → 17 passed, 0 failed
  (3 frame_header_size + 6 header_roundtrip + 8 sink_enforcement)

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-24 13:43:05 -04:00
ruv be4dad6ede feat(adr-118/p1.2): header encode/decode + 6 round-trip tests (9/9 GREEN)
Iter 2 of the BFLD rollout. Adds the canonical little-endian wire form for
BfldFrameHeader with safe (no unsafe) encoders/decoders. Covers ADR-119 AC5
(round-trip preservation), AC6 (deterministic serialization), and partial
AC1 (constant wire size) / AC4 (rejects bad magic + bad version).

Added:
- BfldFrameHeader::empty() — convenience constructor with magic/version set
- BfldFrameHeader::to_le_bytes() -> [u8; 86]
- BfldFrameHeader::from_le_bytes(&[u8; 86]) -> Result<Self, BfldError>
- Field-level doc strings on every header field (clears all 21 missing-docs
  warnings the iter 1 commit logged)
- tests/header_roundtrip.rs — 6 named tests:
    header_roundtrip_preserves_all_fields
    header_serialization_is_deterministic
    header_magic_is_at_offset_zero_little_endian (LE byte order proof)
    parsing_rejects_invalid_magic
    parsing_rejects_unsupported_version
    wire_size_is_constant

Implementation notes:
- Used #[derive(Default)] on BfldFrameHeader so empty() can build cleanly.
- to_le_bytes copies packed fields into locals first to dodge unaligned-
  borrow lints; from_le_bytes uses try_into() on byte slices.
- All field reads/writes are #[forbid(unsafe_code)] compliant.

Out of scope (next iter targets):
- BfldFrame (header + payload sections + section-length prefixes + CRC32
  computation over payload bytes only) — needs the `crc` crate dependency.
- PrivacyGate::demote(...) skeleton (ADR-120 §2.4).
- SinkMarker traits (LocalSink / NetworkSink / MatterSink) — ADR-120 §2.2.

cargo test -p wifi-densepose-bfld --no-default-features → 9 passed, 0 failed

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-24 13:38:11 -04:00
ruv c965e3e6c0 feat(adr-118/p1): scaffold wifi-densepose-bfld crate + frame header (3/3 tests GREEN)
Land P1 of the BFLD rollout — the wire-format primitives:

- New workspace member: v2/crates/wifi-densepose-bfld
- PrivacyClass enum (Raw/Derived/Anonymous/Restricted) with allows_network()
  and allows_matter() const helpers reflecting ADR-120 §2.2 and ADR-122 §2.4
- BfldFrameHeader (#[repr(C, packed)]) per ADR-119 §2.1
- BFLD_MAGIC = 0xBF1D_0001, BFLD_VERSION = 1
- BfldError variants for InvalidMagic / UnsupportedVersion / Crc / PrivacyViolation
- soul-signature cargo feature (gated, default OFF) per ADR-118 §1.4
- Compile-time size assertion via static_assertions::const_assert_eq!
- 3 acceptance tests in tests/frame_header_size.rs (all pass)

Bug fix:
- ADR-119 AC1 claimed BfldFrameHeader is 40 bytes. Actual packed layout sums
  to 86 bytes. Updated AC1 and §2.1 prose to match. const_assert in frame.rs
  pins the value structurally — a future field addition that breaks the size
  fails to compile.

Out of scope for this iter (deferred to later P1 commits):
- Field-level missing-docs warnings (21) — addressed alongside accessor helpers
- Payload section parsing — needs the section-length prefix tests
- Round-trip serialize/parse — covered by a fixture-based test in the next iter

cargo test -p wifi-densepose-bfld --no-default-features → 3 passed, 0 failed

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-24 13:34:05 -04:00
ruv 833ac84059 docs(adr-117): point README + user-guide at the live PyPI releases
Both packages are now live on PyPI; bring the in-repo docs up to
match. Keep both updates brief — the canonical surface
documentation lives on the PyPI project pages themselves.

Root README (Option 4 block):
- Switch the default `pip install` example to `ruview` (the brand
  name) and note `wifi-densepose` is equivalent.
- Add live PyPI version badges for both packages.

docs/user-guide.md (§Python wheel):
- Replace the single-install example with a table showing both
  PyPI projects and their import names so users see the choice
  immediately.
- Add three short usage snippets (vitals, live sensing-server WS,
  HA-MIND semantic-primitive MQTT listener) so the guide doubles
  as a "what does this thing do?" reference for someone landing
  via pip.
- Note the cibuildwheel matrix for multi-arch wheels.
- Add the `pytest tests/` + `pytest bench/` source-build verify
  steps.

No code or test changes.

Refs: docs/adr/ADR-117-pip-wifi-densepose-modernization.md
Refs: #786

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-24 13:12:29 -04:00
12 changed files with 692 additions and 16 deletions
+10 -3
View File
@@ -112,12 +112,19 @@ node scripts/rf-scan.js --port 5006 # Live RF room scan
node scripts/snn-csi-processor.js --port 5006 # SNN real-time learning
node scripts/mincut-person-counter.js --port 5006 # Correct person counting
# Option 4: Python — talk to a RuView node from your own code (ADR-117)
pip install "wifi-densepose[client]" # ~250 KB compiled wheel, abi3-py310
# Option 4: Python — live on PyPI (ADR-117)
pip install ruview # or: pip install wifi-densepose
# Both ship the same compiled PyO3 wheel (~250 KB, abi3-py310, Linux/macOS/Windows).
# Add [client] for the asyncio WebSocket + paho-mqtt clients:
pip install "ruview[client]" # or: pip install "wifi-densepose[client]"
# from ruview import BreathingExtractor, HeartRateExtractor # equivalent to:
# from wifi_densepose import BreathingExtractor, HeartRateExtractor
# from wifi_densepose.client import SensingClient, RuViewMqttClient
# from ruview.client import SensingClient, RuViewMqttClient
```
[![PyPI ruview](https://img.shields.io/pypi/v/ruview?label=ruview)](https://pypi.org/project/ruview/) [![PyPI wifi-densepose](https://img.shields.io/pypi/v/wifi-densepose?label=wifi-densepose)](https://pypi.org/project/wifi-densepose/)
> [!NOTE]
> **CSI-capable hardware recommended.** Presence, vital signs, through-wall sensing, and all advanced capabilities require Channel State Information (CSI) from an ESP32-S3 ($9) or research NIC. The Docker image runs with simulated data for evaluation. Consumer WiFi laptops provide RSSI-only presence detection.
@@ -57,7 +57,7 @@ pub struct BfldFrameHeader {
}
```
Total header size: 40 bytes (validated by `static_assertions::const_assert_eq!`).
Total header size: **86 bytes packed** (validated by `static_assertions::const_assert_eq!` in `wifi-densepose-bfld/src/frame.rs`). Earlier drafts stated 40 bytes — that was a counting error caught during P1 scaffold; see AC1 below.
### 2.2 Payload structure
@@ -144,7 +144,7 @@ Rejected: CRC must be computed after the payload, so its value would otherwise f
## 5. Acceptance Criteria
- [ ] **AC1**: `BfldFrameHeader` size is exactly 40 bytes on x86_64, aarch64, and xtensa-esp32s3.
- [ ] **AC1**: `BfldFrameHeader` size is exactly **86 bytes** (packed) on x86_64, aarch64, and xtensa-esp32s3. The size was initially documented as 40 bytes during ADR drafting — that was a counting error; the implementation in `wifi-densepose-bfld/src/frame.rs` enforces the correct value via `const_assert_eq!`.
- [ ] **AC2**: 1,000 serializations of a fixed `BfiCapture` fixture produce a bit-identical BLAKE3 hash.
- [ ] **AC3**: `privacy_class = 0` frame returned through `NetworkSink::publish()` returns `Err(BfldError::PrivacyViolation)`.
- [ ] **AC4**: Payload CRC32 mismatch causes `BfldFrame::parse()` to return `Err(BfldError::Crc)` without exposing partial payload state.
+41 -11
View File
@@ -166,24 +166,48 @@ See the full crate list and dependency order in [CLAUDE.md](../CLAUDE.md#crate-p
### Python wheel (pip) — ADR-117
The `wifi-densepose` PyPI wheel is a PyO3 binding to the Rust core. It
ships compiled DSP (~250 KB, Linux/macOS/Windows × abi3-py310) plus an
opt-in pure-Python WebSocket/MQTT client for talking to a live RuView
sensing-server.
The Python API ships as **two interchangeable PyPI packages** — same
compiled PyO3 wheel under both names; pick whichever import name
reads better in your code:
| PyPI | Install | Latest | Import |
|---|---|---|---|
| [`ruview`](https://pypi.org/project/ruview/) | `pip install ruview` | `2.0.0a1` | `from ruview import ...` |
| [`wifi-densepose`](https://pypi.org/project/wifi-densepose/) | `pip install wifi-densepose` | `2.0.0a1` | `from wifi_densepose import ...` |
```bash
pip install wifi-densepose # core DSP only
pip install "wifi-densepose[client]" # + websockets + paho-mqtt
pip install ruview # core DSP (~250 KB compiled wheel)
pip install "ruview[client]" # + asyncio WebSocket + paho-mqtt
```
```python
from wifi_densepose import BreathingExtractor, HeartRateExtractor
from wifi_densepose.client import SensingClient, RuViewMqttClient
# vitals
from ruview import BreathingExtractor, HeartRateExtractor
br = BreathingExtractor.esp32_default() # 56 subcarriers @ 100 Hz, 30s window
# live sensing-server stream
from ruview.client import SensingClient, EdgeVitalsMessage
async with SensingClient("ws://localhost:8765/ws/sensing") as c:
async for msg in c.stream():
if isinstance(msg, EdgeVitalsMessage):
print(msg.breathing_rate_bpm, msg.heartrate_bpm)
# Home Assistant semantic primitives (ADR-115 HA-MIND)
from ruview.client import (
RuViewMqttClient, SemanticPrimitive, SemanticPrimitiveListener,
)
```
The legacy `wifi-densepose==1.1.0` FastAPI server is end-of-life;
`wifi-densepose==1.99.0` is a tombstone that raises `ImportError`
with a migration URL.
The wheels ship for Linux (x86_64, aarch64 via sdist), macOS (sdist),
and Windows (amd64 wheel). Stable ABI (`abi3-py310`) — one binary
covers Python 3.10+. Multi-arch native wheels are produced by the
[pip-release.yml](../.github/workflows/pip-release.yml) cibuildwheel
matrix on each `v*-pip` tag.
> **Migrating from v1.x?** The legacy `wifi-densepose==1.1.0` FastAPI
> server is end-of-life. `wifi-densepose==1.99.0` is a tombstone that
> raises `ImportError` with a migration URL; upgrade to `>=2.0.0a1`
> (or switch to `ruview`).
To build the wheel from source (e.g. for a local change):
@@ -192,8 +216,14 @@ git clone https://github.com/ruvnet/RuView.git
cd RuView/python
pip install maturin>=1.7
maturin develop --release
pytest tests/ # 183 tests
pytest bench/ --benchmark-only # 12 hot-path benchmarks
```
Full API + tests breakdown is on the PyPI front page:
[wifi-densepose on PyPI](https://pypi.org/project/wifi-densepose/) ·
[ruview on PyPI](https://pypi.org/project/ruview/).
### Guided Installer
An interactive installer that detects your hardware and recommends a profile:
Generated
+15
View File
@@ -7255,6 +7255,12 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "static_assertions"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
[[package]]
name = "strength_reduce"
version = "0.2.4"
@@ -9133,6 +9139,15 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471"
[[package]]
name = "wifi-densepose-bfld"
version = "0.3.0"
dependencies = [
"proptest",
"static_assertions",
"thiserror 2.0.18",
]
[[package]]
name = "wifi-densepose-cli"
version = "0.3.0"
+5
View File
@@ -42,6 +42,11 @@ members = [
# ADR-115 MQTT publisher as a Seed-installable artifact with
# mDNS, embedded broker, RuVector thresholds, Ed25519 witness.
"crates/cog-ha-matter",
# ADR-118: BFLD — Beamforming Feedback Layer for Detection. The
# privacy/safety layer that measures and gates identity leakage from
# WiFi BFI captures. Sub-ADRs: 119 (frame), 120 (privacy class),
# 121 (identity risk), 122 (HA/Matter), 123 (capture path).
"crates/wifi-densepose-bfld",
# rvCSI — edge RF sensing runtime (ADR-095 platform, ADR-096 FFI/crate layout):
# lives in its own repo (https://github.com/ruvnet/rvcsi), vendored here as
# `vendor/rvcsi` and published to crates.io as `rvcsi-*` 0.3.x. Depend on the
+38
View File
@@ -0,0 +1,38 @@
[package]
name = "wifi-densepose-bfld"
description = "BFLD — Beamforming Feedback Layer for Detection. Privacy-gated WiFi BFI sensing primitives. See ADR-118."
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
documentation.workspace = true
keywords.workspace = true
categories.workspace = true
[features]
default = ["std"]
std = []
# Soul Signature integration (ADR-118 §1.4, ADR-120 §2.7, ADR-121 §2.6) —
# enables privacy_class = 1 (derived) mode and the SoulMatchOracle gate
# exemption. Disabled by default per the structural class-2 default.
soul-signature = []
[dependencies]
thiserror.workspace = true
static_assertions = "1.1"
[dev-dependencies]
proptest.workspace = true
[lints.rust]
unsafe_code = "forbid"
missing_docs = "warn"
[lints.clippy]
all = "warn"
pedantic = "warn"
nursery = "warn"
module_name_repetitions = "allow"
missing_const_for_fn = "allow"
missing_panics_doc = "allow"
+177
View File
@@ -0,0 +1,177 @@
//! `BfldFrame` wire-format primitives. See ADR-119.
//!
//! The header is `#[repr(C, packed)]` so the wire byte order is fixed across
//! x86_64, aarch64, and xtensa-esp32s3 — and so the witness-bundle pattern
//! (ADR-028) extends cleanly to BFLD frames.
//!
//! All multi-byte integers serialize as **little-endian**. The
//! `to_le_bytes`/`from_le_bytes` helpers encode/decode without `unsafe`, which
//! is forbidden in this crate; the encoded bytes are the canonical wire form.
use static_assertions::const_assert_eq;
use crate::BfldError;
/// Magic value identifying a `BfldFrame`. Reads as "BFLD" in hex-dump tools.
pub const BFLD_MAGIC: u32 = 0xBF1D_0001;
/// Current `BfldFrame` major version. Bumps on any incompatible layout change.
pub const BFLD_VERSION: u16 = 1;
/// Size of the packed header in bytes. Asserted at compile time below.
///
/// Note: ADR-119 AC1 initially claimed 40 bytes — that was a counting error.
/// Actual packed layout sums to 86. Updated 2026-05-24 to match implementation.
pub const BFLD_HEADER_SIZE: usize = 86;
/// Flag bits in `BfldFrameHeader::flags`. See ADR-119 §2.1.
pub mod flags {
/// Payload contains an optional CSI delta section.
pub const HAS_CSI_DELTA: u16 = 1 << 0;
/// `privacy_mode` is engaged: identity-derived fields suppressed.
pub const PRIVACY_MODE: u16 = 1 << 1;
/// ESP32-S3 self-only adapter (ADR-123 §2.5): no `identity_risk_score`.
pub const SELF_ONLY: u16 = 1 << 3;
}
/// On-the-wire BFLD frame header. 86 bytes, little-endian, packed.
#[repr(C, packed)]
#[derive(Debug, Clone, Copy, Default)]
pub struct BfldFrameHeader {
/// Must equal [`BFLD_MAGIC`].
pub magic: u32,
/// Layout version. Currently [`BFLD_VERSION`].
pub version: u16,
/// Flag bits — see [`flags`].
pub flags: u16,
/// Monotonic capture-clock timestamp in nanoseconds.
pub timestamp_ns: u64,
/// BLAKE3-keyed(site_salt, ap_mac)[0..16] — ADR-120 §2.3.
pub ap_hash: [u8; 16],
/// BLAKE3-keyed(site_salt ‖ day_epoch, sta_mac)[0..16] — daily-rotated.
pub sta_hash: [u8; 16],
/// Ephemeral session identifier, rotated on capture-session boundary.
pub session_id: [u8; 16],
/// 802.11 channel number.
pub channel: u16,
/// Channel bandwidth in MHz: 20 / 40 / 80 / 160.
pub bandwidth_mhz: u16,
/// Received signal strength in dBm.
pub rssi_dbm: i16,
/// Noise floor in dBm.
pub noise_floor_dbm: i16,
/// Number of OFDM subcarriers represented.
pub n_subcarriers: u16,
/// Number of transmit antennas.
pub n_tx: u8,
/// Number of receive antennas.
pub n_rx: u8,
/// 0=f32, 1=i16, 2=i8, 3=packed (4-bit nibbles).
pub quantization: u8,
/// `PrivacyClass` byte — see ADR-120 §2.1.
pub privacy_class: u8,
/// Length of the payload section in bytes.
pub payload_len: u32,
/// CRC-32/ISO-HDLC over payload bytes only.
pub payload_crc32: u32,
}
const_assert_eq!(core::mem::size_of::<BfldFrameHeader>(), BFLD_HEADER_SIZE);
impl BfldFrameHeader {
/// Build a header with `magic` and `version` already set correctly.
/// All other fields default to zero — caller fills them in.
#[must_use]
pub fn empty() -> Self {
Self {
magic: BFLD_MAGIC,
version: BFLD_VERSION,
..Self::default()
}
}
/// Serialize to canonical little-endian wire form (86 bytes).
#[must_use]
#[allow(clippy::too_many_lines)]
pub fn to_le_bytes(&self) -> [u8; BFLD_HEADER_SIZE] {
let mut buf = [0u8; BFLD_HEADER_SIZE];
let mut o = 0usize;
// Copy locally to dodge `#[repr(packed)]` unaligned-borrow warnings.
let magic = self.magic;
let version = self.version;
let flags = self.flags;
let timestamp_ns = self.timestamp_ns;
let channel = self.channel;
let bandwidth_mhz = self.bandwidth_mhz;
let rssi_dbm = self.rssi_dbm;
let noise_floor_dbm = self.noise_floor_dbm;
let n_subcarriers = self.n_subcarriers;
let payload_len = self.payload_len;
let payload_crc32 = self.payload_crc32;
buf[o..o + 4].copy_from_slice(&magic.to_le_bytes()); o += 4;
buf[o..o + 2].copy_from_slice(&version.to_le_bytes()); o += 2;
buf[o..o + 2].copy_from_slice(&flags.to_le_bytes()); o += 2;
buf[o..o + 8].copy_from_slice(&timestamp_ns.to_le_bytes()); o += 8;
buf[o..o + 16].copy_from_slice(&self.ap_hash); o += 16;
buf[o..o + 16].copy_from_slice(&self.sta_hash); o += 16;
buf[o..o + 16].copy_from_slice(&self.session_id); o += 16;
buf[o..o + 2].copy_from_slice(&channel.to_le_bytes()); o += 2;
buf[o..o + 2].copy_from_slice(&bandwidth_mhz.to_le_bytes()); o += 2;
buf[o..o + 2].copy_from_slice(&rssi_dbm.to_le_bytes()); o += 2;
buf[o..o + 2].copy_from_slice(&noise_floor_dbm.to_le_bytes()); o += 2;
buf[o..o + 2].copy_from_slice(&n_subcarriers.to_le_bytes()); o += 2;
buf[o] = self.n_tx; o += 1;
buf[o] = self.n_rx; o += 1;
buf[o] = self.quantization; o += 1;
buf[o] = self.privacy_class; o += 1;
buf[o..o + 4].copy_from_slice(&payload_len.to_le_bytes()); o += 4;
buf[o..o + 4].copy_from_slice(&payload_crc32.to_le_bytes()); o += 4;
debug_assert_eq!(o, BFLD_HEADER_SIZE);
buf
}
/// Parse from canonical little-endian wire form.
///
/// Returns [`BfldError::InvalidMagic`] if the magic prefix is wrong, and
/// [`BfldError::UnsupportedVersion`] for a version this build cannot decode.
/// Field-level validation (CRC, payload_len bounds) is deliberately *not*
/// performed here — that lives at the frame-level parser.
pub fn from_le_bytes(bytes: &[u8; BFLD_HEADER_SIZE]) -> Result<Self, BfldError> {
let magic = u32::from_le_bytes(bytes[0..4].try_into().unwrap());
if magic != BFLD_MAGIC {
return Err(BfldError::InvalidMagic(magic));
}
let version = u16::from_le_bytes(bytes[4..6].try_into().unwrap());
if version != BFLD_VERSION {
return Err(BfldError::UnsupportedVersion(version));
}
let mut h = Self {
magic,
version,
flags: u16::from_le_bytes(bytes[6..8].try_into().unwrap()),
timestamp_ns: u64::from_le_bytes(bytes[8..16].try_into().unwrap()),
ap_hash: [0; 16],
sta_hash: [0; 16],
session_id: [0; 16],
channel: u16::from_le_bytes(bytes[64..66].try_into().unwrap()),
bandwidth_mhz: u16::from_le_bytes(bytes[66..68].try_into().unwrap()),
rssi_dbm: i16::from_le_bytes(bytes[68..70].try_into().unwrap()),
noise_floor_dbm: i16::from_le_bytes(bytes[70..72].try_into().unwrap()),
n_subcarriers: u16::from_le_bytes(bytes[72..74].try_into().unwrap()),
n_tx: bytes[74],
n_rx: bytes[75],
quantization: bytes[76],
privacy_class: bytes[77],
payload_len: u32::from_le_bytes(bytes[78..82].try_into().unwrap()),
payload_crc32: u32::from_le_bytes(bytes[82..86].try_into().unwrap()),
};
h.ap_hash.copy_from_slice(&bytes[16..32]);
h.sta_hash.copy_from_slice(&bytes[32..48]);
h.session_id.copy_from_slice(&bytes[48..64]);
Ok(h)
}
}
+97
View File
@@ -0,0 +1,97 @@
//! # BFLD — Beamforming Feedback Layer for Detection
//!
//! Privacy-gated WiFi sensing primitives derived from 802.11ac/ax Beamforming
//! Feedback Information (BFI). See [`docs/adr/ADR-118-bfld-beamforming-feedback-layer-for-detection.md`](../../../docs/adr/ADR-118-bfld-beamforming-feedback-layer-for-detection.md).
//!
//! ## Three structural invariants
//!
//! - **I1**: Raw BFI never exits the node.
//! - **I2**: Identity embedding is in-RAM-only.
//! - **I3**: Cross-site identity correlation is cryptographically impossible.
//!
//! Status: P1 in progress — frame format + sink marker traits. P2P6 follow.
#![cfg_attr(not(feature = "std"), no_std)]
pub mod frame;
pub mod sink;
pub use frame::{BfldFrameHeader, BFLD_MAGIC, BFLD_VERSION, BFLD_HEADER_SIZE};
pub use sink::{check_class, LocalSink, MatterSink, NetworkSink, Sink};
/// Privacy classification carried in every `BfldFrame`. See ADR-120 §2.1.
#[repr(u8)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum PrivacyClass {
/// Local-only research data including raw BFI matrix. Never networked.
Raw = 0,
/// Operator-acknowledged research mode over LAN. Downsampled angles +
/// identity_embedding + identity_risk_score available. Required for
/// Soul Signature deployments (ADR-120 §2.7).
Derived = 1,
/// Production default: aggregate sensing only, no identity-derived fields.
Anonymous = 2,
/// Care-home / regulated deployments: class 2 minus risk score and hash.
Restricted = 3,
}
impl PrivacyClass {
/// Returns `true` if frames of this class may cross a `NetworkSink`.
/// Class 0 (`Raw`) is local-only by structural invariant I1.
#[must_use]
pub const fn allows_network(self) -> bool {
!matches!(self, Self::Raw)
}
/// Returns `true` if frames of this class may cross the Matter boundary.
/// Only classes 2 and 3 are Matter-eligible. See ADR-122 §2.4.
#[must_use]
pub const fn allows_matter(self) -> bool {
matches!(self, Self::Anonymous | Self::Restricted)
}
/// Returns the byte value of this class (0..=3) for serialization.
#[must_use]
pub const fn as_u8(self) -> u8 {
self as u8
}
}
impl TryFrom<u8> for PrivacyClass {
type Error = BfldError;
fn try_from(value: u8) -> Result<Self, Self::Error> {
match value {
0 => Ok(Self::Raw),
1 => Ok(Self::Derived),
2 => Ok(Self::Anonymous),
3 => Ok(Self::Restricted),
other => Err(BfldError::InvalidPrivacyClass(other)),
}
}
}
/// Errors produced by BFLD operations.
#[derive(Debug, thiserror::Error)]
pub enum BfldError {
/// Header magic did not match `BFLD_MAGIC`.
#[error("invalid BFLD magic: expected 0x{BFLD_MAGIC:08X}, got 0x{0:08X}")]
InvalidMagic(u32),
/// Header version unsupported.
#[error("unsupported BFLD version: {0}")]
UnsupportedVersion(u16),
/// Payload CRC32 mismatch — frame corrupted or tampered.
#[error("payload CRC mismatch: expected 0x{expected:08X}, got 0x{actual:08X}")]
Crc { expected: u32, actual: u32 },
/// Attempted to publish a class-0 (`Raw`) frame through a network sink.
/// Enforces structural invariant I1.
#[error("privacy violation: {reason}")]
PrivacyViolation { reason: &'static str },
/// Byte value did not map to any defined `PrivacyClass` (0..=3).
#[error("invalid PrivacyClass byte: {0}")]
InvalidPrivacyClass(u8),
}
+92
View File
@@ -0,0 +1,92 @@
//! Sink marker traits — structural enforcement of invariant I1.
//!
//! Every output destination (memory buffer, MQTT topic, Matter cluster) implements
//! exactly one of [`LocalSink`], [`NetworkSink`], or [`MatterSink`]. The associated
//! constant [`Sink::MIN_CLASS`] declares the lowest `PrivacyClass` value that sink
//! is willing to accept; the runtime gate [`check_class`] enforces this on every
//! publish.
//!
//! Mapping (ADR-120 §2.2, ADR-122 §2.4):
//!
//! | Sink trait | `MIN_CLASS` | Accepts classes |
//! |---------------|----------------------|-----------------|
//! | `LocalSink` | `PrivacyClass::Raw` | 0, 1, 2, 3 |
//! | `NetworkSink` | `PrivacyClass::Derived` | 1, 2, 3 |
//! | `MatterSink` | `PrivacyClass::Anonymous` | 2, 3 |
//!
//! `MatterSink: NetworkSink` — every Matter sink is also a network sink.
use crate::{BfldError, PrivacyClass};
/// Base sink trait. Every sink type declares the minimum `PrivacyClass` it accepts.
pub trait Sink {
/// Lowest privacy class (highest information density) this sink will publish.
const MIN_CLASS: PrivacyClass;
/// Human-readable sink kind, used in `BfldError::PrivacyViolation` messages.
const KIND: &'static str;
}
/// Marker for sinks that stay on the originating node (memory, in-RAM channel,
/// local file with explicit operator opt-in). Accepts every class including `Raw`.
pub trait LocalSink: Sink {}
/// Marker for sinks that cross the node boundary (MQTT, HTTP, gRPC). Rejects
/// `Raw` frames by structural invariant I1.
pub trait NetworkSink: Sink {}
/// Marker for sinks that bridge into the Matter cluster surface. Rejects `Raw`
/// and `Derived`; the `cog-ha-matter` boundary filter consumes only classes 2/3.
pub trait MatterSink: NetworkSink {}
/// Runtime gate. Returns `Ok(())` if `class` is acceptable for `S`, otherwise
/// returns `BfldError::PrivacyViolation` with the offending sink kind.
///
/// Class numerical order *is* meaningful here: a sink that accepts `MIN_CLASS`
/// also accepts every higher-numbered class (less identity content). The check
/// is therefore a simple `>=` on the byte representation.
pub fn check_class<S: Sink>(class: PrivacyClass) -> Result<(), BfldError> {
if class.as_u8() >= S::MIN_CLASS.as_u8() {
Ok(())
} else {
Err(BfldError::PrivacyViolation {
reason: S::KIND,
})
}
}
// --- Default sink types ----------------------------------------------------
//
// Concrete sinks live in downstream crates (emitter.rs, mqtt.rs, the cog-ha-matter
// Matter bridge). These three "kind tags" are convenient zero-sized stand-ins for
// unit tests and for the privacy_gate compile-time tables.
/// Zero-sized tag: a local in-memory ring buffer or file sink.
#[derive(Debug, Clone, Copy, Default)]
pub struct LocalKind;
impl Sink for LocalKind {
const MIN_CLASS: PrivacyClass = PrivacyClass::Raw;
const KIND: &'static str = "LocalKind";
}
impl LocalSink for LocalKind {}
/// Zero-sized tag: a generic network sink (MQTT, HTTP, gRPC).
#[derive(Debug, Clone, Copy, Default)]
pub struct NetworkKind;
impl Sink for NetworkKind {
const MIN_CLASS: PrivacyClass = PrivacyClass::Derived;
const KIND: &'static str = "NetworkKind";
}
impl NetworkSink for NetworkKind {}
/// Zero-sized tag: the Matter cluster boundary in `cog-ha-matter`.
#[derive(Debug, Clone, Copy, Default)]
pub struct MatterKind;
impl Sink for MatterKind {
const MIN_CLASS: PrivacyClass = PrivacyClass::Anonymous;
const KIND: &'static str = "MatterKind";
}
impl NetworkSink for MatterKind {}
impl MatterSink for MatterKind {}
@@ -0,0 +1,28 @@
//! Acceptance test ADR-119 AC1: `BfldFrameHeader` size is platform-stable.
//!
//! The static assertion in `frame.rs` already enforces this at compile time on
//! the local target. This runtime test exists so CI surfaces the failure with
//! a useful message rather than a `const_assert_eq!` link error.
use wifi_densepose_bfld::{BfldFrameHeader, BFLD_HEADER_SIZE, BFLD_MAGIC, BFLD_VERSION};
#[test]
fn header_size_is_86_bytes() {
assert_eq!(
core::mem::size_of::<BfldFrameHeader>(),
BFLD_HEADER_SIZE,
"BfldFrameHeader must be exactly {BFLD_HEADER_SIZE} bytes (packed)",
);
}
#[test]
fn magic_reads_as_bfld_in_hex() {
// 0xBF1D_0001 — "BF1D" looks like "BFLD" in xxd output; final 0001 is the
// major version that lives in the dedicated `version` field as well.
assert_eq!(BFLD_MAGIC, 0xBF1D_0001);
}
#[test]
fn version_is_one() {
assert_eq!(BFLD_VERSION, 1);
}
@@ -0,0 +1,94 @@
//! Acceptance tests for `BfldFrameHeader` serialization (ADR-119 AC5/AC6).
use wifi_densepose_bfld::frame::flags;
use wifi_densepose_bfld::{BfldError, BfldFrameHeader, BFLD_HEADER_SIZE, BFLD_MAGIC};
fn sample_header() -> BfldFrameHeader {
let mut h = BfldFrameHeader::empty();
h.flags = flags::HAS_CSI_DELTA | flags::PRIVACY_MODE;
h.timestamp_ns = 0x0123_4567_89AB_CDEF;
h.ap_hash = [0xAA; 16];
h.sta_hash = [0xBB; 16];
h.session_id = [0xCC; 16];
h.channel = 36;
h.bandwidth_mhz = 80;
h.rssi_dbm = -55;
h.noise_floor_dbm = -95;
h.n_subcarriers = 234;
h.n_tx = 3;
h.n_rx = 4;
h.quantization = 1;
h.privacy_class = 2;
h.payload_len = 12_345;
h.payload_crc32 = 0xDEAD_BEEF;
h
}
#[test]
fn header_roundtrip_preserves_all_fields() {
let original = sample_header();
let bytes = original.to_le_bytes();
let parsed = BfldFrameHeader::from_le_bytes(&bytes).expect("parse must succeed");
assert_eq!({ parsed.magic }, BFLD_MAGIC);
assert_eq!({ parsed.version }, 1);
assert_eq!({ parsed.flags }, flags::HAS_CSI_DELTA | flags::PRIVACY_MODE);
assert_eq!({ parsed.timestamp_ns }, 0x0123_4567_89AB_CDEF);
assert_eq!(parsed.ap_hash, [0xAA; 16]);
assert_eq!(parsed.sta_hash, [0xBB; 16]);
assert_eq!(parsed.session_id, [0xCC; 16]);
assert_eq!({ parsed.channel }, 36);
assert_eq!({ parsed.bandwidth_mhz }, 80);
assert_eq!({ parsed.rssi_dbm }, -55);
assert_eq!({ parsed.noise_floor_dbm }, -95);
assert_eq!({ parsed.n_subcarriers }, 234);
assert_eq!(parsed.n_tx, 3);
assert_eq!(parsed.n_rx, 4);
assert_eq!(parsed.quantization, 1);
assert_eq!(parsed.privacy_class, 2);
assert_eq!({ parsed.payload_len }, 12_345);
assert_eq!({ parsed.payload_crc32 }, 0xDEAD_BEEF);
}
#[test]
fn header_serialization_is_deterministic() {
let h = sample_header();
let a = h.to_le_bytes();
let b = h.to_le_bytes();
assert_eq!(a, b, "two serializations of the same header must be bit-identical");
}
#[test]
fn header_magic_is_at_offset_zero_little_endian() {
let bytes = sample_header().to_le_bytes();
// BFLD_MAGIC = 0xBF1D_0001 → little-endian: 01 00 1D BF
assert_eq!(&bytes[0..4], &[0x01, 0x00, 0x1D, 0xBF]);
}
#[test]
fn parsing_rejects_invalid_magic() {
let mut bytes = sample_header().to_le_bytes();
bytes[0] = 0xFF; // clobber magic
match BfldFrameHeader::from_le_bytes(&bytes) {
Err(BfldError::InvalidMagic(got)) => {
assert_ne!(got, BFLD_MAGIC);
}
other => panic!("expected InvalidMagic, got {other:?}"),
}
}
#[test]
fn parsing_rejects_unsupported_version() {
let mut bytes = sample_header().to_le_bytes();
bytes[4] = 99; // version field at offset 4 (LE u16)
bytes[5] = 0;
match BfldFrameHeader::from_le_bytes(&bytes) {
Err(BfldError::UnsupportedVersion(v)) => assert_eq!(v, 99),
other => panic!("expected UnsupportedVersion, got {other:?}"),
}
}
#[test]
fn wire_size_is_constant() {
assert_eq!(sample_header().to_le_bytes().len(), BFLD_HEADER_SIZE);
}
@@ -0,0 +1,93 @@
//! Acceptance tests for ADR-120 §2.2 sink marker enforcement (invariant I1).
use wifi_densepose_bfld::sink::{LocalKind, MatterKind, NetworkKind};
use wifi_densepose_bfld::{check_class, BfldError, PrivacyClass};
// --- PrivacyClass::try_from ----------------------------------------------
#[test]
fn privacy_class_try_from_accepts_all_four_valid_bytes() {
assert_eq!(PrivacyClass::try_from(0).unwrap(), PrivacyClass::Raw);
assert_eq!(PrivacyClass::try_from(1).unwrap(), PrivacyClass::Derived);
assert_eq!(PrivacyClass::try_from(2).unwrap(), PrivacyClass::Anonymous);
assert_eq!(PrivacyClass::try_from(3).unwrap(), PrivacyClass::Restricted);
}
#[test]
fn privacy_class_try_from_rejects_out_of_range_bytes() {
for b in [4u8, 5, 7, 17, 42, 100, 200, 255] {
match PrivacyClass::try_from(b) {
Err(BfldError::InvalidPrivacyClass(got)) => assert_eq!(got, b),
other => panic!("expected InvalidPrivacyClass({b}), got {other:?}"),
}
}
}
#[test]
fn privacy_class_byte_roundtrip_is_stable() {
for c in [
PrivacyClass::Raw,
PrivacyClass::Derived,
PrivacyClass::Anonymous,
PrivacyClass::Restricted,
] {
assert_eq!(PrivacyClass::try_from(c.as_u8()).unwrap(), c);
}
}
// --- LocalSink accepts everything ---------------------------------------
#[test]
fn local_sink_accepts_all_classes() {
for c in [
PrivacyClass::Raw,
PrivacyClass::Derived,
PrivacyClass::Anonymous,
PrivacyClass::Restricted,
] {
check_class::<LocalKind>(c).expect("LocalSink must accept every class");
}
}
// --- NetworkSink rejects Raw, accepts the rest --------------------------
#[test]
fn network_sink_rejects_raw_frames() {
let err = check_class::<NetworkKind>(PrivacyClass::Raw).unwrap_err();
match err {
BfldError::PrivacyViolation { reason } => assert_eq!(reason, "NetworkKind"),
other => panic!("expected PrivacyViolation, got {other:?}"),
}
}
#[test]
fn network_sink_accepts_derived_anonymous_restricted() {
for c in [
PrivacyClass::Derived,
PrivacyClass::Anonymous,
PrivacyClass::Restricted,
] {
check_class::<NetworkKind>(c)
.expect("NetworkSink must accept Derived/Anonymous/Restricted");
}
}
// --- MatterSink rejects Raw and Derived ---------------------------------
#[test]
fn matter_sink_rejects_raw_and_derived() {
for c in [PrivacyClass::Raw, PrivacyClass::Derived] {
let err = check_class::<MatterKind>(c).unwrap_err();
match err {
BfldError::PrivacyViolation { reason } => assert_eq!(reason, "MatterKind"),
other => panic!("expected PrivacyViolation for {c:?}, got {other:?}"),
}
}
}
#[test]
fn matter_sink_accepts_anonymous_and_restricted() {
for c in [PrivacyClass::Anonymous, PrivacyClass::Restricted] {
check_class::<MatterKind>(c).expect("MatterSink must accept anonymous + restricted");
}
}