diff --git a/docs/adr/ADR-288-veil-privacy-shield-compliant-waveform.md b/docs/adr/ADR-288-veil-privacy-shield-compliant-waveform.md index 02ac409f..a2d09dad 100644 --- a/docs/adr/ADR-288-veil-privacy-shield-compliant-waveform.md +++ b/docs/adr/ADR-288-veil-privacy-shield-compliant-waveform.md @@ -9,7 +9,7 @@ | **Codebase target** | new leaf crate `v2/crates/wifi-densepose-privshield` | | **Parent** | ADR-118 (BFLD — the detection layer VEIL is the countermeasure to), ADR-282 (mandatory L0–L5 evidence ladder) | | **Relates to** | ADR-120/121 (BFLD privacy class + identity-risk scoring — the trigger source), ADR-141 (privacy control plane / runtime attestation — the audit consumer), ADR-280 (active sensing / governed actuation — VEIL is a defensive sensing action), ADR-185 §13 (`wifi-densepose-aether` — the pure-compute leaf pattern this crate follows) | -| **Research bundle** | [`docs/research/privacy-shield/`](../research/privacy-shield/) (8 files) | +| **Research bundle** | [`docs/research/privacy-shield/`](../research/privacy-shield/) (9 files) | | **Tracking issue** | TBD | ## 0. PROOF discipline @@ -77,18 +77,22 @@ WASM-ready, zero coupling to any radio or ingestion path), implementing: quantization/dither, sounding-cadence randomization, and a `SensingDetector` that engages the shield only when sensing activity is observed. 3. **The adversary** (`attacker.rs`): a passive nearest-centroid re-identifier - modeling the BFId threat. -4. **A throughput model** (`throughput.rs`): `(1 − overhead) · C(SNR·(1−ρ))/C(SNR)`, - where the beamforming residual `ρ` comes from finite feedback resolution - (negligible at 7+ bits, since the legitimate receiver inverts the keyed - rotation). + modeling the BFId threat, with selectable Euclidean/Cosine metrics. +4. **A throughput model** (`throughput.rs`): + `(1 − sounding − feedback_airtime) · C(SNR·(1−ρ))/C(SNR)`, where the residual + `ρ` falls with feedback bits and the feedback airtime rises with them — giving + a genuine interior throughput optimum in feedback resolution. 5. **A compliance audit** (`compliance.rs`): the rotation is orthogonal ⇒ energy-preserving ⇒ adds no interfering energy ⇒ **not jamming**, turned into a checked `ComplianceReport` (energy ratio ≈ 1.0). 6. **The experiment** (`experiment.rs`): runs the attacker against unprotected and protected traffic and reports both accuracies vs. chance, plus throughput and compliance, with a single `passed()` verdict. -7. **A deterministic proof** (`proof.rs`): a pinned FNV-1a witness over the +7. **The hyper-optimizer** (`optimize.rs`): derives the shipped shield config + rather than hand-picking it — the throughput-optimal feedback resolution and + the minimum rotation-mixing budget that collapses re-ID robustly (across both + attacker metrics and N∈{16,32}), plus a Pareto frontier. +8. **A deterministic proof** (`proof.rs`): a pinned FNV-1a witness over the reference experiment (the `nvsim`/`verify.py` discipline). ### 2.1 Why the keyed Givens rotation @@ -103,17 +107,38 @@ precoding idea (cf. MIMOCrypt) specialized to the identity-bearing subspace. ### 2.2 Measured behavior (SYNTHETIC / L0) -Reference experiment (default scene, N=16 identities, `cargo test`): +Reference experiment at the hyper-optimized operating point (§opt), default +scene, N=16 identities, `cargo test`: | Metric | Shield off | Shield on | |---|---|---| -| Passive re-ID accuracy | 100.0% | **7.8%** (chance 6.25%) | -| Link throughput ratio | 100% | **98.0%** | +| Passive re-ID accuracy | 100.0% | **4.7%** (chance 6.25%) | +| Link throughput ratio | 100% | **97.6%** | | Emission energy ratio | — | **1.000000** (compliant) | -All 29 unit/proof tests + doctest pass; the crate builds for +All 35 unit/proof tests + doctest pass; the crate builds for `wasm32-unknown-unknown` and is clippy-clean. +### opt. Hyper-optimization (`optimize.rs`) + +The shipped shield config is the optimizer's output, not a guess, and +`ShieldConfig::default()` is asserted equal to it: + +- **Feedback resolution = 5 bits.** Throughput has an interior optimum in + feedback bits (residual falls, feedback airtime rises); the unconstrained + optimum is 3 bits (matching DySPAN-2026), and 5 is the throughput-best value in + the spec-allowed 802.11 {5,7,9} set. +- **Givens passes = 96.** The proven minimum for robust collapse — across both + attacker metrics *and* N∈{16,32} — is **48**; the shipped 96 is a free 2× + privacy margin, since the keyed rotation is derived from the shared secret and + never signaled (extra passes cost compute, not airtime). The original + hand-picked 112 was 2.3× over-provisioned. + +Net vs. the original hand-picked (112 passes / 7 bits): the optimum is strictly +better on **both** privacy (re-ID 0.047 vs 0.078) and throughput (0.976 vs 0.974), +and is now verified rather than assumed. See +`docs/research/privacy-shield/08-optimization.md`. + ## 3. What this explicitly is NOT - **Not a radio driver.** No RF frontend, no transmit path, no diff --git a/docs/research/privacy-shield/03-countermeasure-design.md b/docs/research/privacy-shield/03-countermeasure-design.md index 1d7b9a1d..8c3cf540 100644 --- a/docs/research/privacy-shield/03-countermeasure-design.md +++ b/docs/research/privacy-shield/03-countermeasure-design.md @@ -57,7 +57,9 @@ fine block. This choice gives three properties at once: the per-session key, derives the identical rotation schedule, and applies the inverse (negated angles, reversed order) to recover the true precoder. It pays only the tiny residual from quantizing the extra angles at `feedback_bits` - resolution — negligible at 7+ bits — plus the sounding overhead. + resolution — negligible across the 802.11 5–9-bit range — plus the sounding + overhead. (The throughput-optimal resolution is derived in + [08-optimization.md](08-optimization.md).) 3. **Fresh per session ⇒ unlinkable.** A different rotation each session means an A1 sniffer sees `R_e · signature` for a new random `R_e` every time. Averaging over sessions (the natural enrollment attack) drives @@ -89,25 +91,31 @@ saves the (already small) overhead when no sensing is present. | Givens algebra, energy conservation | `linalg` | `apply_givens`, `norm`, `dist_sq` | | SYNTHETIC two-subspace BFI model | `identity` | `SceneConfig`, `Channel`, `BfiSample` (`comm()`/`fine()`) | | The four controls (shield) | `protector` | `ShieldConfig`, `Protector::protect`/`recover`, `SensingDetector` | -| Passive re-ID adversary | `attacker` | `NearestCentroidAttacker` | -| Privacy–throughput tradeoff | `throughput` | `LinkModel::throughput_ratio`, `beamforming_residual` | +| Passive re-ID adversary | `attacker` | `NearestCentroidAttacker`, `Metric` | +| Privacy–throughput tradeoff | `throughput` | `LinkModel::throughput_ratio`, `beamforming_residual`, `feedback_airtime` | | "Not jamming" audit | `compliance` | `ComplianceReport::audit`/`is_compliant` | | Attacker-vs-protector head-to-head | `experiment` | `ExperimentConfig`, `run`, `ExperimentReport` | +| Config hyper-optimization | `optimize` | `hyper_optimize`, `min_givens_passes`, `pareto_frontier` | | Byte-stable deterministic witness | `proof` | `Proof::EXPECTED_WITNESS`, `Proof::witness` | --- -## 6. The privacy–throughput knob +## 6. The privacy–throughput knobs (and which the optimizer turns) -The design exposes one honest tuning knob, matching the literature: +- **`feedback_bits`:** the only knob with a genuine throughput tradeoff — + residual falls with bits, feedback airtime rises with them, so there is an + interior optimum (3 bits unconstrained; 5 bits within the 802.11-allowed set). + Privacy is unaffected by bits (the rotation is fresh regardless). +- **`givens_passes`:** the privacy/robustness knob. More mixing lowers re-ID at + **no throughput cost** (the keyed rotation is never signaled), so it trades + only compute. The optimizer finds the minimum for robust collapse and ships a + free 2× margin. +- **`sounding_overhead`:** a flat throughput cost from cadence randomization; + trades motion-obfuscation strength against airtime (outside the re-ID metric). -- **`feedback_bits` high (7–9):** the legitimate receiver's residual is ~1e-5 → - throughput ≈ baseline; privacy is full (rotation is fresh regardless of bits). - This is VEIL's operating point. -- **`feedback_bits` low (≤3) or extra additive dither:** more robustness to a - key-recovery-adjacent attacker, at measurable throughput cost. -- **`sounding_overhead`:** the dominant (small) throughput cost, from cadence - randomization; trades motion-obfuscation strength against airtime. +The `optimize` module turns these knobs deterministically — see +[08-optimization.md](08-optimization.md). It is what replaced the original +hand-picked config. The `throughput` module computes the ratio from these, so the tradeoff is inspectable rather than asserted (`cargo test throughput`). diff --git a/docs/research/privacy-shield/05-experiment-protocol.md b/docs/research/privacy-shield/05-experiment-protocol.md index cc168d77..c5a53b67 100644 --- a/docs/research/privacy-shield/05-experiment-protocol.md +++ b/docs/research/privacy-shield/05-experiment-protocol.md @@ -43,10 +43,12 @@ Overall `passed()` requires all four. --- -## 3. Results (SYNTHETIC, default configuration) +## 3. Results (SYNTHETIC, hyper-optimized default configuration) -Reproduce with `cargo test -p wifi-densepose-privshield` (all 29 tests + doctest -pass). Salient values from the reference run: +Reproduce with `cargo test -p wifi-densepose-privshield` (all 35 tests + doctest +pass). The default shield config is the `optimize` module's output — 96 Givens +passes at 5-bit feedback resolution (see +[08-optimization.md](08-optimization.md)). Salient values from the reference run: | Metric | Value | |---|---| @@ -54,15 +56,18 @@ pass). Salient values from the reference run: | Chance level | 6.25% | | Chance band (acceptance) | ≤ 15.5% | | **Re-ID accuracy, shield OFF** | **100.0%** | -| **Re-ID accuracy, shield ON** | **7.8%** | -| **Throughput ratio** | **97.9997%** | +| **Re-ID accuracy, shield ON** | **4.7%** | +| **Throughput ratio** | **97.60%** | | Emission energy ratio | 1.000000 | | Overall verdict | **PASS** | Reading the result: the attacker is a *perfect* re-identifier without protection -(the synthetic signatures are cleanly separable), and VEIL drives it to within -1.6 points of the ideal chance floor — while the modeled link keeps 98% of its -throughput and the emission conserves energy exactly (compliant, not jamming). +(the synthetic signatures are cleanly separable), and VEIL drives it *to the +chance floor* (4.7% sits just below the ideal 6.25%, i.e. no better than +guessing) — while the modeled link keeps 97.6% of its throughput and the +emission conserves energy exactly (compliant, not jamming). The same collapse +holds under a Cosine-metric attacker and at N=32, confirming it is a property of +the signal, not the classifier. --- diff --git a/docs/research/privacy-shield/07-implementation-and-roadmap.md b/docs/research/privacy-shield/07-implementation-and-roadmap.md index 080319b6..4dd10107 100644 --- a/docs/research/privacy-shield/07-implementation-and-roadmap.md +++ b/docs/research/privacy-shield/07-implementation-and-roadmap.md @@ -7,8 +7,9 @@ - **Reference crate** `v2/crates/wifi-densepose-privshield` (VEIL): a deterministic, dependency-free, WASM-ready pure-compute leaf implementing the full attacker-vs-protector experiment, the four compliant controls, the - throughput model, the compliance audit, and a byte-stable proof. 29 tests + - doctest pass; builds for `wasm32-unknown-unknown`; clippy-clean. + throughput model, the compliance audit, the `optimize` hyper-optimizer, and a + byte-stable proof. 35 tests + doctest pass; builds for + `wasm32-unknown-unknown`; clippy-clean. - **This research bundle** (`docs/research/privacy-shield/`). - **[ADR-288](../../adr/ADR-288-veil-privacy-shield-compliant-waveform.md)** — the formal decision record. diff --git a/docs/research/privacy-shield/08-optimization.md b/docs/research/privacy-shield/08-optimization.md new file mode 100644 index 00000000..11625e3c --- /dev/null +++ b/docs/research/privacy-shield/08-optimization.md @@ -0,0 +1,120 @@ +# 08 — Hyper-Optimization + +The reference crate first shipped a **hand-picked** shield config (112 Givens +passes, 7-bit feedback). This file records how the `optimize` module replaces +that guess with a *derived*, robustness-verified optimum, and what it found. All +numbers are **SYNTHETIC / L0**, reproduced by +`cargo test -p wifi-densepose-privshield`. + +--- + +## 1. What is being optimized, and against what + +Two knobs, two objectives, one hard constraint: + +| Knob | Costs | Does it trade against privacy? | +|---|---|---| +| `feedback_bits` (angle resolution) | Throughput: **residual** falls with bits, **feedback airtime** rises with bits | No — the keyed rotation is applied regardless of resolution | +| `givens_passes` (rotation mixing) | Compute only | Yes — more mixing ⇒ lower re-ID | + +**Constraint:** re-ID must collapse into the chance band `1/N · 2 + 0.03` — and +it must do so *robustly*: for **both** attacker metrics (Euclidean and Cosine) +and **both** identity counts (N = 16 and N = 32, the harder, lower-chance case). + +The key structural fact: **rotation mixing is throughput-free.** The per-session +rotation is derived from the shared link secret on both ends (like MIMOCrypt) — +it is never transmitted — so extra Givens passes cost compute, not airtime. That +means privacy margin is essentially free; the only throughput tradeoff lives in +`feedback_bits`. + +--- + +## 2. Throughput is a 1-D problem with an interior optimum + +Because the residual falls with bits while feedback airtime rises, throughput +has a genuine interior optimum in `feedback_bits` (`LinkModel`, default SNR 20 dB, +`feedback_overhead_per_bit = 0.0008`): + +| bits | throughput ratio | +|---|---| +| 1 | 0.9681 | +| 2 | 0.9757 | +| **3** | **0.9769** ← unconstrained optimum | +| 4 | 0.9766 | +| **5** | **0.9760** ← shipped (spec-allowed) | +| 7 | 0.9744 (the old hand-picked value) | +| 9 | 0.9728 | +| 12 | 0.9704 | + +The unconstrained optimum is **3 bits** — which coincides with the DySPAN-2026 +MEASURED finding that ~3-bit feedback is the privacy–utility sweet spot, because +the receiver compensates the keyed rotation and extra bits mostly buy airtime. +802.11 compressed beamforming quantizes ψ/φ to roughly 5–9 bits, so the shipped +shield uses the throughput-best **spec-allowed** value, **5 bits** (0.9760), +rather than the out-of-spec 3-bit optimum. Either way it beats the old 7-bit +choice. + +--- + +## 3. Mixing: the minimum robust budget, and a free margin + +Worst-case shield-on re-ID vs. `givens_passes` (bits = 5; worst over Euclidean +and Cosine): + +| passes | re-ID @ N=16 | re-ID @ N=32 | robust collapse? | +|---|---|---|---| +| 16 | 0.75 | 0.62 | no | +| 24 | 0.50 | 0.35 | no | +| 32 | 0.20 | 0.14 | no (N=32 band is 0.0925) | +| **48** | 0.12 | 0.057 | **yes** ← proven minimum | +| 64 | 0.078 | 0.044 | yes | +| **96** | **0.047** | **0.018** | **yes** ← shipped (2× margin) | +| 112 | 0.078 | 0.042 | yes (the old default — no better than 96) | + +The proven minimum for robust collapse is **48 passes** — the hand-picked 112 was +**2.3× over-provisioned**. Since mixing is throughput-free, the shield ships +**96 passes** (`PRIVACY_MARGIN_FACTOR = 2` × 48, rounded up to a candidate): it +drives re-ID *below chance* at N=16 (0.047 < 0.0625) at zero throughput cost, and +is still cheaper compute than the original 112. + +--- + +## 4. The adopted config, and why it beats the original + +| | Old (hand-picked) | Hyper-optimized (shipped) | +|---|---|---| +| Givens passes | 112 | **96** (from proven-min 48 × 2) | +| Feedback bits | 7 | **5** (spec-optimal) | +| Shield-on re-ID (N=16) | 0.078 | **0.047** | +| Throughput ratio | 0.9744 | **0.9760** | +| Robust across metrics & N | not checked | **verified** | + +The optimum is **strictly better on privacy and throughput at once**, and is now +*verified* rather than assumed. `ShieldConfig::default()` is exactly the +optimizer's output; the test `optimize::shipped_default_equals_optimizer_output` +fails if they ever drift apart. + +--- + +## 5. The Pareto frontier (and an honest note) + +`optimize::pareto_frontier` enumerates non-dominated (worst-case re-ID, +throughput) points over a pass × bits grid. In this model the frontier +**collapses toward the max-mixing, 5-bit point**, because mixing is +throughput-free — so beyond the throughput knob (bits) there is no privacy– +throughput tradeoff to trace. That degeneracy is itself the finding: *the only +thing privacy costs here is feedback resolution, and even that is cheap.* On real +hardware, where comm/identity subspaces are only approximately separable and +where more aggressive mixing may touch the data-carrying beam, this frontier is +expected to open up — a hardware study (roadmap P5) will re-measure it. + +--- + +## 6. Robustness caveats (unchanged from the threat model) + +- The collapse is verified against two classifiers and two N; a learned + attacker on real captures must still be checked (P2/P5). +- `feedback_bits` affects only throughput in this model, not re-ID; on hardware, + coarse quantization also adds obfuscation, which would *help* privacy — the + model conservatively ignores that. +- All optimization results are SYNTHETIC until a hardware witness exists. diff --git a/docs/research/privacy-shield/README.md b/docs/research/privacy-shield/README.md index 22163516..f49380ac 100644 --- a/docs/research/privacy-shield/README.md +++ b/docs/research/privacy-shield/README.md @@ -27,6 +27,7 @@ transmission (the statutory definition of jamming, 47 U.S.C. §333/§302a). | [05-experiment-protocol.md](05-experiment-protocol.md) | The attacker-vs-protector experiment: metrics, acceptance bar, reproducer, and results | | [06-market-and-buyers.md](06-market-and-buyers.md) | First buyers, procurement drivers, competitive landscape, and the standards-body gap | | [07-implementation-and-roadmap.md](07-implementation-and-roadmap.md) | Crate layout, reuse map, hardware path, phased rollout, and open problems | +| [08-optimization.md](08-optimization.md) | Hyper-optimization: throughput-optimal feedback resolution, minimum robust mixing budget, Pareto frontier, and the adopted config | Formal decision: [ADR-288](../../adr/ADR-288-veil-privacy-shield-compliant-waveform.md). Reference implementation: [`v2/crates/wifi-densepose-privshield`](../../../v2/crates/wifi-densepose-privshield). @@ -66,11 +67,15 @@ Reference implementation: [`v2/crates/wifi-densepose-privshield`](../../../v2/cr legitimate receiver inverts it ⇒ throughput preserved), and *fresh each session* (a sniffer cannot average it back ⇒ re-ID collapses to chance). -5. **Measured on the reference model (SYNTHETIC).** On the default synthetic - scene (16 candidate identities), a passive nearest-centroid re-identifier - scores **100% with the shield off** and **7.8% with it on** (chance = 6.25%), - while modeled link throughput stays at **98.0%** of baseline and the emission - energy ratio is **1.000000** (compliant). Reproduce: +5. **Measured on the reference model (SYNTHETIC), at the hyper-optimized + operating point.** On the default synthetic scene (16 candidate identities), + a passive re-identifier scores **100% with the shield off** and **4.7% with + it on** (chance = 6.25%), while modeled link throughput stays at **97.6%** of + baseline and the emission energy ratio is **1.000000** (compliant). The shield + config is chosen by the `optimize` module — 96 Givens passes (2× the proven- + minimum 48 for robust collapse across both attacker metrics and N∈{16,32}) at + 5-bit feedback resolution — not hand-picked (see + [08-optimization.md](08-optimization.md)). Reproduce: `cargo test -p wifi-densepose-privshield`. 6. **Scope, honestly.** VEIL defends against a *third-party passive sniffer*. It diff --git a/v2/crates/wifi-densepose-privshield/README.md b/v2/crates/wifi-densepose-privshield/README.md index 7deb4cbd..365a65ea 100644 --- a/v2/crates/wifi-densepose-privshield/README.md +++ b/v2/crates/wifi-densepose-privshield/README.md @@ -29,14 +29,21 @@ from — over the *fine* subspace only: | **Keyed per session** | The legitimate AP inverts it ⇒ throughput preserved | | **Fresh each session** | A sniffer sees a different rotation every time and can't average it back ⇒ re-identification collapses to chance | -## Result (default synthetic scene, N = 16 identities) +## Result (hyper-optimized default scene, N = 16 identities) | Metric | Shield off | Shield on | |---|---|---| -| Passive re-ID accuracy | **100%** | **7.8%** (chance = 6.25%) | -| Link throughput ratio | 100% | **98.0%** | +| Passive re-ID accuracy | **100%** | **4.7%** (chance = 6.25%) | +| Link throughput ratio | 100% | **97.6%** | | Emission energy ratio | — | **1.000000** (compliant) | +The shipped shield config is not hand-picked — it is the output of the +`optimize` module (ADR-288 §opt): **96 Givens passes** (2× the proven-minimum +48 for robust collapse across both attacker metrics and N∈{16,32}; extra passes +are free because the keyed rotation is never signaled) at **5-bit** feedback +resolution (the throughput-best value in the 802.11 {5,7,9} set). The +unconstrained model optimum is 3-bit, matching the DySPAN-2026 finding. + ## Threat model & scope (stated plainly) VEIL defends against a **third-party passive sniffer** capturing plaintext @@ -62,8 +69,9 @@ cargo test -p wifi-densepose-privshield --no-default-features | `linalg` | Givens-rotation vector algebra | | `identity` | SYNTHETIC two-subspace beamforming-feedback model | | `protector` | The compliant waveform controls (the shield) | -| `attacker` | Passive re-identification adversary | -| `throughput` | Link-throughput model | +| `attacker` | Passive re-identification adversary (Euclidean + Cosine metrics) | +| `throughput` | Link-throughput model (residual + feedback-airtime + sounding) | | `compliance` | Machine-checkable "not jamming" audit | | `experiment` | Attacker-vs-protector head-to-head | +| `optimize` | Finds the optimal shield config (feedback bits, min passes, Pareto frontier) | | `proof` | Byte-stable deterministic witness | diff --git a/v2/crates/wifi-densepose-privshield/src/attacker.rs b/v2/crates/wifi-densepose-privshield/src/attacker.rs index eabf82b4..5ab56e78 100644 --- a/v2/crates/wifi-densepose-privshield/src/attacker.rs +++ b/v2/crates/wifi-densepose-privshield/src/attacker.rs @@ -17,22 +17,47 @@ //! strength is not the lever; signature stability is. use crate::identity::BfiSample; -use crate::linalg::dist_sq; +use crate::linalg::{dist_sq, dot, norm}; + +/// Similarity metric the attacker uses to match a capture to a centroid. +/// +/// Sweeping the metric is how [`crate::optimize`] checks that the shield's +/// collapse is a property of the *signal* (a rotated signature carries no +/// stable identity), not an artifact of one classifier's geometry. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Metric { + /// Euclidean nearest-centroid (default). Sensitive to magnitude. + #[default] + Euclidean, + /// Cosine nearest-centroid. Scale-invariant; a natural stronger attacker + /// against energy-preserving perturbations, since it ignores magnitude. + Cosine, +} /// A nearest-centroid re-identification attacker. #[derive(Debug, Clone, Default)] pub struct NearestCentroidAttacker { centroids: Vec>, ids: Vec, + metric: Metric, } impl NearestCentroidAttacker { - /// Build an empty attacker. + /// Build an empty attacker using the Euclidean metric. #[must_use] pub fn new() -> Self { Self::default() } + /// Build an empty attacker using the given metric. + #[must_use] + pub fn with_metric(metric: Metric) -> Self { + Self { + metric, + ..Self::default() + } + } + /// Enroll from labeled captures: one centroid per identity, the mean of /// that identity's observed report vectors. pub fn enroll(&mut self, samples: &[(usize, BfiSample)]) { @@ -68,9 +93,24 @@ impl NearestCentroidAttacker { /// predicted identity, or `None` if the attacker has not enrolled. #[must_use] pub fn classify(&self, sample: &BfiSample) -> Option { + // Score is "lower is better" for both metrics: Euclidean uses squared + // distance; Cosine uses the negated similarity. + let score = |c: &[f32]| -> f32 { + match self.metric { + Metric::Euclidean => dist_sq(c, &sample.values), + Metric::Cosine => { + let denom = norm(c) * norm(&sample.values); + if denom > 1e-12 { + -dot(c, &sample.values) / denom + } else { + 0.0 + } + } + } + }; let mut best: Option<(usize, f32)> = None; for (id, c) in self.ids.iter().zip(&self.centroids) { - let d = dist_sq(c, &sample.values); + let d = score(c); if best.is_none_or(|(_, bd)| d < bd) { best = Some((*id, d)); } diff --git a/v2/crates/wifi-densepose-privshield/src/experiment.rs b/v2/crates/wifi-densepose-privshield/src/experiment.rs index e1810252..2ea06467 100644 --- a/v2/crates/wifi-densepose-privshield/src/experiment.rs +++ b/v2/crates/wifi-densepose-privshield/src/experiment.rs @@ -13,7 +13,7 @@ //! 2. throughput stays above 95% of the unshielded baseline; //! 3. the control is compliant (energy-preserving, non-jamming). -use crate::attacker::NearestCentroidAttacker; +use crate::attacker::{Metric, NearestCentroidAttacker}; use crate::compliance::ComplianceReport; use crate::identity::{Channel, SceneConfig}; use crate::prng::derive_key; @@ -40,6 +40,8 @@ pub struct ExperimentConfig { pub chance_margin: f32, /// Minimum acceptable throughput ratio. pub min_throughput_ratio: f64, + /// Metric the passive attacker uses. + pub attacker_metric: Metric, } impl Default for ExperimentConfig { @@ -53,6 +55,7 @@ impl Default for ExperimentConfig { chance_multiple: 2.0, chance_margin: 0.03, min_throughput_ratio: 0.95, + attacker_metric: Metric::Euclidean, } } } @@ -110,8 +113,12 @@ impl ExperimentReport { /// Build the enroll/test capture sets for a given shield, then measure attacker /// accuracy. `shield_on` selects whether the protector is applied to every /// captured frame (the attacker only ever sees what is transmitted). -fn measure_accuracy(cfg: &ExperimentConfig, protector: &Protector, shield_on: bool) -> f32 { - let ch = Channel::new(cfg.scene.clone()); +fn measure_accuracy( + cfg: &ExperimentConfig, + ch: &Channel, + protector: &Protector, + shield_on: bool, +) -> f32 { let mut enroll = Vec::new(); let mut test = Vec::new(); @@ -143,7 +150,7 @@ fn measure_accuracy(cfg: &ExperimentConfig, protector: &Protector, shield_on: bo } } - let mut atk = NearestCentroidAttacker::new(); + let mut atk = NearestCentroidAttacker::with_metric(cfg.attacker_metric); atk.enroll(&enroll); atk.accuracy(&test) } @@ -152,14 +159,14 @@ fn measure_accuracy(cfg: &ExperimentConfig, protector: &Protector, shield_on: bo #[must_use] pub fn run(cfg: &ExperimentConfig) -> ExperimentReport { let protector = Protector::new(cfg.shield.clone()); + let ch = Channel::new(cfg.scene.clone()); - let accuracy_shield_off = measure_accuracy(cfg, &protector, false); - let accuracy_shield_on = measure_accuracy(cfg, &protector, true); + let accuracy_shield_off = measure_accuracy(cfg, &ch, &protector, false); + let accuracy_shield_on = measure_accuracy(cfg, &ch, &protector, true); let throughput_ratio = cfg.link.throughput_ratio(&cfg.shield); // Representative compliance audit: one protected frame vs its clean form. - let ch = Channel::new(cfg.scene.clone()); let clean = ch.observe(0, b"test", 0); let protected = protector.protect(&clean, derive_key(cfg.scene.seed, b"rot-test", 0, 0)); let compliance = ComplianceReport::audit(&clean, &protected); diff --git a/v2/crates/wifi-densepose-privshield/src/lib.rs b/v2/crates/wifi-densepose-privshield/src/lib.rs index e8036267..27a0f5eb 100644 --- a/v2/crates/wifi-densepose-privshield/src/lib.rs +++ b/v2/crates/wifi-densepose-privshield/src/lib.rs @@ -69,14 +69,17 @@ pub mod compliance; pub mod experiment; pub mod identity; pub mod linalg; +pub mod optimize; pub mod prng; pub mod proof; pub mod protector; pub mod throughput; +pub use attacker::{Metric, NearestCentroidAttacker}; pub use compliance::ComplianceReport; pub use experiment::{run, ExperimentConfig, ExperimentReport}; pub use identity::{BfiSample, Channel, SceneConfig}; +pub use optimize::{hyper_optimize, HyperOptimized}; pub use proof::Proof; pub use protector::{Protector, SensingDetector, ShieldConfig}; pub use throughput::LinkModel; diff --git a/v2/crates/wifi-densepose-privshield/src/optimize.rs b/v2/crates/wifi-densepose-privshield/src/optimize.rs new file mode 100644 index 00000000..12235805 --- /dev/null +++ b/v2/crates/wifi-densepose-privshield/src/optimize.rs @@ -0,0 +1,309 @@ +//! Hyper-optimization of the shield's operating point. +//! +//! The reference crate shipped a hand-picked shield config. This module finds +//! the *optimal* one deterministically, and — crucially — proves the optimum is +//! robust rather than tuned to one attacker or one identity count: +//! +//! - [`optimal_feedback_bits`] finds the throughput-maximizing feedback +//! resolution, exploiting the interior optimum the [`crate::throughput`] model +//! exposes (residual falls with bits, airtime rises). +//! - [`min_givens_passes`] finds the **smallest** rotation-mixing budget that +//! still drives re-identification into the chance band — checked against +//! *every* attacker [`Metric`] and *every* identity count in a robustness set, +//! so the answer is the minimum that survives the hardest case, not the +//! easiest. +//! - [`pareto_frontier`] enumerates the non-dominated (privacy, throughput) +//! points for documentation and inspection. +//! - [`hyper_optimize`] combines the two into a ready-to-ship [`ShieldConfig`] +//! plus the verifying [`ExperimentReport`]. +//! +//! Optimizing over both metrics and multiple `N` is the point: if the collapse +//! held only for Euclidean at N=16, it would be a classifier artifact. It holds +//! across the set because a session-fresh secret rotation removes stable +//! identity information from the *signal*. + +use crate::attacker::Metric; +use crate::experiment::{run, ExperimentConfig, ExperimentReport}; +use crate::protector::ShieldConfig; + +/// Attacker metrics the optimizer must satisfy simultaneously. +pub const ROBUSTNESS_METRICS: [Metric; 2] = [Metric::Euclidean, Metric::Cosine]; + +/// Identity counts the optimizer must satisfy simultaneously. Larger `N` has a +/// lower chance floor, so it is the harder collapse target. +pub const ROBUSTNESS_IDENTITIES: [usize; 2] = [16, 32]; + +/// Candidate Givens-pass budgets, ascending. The optimizer returns the first +/// that collapses re-ID across the whole robustness set. +pub const PASS_CANDIDATES: [usize; 12] = [2, 4, 6, 8, 12, 16, 24, 32, 48, 64, 96, 112]; + +/// Per-angle feedback resolutions 802.11 compressed beamforming actually uses +/// (ψ/φ are quantized to roughly 5–9 bits). The shipped shield picks the +/// throughput-best value from this *spec-allowed* set, not the unconstrained +/// model optimum, so the config stays standards-faithful. +pub const ALLOWED_FEEDBACK_BITS: [u32; 3] = [5, 7, 9]; + +/// Safety margin applied to the proven-minimum pass budget. Rotation mixing is +/// keyed (derived from the shared link secret, never signaled), so extra passes +/// cost compute but **no** throughput — we spend a 2× margin on privacy for +/// free. +pub const PRIVACY_MARGIN_FACTOR: usize = 2; + +/// Run one experiment variant with the given knobs, holding everything else at +/// `base`. +fn run_variant( + base: &ExperimentConfig, + passes: usize, + bits: u32, + metric: Metric, + identities: usize, +) -> ExperimentReport { + let mut cfg = base.clone(); + cfg.shield = ShieldConfig { + givens_passes: passes, + feedback_bits: bits, + ..base.shield.clone() + }; + cfg.scene.identities = identities; + cfg.attacker_metric = metric; + run(&cfg) +} + +/// Throughput of the base link at a given feedback resolution. +fn throughput_at_bits(base: &ExperimentConfig, bits: u32) -> f64 { + base.link.throughput_ratio(&ShieldConfig { + feedback_bits: bits, + ..base.shield.clone() + }) +} + +/// Find the throughput-maximizing `feedback_bits` in `1..=max_bits` +/// (unconstrained model optimum). Returns `(bits, throughput_ratio)`. +#[must_use] +pub fn optimal_feedback_bits(base: &ExperimentConfig, max_bits: u32) -> (u32, f64) { + (1..=max_bits) + .map(|bits| (bits, throughput_at_bits(base, bits))) + .max_by(|a, b| a.1.partial_cmp(&b.1).unwrap()) + .unwrap_or((base.shield.feedback_bits, 0.0)) +} + +/// Find the throughput-maximizing feedback resolution within the spec-allowed +/// set [`ALLOWED_FEEDBACK_BITS`]. This is what the shipped shield uses. +#[must_use] +pub fn spec_optimal_feedback_bits(base: &ExperimentConfig) -> (u32, f64) { + ALLOWED_FEEDBACK_BITS + .iter() + .map(|&bits| (bits, throughput_at_bits(base, bits))) + .max_by(|a, b| a.1.partial_cmp(&b.1).unwrap()) + .unwrap() +} + +/// Does `passes` collapse re-ID into the chance band for *every* metric and +/// *every* identity count in the robustness set? +#[must_use] +pub fn passes_collapse_robustly(base: &ExperimentConfig, passes: usize, bits: u32) -> bool { + for &n in &ROBUSTNESS_IDENTITIES { + for &m in &ROBUSTNESS_METRICS { + if !run_variant(base, passes, bits, m, n).drives_to_chance() { + return false; + } + } + } + true +} + +/// Smallest Givens-pass budget from [`PASS_CANDIDATES`] that collapses re-ID +/// robustly, or `None` if even the largest candidate fails. +#[must_use] +pub fn min_givens_passes(base: &ExperimentConfig, bits: u32) -> Option { + PASS_CANDIDATES + .iter() + .copied() + .find(|&p| passes_collapse_robustly(base, p, bits)) +} + +/// One point on the privacy–throughput tradeoff. +#[derive(Debug, Clone, PartialEq)] +pub struct ParetoPoint { + /// Givens-pass budget. + pub givens_passes: usize, + /// Feedback resolution in bits. + pub feedback_bits: u32, + /// Worst-case (highest) re-ID accuracy over the robustness metrics at the + /// base identity count. + pub worst_reid: f32, + /// Modeled throughput ratio. + pub throughput_ratio: f64, + /// Whether this point collapses re-ID robustly (all metrics, all N). + pub robustly_private: bool, +} + +/// Enumerate the non-dominated (lower re-ID, higher throughput) points over a +/// grid of pass budgets and feedback resolutions. +#[must_use] +pub fn pareto_frontier(base: &ExperimentConfig, max_bits: u32) -> Vec { + let mut points: Vec = Vec::new(); + for &passes in &PASS_CANDIDATES { + for bits in 1..=max_bits { + // Worst-case re-ID over metrics at the base identity count. + let worst_reid = ROBUSTNESS_METRICS + .iter() + .map(|&m| { + run_variant(base, passes, bits, m, base.scene.identities).accuracy_shield_on + }) + .fold(0.0_f32, f32::max); + let shield = ShieldConfig { + givens_passes: passes, + feedback_bits: bits, + ..base.shield.clone() + }; + points.push(ParetoPoint { + givens_passes: passes, + feedback_bits: bits, + worst_reid, + throughput_ratio: base.link.throughput_ratio(&shield), + robustly_private: passes_collapse_robustly(base, passes, bits), + }); + } + } + // Keep only non-dominated points: no other point has both lower-or-equal + // re-ID and higher-or-equal throughput while being strictly better in one. + points + .iter() + .filter(|p| { + !points.iter().any(|q| { + let better_or_eq = + q.worst_reid <= p.worst_reid && q.throughput_ratio >= p.throughput_ratio; + let strictly_better = + q.worst_reid < p.worst_reid || q.throughput_ratio > p.throughput_ratio; + better_or_eq && strictly_better + }) + }) + .cloned() + .collect() +} + +/// The chosen optimum plus the report that verifies it. +#[derive(Debug, Clone)] +pub struct HyperOptimized { + /// The optimized, ready-to-ship shield configuration. + pub shield: ShieldConfig, + /// Minimum Givens passes that collapses re-ID robustly (before the margin). + pub min_passes: usize, + /// Shipped Givens passes = `min_passes` grown by [`PRIVACY_MARGIN_FACTOR`]. + pub shipped_passes: usize, + /// Unconstrained throughput-optimal feedback resolution (a research point). + pub model_optimal_bits: u32, + /// Spec-allowed throughput-optimal resolution (what the shield ships with). + pub spec_optimal_bits: u32, + /// The verifying experiment at the base identity count. + pub report: ExperimentReport, +} + +/// Smallest pass candidate that is at least `target`. +fn ceil_to_candidate(target: usize) -> usize { + PASS_CANDIDATES + .iter() + .copied() + .find(|&p| p >= target) + .unwrap_or_else(|| *PASS_CANDIDATES.last().unwrap()) +} + +/// Find the optimal shield: the spec-allowed throughput-optimal feedback +/// resolution, and the minimum rotation-mixing budget that collapses re-ID +/// robustly, grown by a free privacy margin. Deterministic and idempotent — the +/// shipped [`ShieldConfig::default`] is exactly this function's output on the +/// default base (asserted in tests). +#[must_use] +pub fn hyper_optimize(base: &ExperimentConfig) -> HyperOptimized { + let (model_optimal_bits, _) = optimal_feedback_bits(base, 12); + let (spec_optimal_bits, _) = spec_optimal_feedback_bits(base); + + let min_passes = min_givens_passes(base, spec_optimal_bits) + .unwrap_or_else(|| *PASS_CANDIDATES.last().unwrap()); + let shipped_passes = ceil_to_candidate(min_passes * PRIVACY_MARGIN_FACTOR); + + let shield = ShieldConfig { + givens_passes: shipped_passes, + feedback_bits: spec_optimal_bits, + ..base.shield.clone() + }; + let mut cfg = base.clone(); + cfg.shield = shield.clone(); + let report = run(&cfg); + + HyperOptimized { + shield, + min_passes, + shipped_passes, + model_optimal_bits, + spec_optimal_bits, + report, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn model_optimal_bits_is_interior() { + let (bits, ratio) = optimal_feedback_bits(&ExperimentConfig::default(), 12); + assert!(bits > 1 && bits < 12, "optimum at edge: {bits}"); + assert!(ratio > 0.95); + } + + #[test] + fn spec_optimal_bits_is_the_low_res_end() { + // Within {5,7,9}, lower resolution wins because the receiver compensates + // the keyed rotation, so extra bits mostly buy airtime. + let (bits, _) = spec_optimal_feedback_bits(&ExperimentConfig::default()); + assert_eq!(bits, 5); + } + + #[test] + fn min_passes_is_below_the_original_default() { + // The original hand-picked default was 112 passes. The optimizer proves + // far fewer suffice — the "we over-provisioned" finding. + let (bits, _) = spec_optimal_feedback_bits(&ExperimentConfig::default()); + let p = min_givens_passes(&ExperimentConfig::default(), bits).expect("collapses"); + assert!(p < 112, "min passes {p} should be below the old 112"); + assert!(p >= 2); + } + + #[test] + fn shipped_default_equals_optimizer_output() { + // The crate's default shield IS the optimizer's recommendation — they + // cannot silently drift apart. + let opt = hyper_optimize(&ExperimentConfig::default()); + assert_eq!( + opt.shield.givens_passes, + ShieldConfig::default().givens_passes + ); + assert_eq!( + opt.shield.feedback_bits, + ShieldConfig::default().feedback_bits + ); + assert!(opt.report.passed(), "{:#?}", opt.report); + } + + #[test] + fn optimum_collapses_under_both_metrics_and_larger_n() { + let opt = hyper_optimize(&ExperimentConfig::default()); + assert!(passes_collapse_robustly( + &ExperimentConfig::default(), + opt.shipped_passes, + opt.spec_optimal_bits + )); + } + + #[test] + fn frontier_is_non_empty_and_deterministic() { + // Small grid keeps this fast; the frontier logic is grid-size agnostic. + let base = ExperimentConfig::default(); + let a = pareto_frontier(&base, 3); + let b = pareto_frontier(&base, 3); + assert!(!a.is_empty()); + assert_eq!(a, b); + } +} diff --git a/v2/crates/wifi-densepose-privshield/src/proof.rs b/v2/crates/wifi-densepose-privshield/src/proof.rs index ed975df3..b1124916 100644 --- a/v2/crates/wifi-densepose-privshield/src/proof.rs +++ b/v2/crates/wifi-densepose-privshield/src/proof.rs @@ -20,7 +20,7 @@ pub struct Proof; impl Proof { /// Pinned witness over the reference experiment. Re-derived by /// [`Proof::witness`]; asserted by the test below. - pub const EXPECTED_WITNESS: u64 = 0xD098_C38D_B7C6_BCA9; + pub const EXPECTED_WITNESS: u64 = 0x350D_7CDF_95D9_F448; /// The reference configuration. Uses every default so the proof tracks the /// shipped behavior of the crate. diff --git a/v2/crates/wifi-densepose-privshield/src/protector.rs b/v2/crates/wifi-densepose-privshield/src/protector.rs index 0198c975..96a3a6e7 100644 --- a/v2/crates/wifi-densepose-privshield/src/protector.rs +++ b/v2/crates/wifi-densepose-privshield/src/protector.rs @@ -40,8 +40,10 @@ pub struct ShieldConfig { /// (used to model the "shield off" baseline). pub enabled: bool, /// Number of keyed Givens rotations composed per session. Enough passes - /// (≈ `2 × fine_dims`) approximate a Haar-random rotation of the fine - /// block, which is what drives the attacker to chance. + /// approximate a Haar-random rotation of the fine block, which is what + /// drives the attacker to chance. The optimal value is found by + /// [`crate::optimize`] (not hand-tuned); more passes cost compute but no + /// throughput, since the rotation is keyed rather than signaled. pub givens_passes: usize, /// Bits used to quantize each reported angle (802.11 uses 5–9). Higher /// resolution ⇒ smaller uncompensated residual at the legitimate receiver @@ -54,10 +56,16 @@ pub struct ShieldConfig { impl Default for ShieldConfig { fn default() -> Self { + // These values are the output of `optimize::hyper_optimize` on the + // default scene (ADR-288 §opt), not hand-picked: 96 = 2× the proven- + // minimum 48 robust passes (free margin, since mixing is keyed not + // signaled), and 5 = the throughput-best resolution in the 802.11 + // {5,7,9} set. `optimize::shipped_default_equals_optimizer_output` + // guards against drift. Self { enabled: true, - givens_passes: 112, // 2 × 56 fine dims at the default scene - feedback_bits: 7, + givens_passes: 96, + feedback_bits: 5, sounding_overhead: 0.02, } } diff --git a/v2/crates/wifi-densepose-privshield/src/throughput.rs b/v2/crates/wifi-densepose-privshield/src/throughput.rs index e249f496..ddd1837c 100644 --- a/v2/crates/wifi-densepose-privshield/src/throughput.rs +++ b/v2/crates/wifi-densepose-privshield/src/throughput.rs @@ -2,23 +2,31 @@ //! //! The claim under test is "throughput stays above 95% with the shield on". //! The model is intentionally transparent and errs toward *charging* the -//! shield, not flattering it: +//! shield, not flattering it. Three costs are charged: //! //! - **Beamforming residual.** The legitimate receiver shares the session key //! and inverts the protector's rotation, so it does not pay the rotation //! itself — only the residual from quantizing the extra angles at //! `feedback_bits` resolution. Per-angle mean-square quantization error is //! `Δ²/12` for step `Δ = (π/2)/2^bits`; this fraction of beamforming gain is -//! lost. At 7 bits it is ~1e-5 — negligible, which matches the DySPAN-2026 -//! finding that fine feedback resolution makes the privacy–utility tradeoff -//! nearly free. +//! lost. It shrinks fast with more bits. +//! - **Feedback airtime.** Reporting the angles at higher resolution costs more +//! uplink airtime — charged as `feedback_overhead_per_bit · feedback_bits`. +//! It grows with more bits. //! - **Sounding overhead.** Randomizing the NDP sounding cadence costs airtime -//! directly; charged as a flat `sounding_overhead` fraction of throughput. +//! directly; a flat `sounding_overhead` fraction. //! -//! Throughput ratio = `(1 − overhead) · C(SNR·(1−ρ)) / C(SNR)` where -//! `C(x) = log2(1 + x)` is the Shannon capacity of the data-carrying beam. The -//! comm block is never perturbed, so its geometry is intact; only the SNR is -//! nudged by the residual `ρ`. +//! The residual (falling) and the feedback airtime (rising) pull `feedback_bits` +//! in opposite directions, so throughput has a genuine **interior optimum** in +//! the number of feedback bits — the quantity [`crate::optimize`] searches for. +//! The optimum lands at coarse-to-moderate resolution because the receiver +//! compensates the keyed rotation, so extra bits mostly buy airtime, not gain — +//! echoing the DySPAN-2026 finding that ~3-bit feedback is near the sweet spot. +//! +//! Throughput ratio = +//! `(1 − sounding − feedback_airtime) · C(SNR·(1−ρ)) / C(SNR)` where +//! `C(x) = log2(1 + x)`. The comm block is never perturbed, so its geometry is +//! intact; only the SNR is nudged by the residual `ρ`. use crate::protector::ShieldConfig; @@ -27,11 +35,17 @@ use crate::protector::ShieldConfig; pub struct LinkModel { /// Operating SNR of the data-carrying beam, in dB. pub snr_db: f64, + /// Uplink airtime charged per feedback bit, as a fraction of throughput. + /// Larger values push the throughput-optimal `feedback_bits` lower. + pub feedback_overhead_per_bit: f64, } impl Default for LinkModel { fn default() -> Self { - Self { snr_db: 20.0 } + Self { + snr_db: 20.0, + feedback_overhead_per_bit: 0.0008, + } } } @@ -60,6 +74,15 @@ impl LinkModel { (step * step / 12.0).min(0.5) } + /// Uplink airtime cost of reporting angles at `feedback_bits` resolution. + #[must_use] + pub fn feedback_airtime(&self, shield: &ShieldConfig) -> f64 { + if !shield.enabled { + return 0.0; + } + self.feedback_overhead_per_bit * f64::from(shield.feedback_bits) + } + /// Throughput ratio of the protected link versus the unshielded baseline, /// in `[0, 1]`. #[must_use] @@ -69,9 +92,9 @@ impl LinkModel { } let rho = Self::beamforming_residual(shield); let snr = self.snr_linear(); - let protected = (1.0 + snr * (1.0 - rho)).log2(); - let ratio = protected / self.baseline_capacity(); - ((1.0 - shield.sounding_overhead) * ratio).clamp(0.0, 1.0) + let capacity_ratio = (1.0 + snr * (1.0 - rho)).log2() / self.baseline_capacity(); + let airtime = shield.sounding_overhead + self.feedback_airtime(shield); + ((1.0 - airtime) * capacity_ratio).clamp(0.0, 1.0) } } @@ -89,27 +112,32 @@ mod tests { } #[test] - fn fine_resolution_is_nearly_free() { + fn default_config_preserves_throughput() { let ratio = LinkModel::default().throughput_ratio(&ShieldConfig::default()); assert!(ratio > 0.95, "ratio {ratio}"); - // Almost all of the (small) loss is the sounding overhead, not the - // perturbation — consistent with the DySPAN-2026 fine-resolution result. assert!(ratio < 1.0); } #[test] - fn coarse_resolution_costs_more() { - // Lowering feedback resolution raises the residual and lowers throughput - // — the tradeoff is real, just cheap at fine resolution. - let fine = ShieldConfig { - feedback_bits: 9, - ..ShieldConfig::default() - }; - let coarse = ShieldConfig { - feedback_bits: 2, - ..ShieldConfig::default() - }; + fn throughput_has_interior_optimum_in_bits() { + // Very low resolution pays the residual; very high resolution pays + // airtime. The optimum is strictly interior — neither extreme wins. let link = LinkModel::default(); - assert!(link.throughput_ratio(&fine) > link.throughput_ratio(&coarse)); + let at = |bits: u32| { + link.throughput_ratio(&ShieldConfig { + feedback_bits: bits, + ..ShieldConfig::default() + }) + }; + let lo = at(1); + let hi = at(12); + let best_bits = (1..=12) + .max_by(|&a, &b| at(a).partial_cmp(&at(b)).unwrap()) + .unwrap(); + assert!( + best_bits > 1 && best_bits < 12, + "optimum at edge: {best_bits}" + ); + assert!(at(best_bits) > lo && at(best_bits) > hi); } }