feat(adr-125 tier1+2 iter 6 SOTA): PyO3 BFLD PrivacyClass binding

scripts/c6-presence-watcher.py and friends carry a Python port of
`wifi_densepose_bfld::PrivacyClass`. This iter ships the canonical
SOTA replacement — a PyO3 binding over the published Rust crate so
the runtime can pivot to the same enum semantics every other consumer
of `wifi-densepose-bfld 0.3.0` already uses.

New file: `python/src/bindings/privacy_gate.rs` (~155 LOC)
  - `#[pyclass] PrivacyClass {Raw, Derived, Anonymous, Restricted}`
  - `.allows_network`, `.allows_matter`, `.allows_hap`, `.as_u8` getters
  - `PrivacyClass.from_u8(v)` / `PrivacyClass.from_str(name)` constructors
  - free fns `allows_hap`, `allows_network`, `allows_matter`
  - registered in `python/src/lib.rs` via `bindings::privacy_gate::register`

Cargo.toml gains `wifi-densepose-bfld = { version = "0.3.0", path = ... }`
as a hard dep; numpy + pyo3 + the existing core/vitals deps unchanged.

ADR-125 §2.1.d invariant restated at the binding boundary: HAP eligibility
mirrors Matter eligibility (Anonymous and Restricted only); a single
`PrivacyClass::from(*self).allows_matter()` call is the gate truth-source.

Verification: `cargo check -p wifi-densepose-py` on the workspace
compiles cleanly with the new binding linking against the published
crate (Checking wifi-densepose-bfld v0.3.0 ✓, Checking
wifi-densepose-py v2.0.0-alpha.1 ✓).

Runtime swap-in is the next iter: when the maturin wheel ships
(ADR-117 P5), `c6-presence-watcher.py` imports
`from wifi_densepose import PrivacyClass` instead of carrying the
Python enum port. Same struct shape, same semantics, just backed by
the published Rust crate. The Python port stays as a fallback for
operators on systems where the wheel isn't installed.

Refs ADR-118 §2.1, ADR-125 §2.1.d, ADR-117 §5.7 (binding strategy).

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
ruv 2026-05-25 17:05:34 -04:00
parent c19742d71a
commit de0712d435
3 changed files with 166 additions and 0 deletions

View File

@ -39,6 +39,13 @@ wifi-densepose-core = { version = "0.3.0", path = "../v2/crates/wifi-densepose-c
# no tokio (Q5 audited 2026-05-24); safe to wrap in py.allow_threads.
wifi-densepose-vitals = { version = "0.3.0", path = "../v2/crates/wifi-densepose-vitals" }
# ADR-118 BFLD core — PrivacyClass enum + identity_risk scoring +
# privacy gate. Exposed to Python via bindings/privacy_gate.rs so the
# c6-presence-watcher.py runtime (currently using a Python port of the
# same semantics) can switch to the canonical Rust implementation when
# the wheel ships. ADR-125 §2.1.d invariant enforcement lives here.
wifi-densepose-bfld = { version = "0.3.0", path = "../v2/crates/wifi-densepose-bfld" }
# numpy bridge — needed for P3.5 BfldFrame (Complex64 ndarray) and for
# the future P3 CsiFrame numpy round-trip.
numpy = "0.22"

View File

@ -0,0 +1,154 @@
//! ADR-118 / ADR-125 §2.1.d — Python binding for the BFLD `PrivacyClass`
//! enum and the HAP-eligibility gate.
//!
//! Python:
//! ```python
//! from wifi_densepose import PrivacyClass, allows_hap, allows_matter, allows_network
//!
//! PrivacyClass.Anonymous # → 2
//! allows_hap(PrivacyClass.Raw) # → False (I1 invariant)
//! allows_hap(PrivacyClass.Anonymous)# → True
//! allows_matter(PrivacyClass.Restricted) # → True (ADR-122 §2.4)
//! ```
//!
//! This is the SOTA replacement for the Python port that ships in
//! `scripts/c6-presence-watcher.py::PrivacyClass`. When the
//! `wifi-densepose` PyPI wheel lands (ADR-117 P5), runtimes flip from
//! the Python port to this Rust-backed binding and get the same enum
//! semantics as every other consumer of the published
//! `wifi-densepose-bfld 0.3.0` crate.
use pyo3::prelude::*;
use wifi_densepose_bfld::PrivacyClass;
/// Python-facing wrapper for [`wifi_densepose_bfld::PrivacyClass`].
///
/// Repr matches the Rust enum byte values 0..=3.
#[pyclass(eq, eq_int, hash, frozen, name = "PrivacyClass", module = "wifi_densepose")]
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
pub enum PyPrivacyClass {
Raw = 0,
Derived = 1,
Anonymous = 2,
Restricted = 3,
}
impl From<PrivacyClass> for PyPrivacyClass {
fn from(c: PrivacyClass) -> Self {
match c {
PrivacyClass::Raw => Self::Raw,
PrivacyClass::Derived => Self::Derived,
PrivacyClass::Anonymous => Self::Anonymous,
PrivacyClass::Restricted => Self::Restricted,
}
}
}
impl From<PyPrivacyClass> for PrivacyClass {
fn from(c: PyPrivacyClass) -> Self {
match c {
PyPrivacyClass::Raw => Self::Raw,
PyPrivacyClass::Derived => Self::Derived,
PyPrivacyClass::Anonymous => Self::Anonymous,
PyPrivacyClass::Restricted => Self::Restricted,
}
}
}
#[pymethods]
impl PyPrivacyClass {
/// True if frames of this class may cross a `NetworkSink`.
/// Class 0 (`Raw`) is local-only by structural invariant I1
/// (ADR-118 §2.2).
#[getter]
fn allows_network(&self) -> bool {
PrivacyClass::from(*self).allows_network()
}
/// True if frames of this class may cross the Matter boundary.
/// Only classes 2 (`Anonymous`) and 3 (`Restricted`) qualify per
/// ADR-122 §2.4 / ADR-125 §2.1.d.
#[getter]
fn allows_matter(&self) -> bool {
PrivacyClass::from(*self).allows_matter()
}
/// True if frames of this class may cross the HomeKit Accessory
/// Protocol boundary. Same set as `allows_matter` — class 2 or 3.
#[getter]
fn allows_hap(&self) -> bool {
// HAP eligibility is the same shape as Matter eligibility per
// ADR-125 §2.1.d; we don't add a separate Rust method until
// there's a divergence to justify it.
PrivacyClass::from(*self).allows_matter()
}
/// Byte value (0..=3) for serialization.
#[getter]
fn as_u8(&self) -> u8 {
PrivacyClass::from(*self).as_u8()
}
fn __repr__(&self) -> String {
match self {
Self::Raw => "PrivacyClass.Raw",
Self::Derived => "PrivacyClass.Derived",
Self::Anonymous => "PrivacyClass.Anonymous",
Self::Restricted => "PrivacyClass.Restricted",
}
.to_string()
}
/// Map a byte value 0..=3 to the corresponding `PrivacyClass`.
/// Raises `ValueError` on out-of-range input.
#[staticmethod]
fn from_u8(v: u8) -> PyResult<Self> {
PrivacyClass::try_from(v)
.map(Self::from)
.map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))
}
/// Map a string ("raw" / "derived" / "anonymous" / "restricted",
/// case-insensitive) to the corresponding `PrivacyClass`. Raises
/// `ValueError` on unknown names.
#[staticmethod]
fn from_str(s: &str) -> PyResult<Self> {
match s.to_ascii_lowercase().as_str() {
"raw" => Ok(Self::Raw),
"derived" => Ok(Self::Derived),
"anonymous" => Ok(Self::Anonymous),
"restricted" => Ok(Self::Restricted),
_ => Err(pyo3::exceptions::PyValueError::new_err(format!(
"invalid PrivacyClass name: {s:?} (expected raw/derived/anonymous/restricted)"
))),
}
}
}
/// Free-function helper: `True` iff `c` may cross the HAP boundary.
/// Convenience wrapper so Python callers can write
/// `allows_hap(PrivacyClass.Anonymous)` without method-call syntax.
#[pyfunction]
fn allows_hap(c: PyPrivacyClass) -> bool {
c.allows_hap()
}
/// Free-function helper: `True` iff `c` may cross a `NetworkSink`.
#[pyfunction]
fn allows_network(c: PyPrivacyClass) -> bool {
c.allows_network()
}
/// Free-function helper: `True` iff `c` may cross the Matter boundary.
#[pyfunction]
fn allows_matter(c: PyPrivacyClass) -> bool {
c.allows_matter()
}
pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::<PyPrivacyClass>()?;
m.add_function(wrap_pyfunction!(allows_hap, m)?)?;
m.add_function(wrap_pyfunction!(allows_network, m)?)?;
m.add_function(wrap_pyfunction!(allows_matter, m)?)?;
Ok(())
}

View File

@ -20,6 +20,7 @@ mod bindings {
pub mod bfld;
pub mod keypoint;
pub mod pose;
pub mod privacy_gate;
pub mod vitals;
}
@ -80,5 +81,9 @@ fn wifi_densepose_native(m: &Bound<'_, PyModule>) -> PyResult<()> {
// P3.5 — BFLD bindings (stub Rust; future wifi-densepose-bfld crate
// will replace the stub without changing the Python API).
bindings::bfld::register(m)?;
// ADR-118 PrivacyClass + HAP/Matter eligibility gates (SOTA — backed by
// the published `wifi-densepose-bfld 0.3.0` crate, not the Python port).
// Closes ADR-125 §2.1.d at the binding boundary.
bindings::privacy_gate::register(m)?;
Ok(())
}