docs/adr/ADR-086-edge-novelty-gate.md
| Field | Value |
|---|---|
| Status | Proposed |
| Date | 2026-04-26 |
| Authors | ruv |
| Refines | ADR-081 (5-layer adaptive CSI mesh firmware kernel — Layer 4 / On-device feature extraction), ADR-084 (RaBitQ similarity sensor) |
| Touches | ADR-018 (binary CSI frame magic discipline), ADR-028 (capability audit / witness verification), ADR-082 (confirmed-track output filter), ADR-085 (RaBitQ pipeline expansion) |
| Companion | firmware/esp32-csi-node/main/rv_feature_state.h (current 0xC5110006 v6 wire format), docs/research/architecture/three-tier-rust-node.md (BQ24074 power budget context), vendor/ruvector/crates/ruvector-core/src/quantization.rs::BinaryQuantized (std reference implementation that this ADR will not directly reuse on-MCU) |
ADR-081's 5-layer firmware kernel today emits one rv_feature_state_t
packet per node every 100–1000 ms (1–10 Hz, default 5 Hz on COM7),
60 bytes payload, magic 0xC5110006, regardless of how interesting
the underlying CSI window was. At a 5 Hz baseline the per-node steady-
state load is ~300 B/s of UDP plus the radio TX duty that emits it.
Across a 12-node deployment the cluster Pi sees ~3.6 kB/s of
feature-state — not a bandwidth crisis on its own, but every one of
those packets also costs sensor-MCU radio TX energy, every one
contends for ESP-WIFI-MESH airtime per ADR-081 Layer 3, and every one
runs through the cluster-Pi novelty bank ADR-084 Pass 3 only to be
classified as "nothing new" most of the time in a quiet room.
ADR-084 made novelty cheap on the cluster-Pi side. The same novelty
sensor is structurally local: a sketch, a small ring of recent
sketches, and a hamming-distance compare. Pushing that gate down into
the sensor MCU's Layer 4 (On-device feature extraction) lets the node
not transmit a frame the cluster-Pi would have filed under
"familiar" anyway. Bandwidth, sensor-MCU TX energy, and RF airtime
all win, and the cluster-Pi novelty path stops re-doing work the edge
already proved pointless. This is the natural ADR-085 follow-up
flagged but deliberately left out of the ADR-085 scope because it
requires a no_std sketch port, a Kconfig-gated rollout, a wire-
format bump, and a fresh witness regeneration — none of which are
appropriate inside an in-flight cluster-Pi work loop.
The crux of the decision is whether the cost of (a) hand-porting the
sketch primitive to no_std Xtensa LX7, (b) sizing the in-IRAM ring
without disturbing the existing Layer 4 budget, (c) bumping the
rv_feature_state_t magic and teaching the cluster-Pi a graceful
v6/v7 fallback, and (d) re-cutting the ADR-028 witness bundle is
justified by the suppression rate the gate actually achieves on real
deployments. The answer should be obvious in stable rooms (≥50 %
suppression looks easy) and ambiguous in active rooms (suppression
should drop sharply, which is exactly what we want). This ADR commits
to numbers up front so the decision is falsifiable.
Adopt an edge novelty gate in the sensor MCU's Layer 4 of
ADR-081's 5-layer kernel. The gate sits between feature extraction
and the existing UDP send path; when novelty is below a configurable
threshold the frame is not transmitted, and the node accumulates
a per-source suppressed_since_last counter that is folded into the
next non-suppressed packet. This keeps the cluster-Pi's books
honest — the edge can suppress bandwidth, but it can never
silently suppress the fact of suppression.
The implementation is two pieces, both new in
firmware/esp32-csi-node/main/:
rv_sketch.{h,c} — a no_std-equivalent (plain C, ESP-IDF)
1-bit sketch primitive. Sign-quantize a feature vector, pack into
bytes ((dim + 7) / 8 bytes), hamming distance via 8-bit
table-lookup popcount. Xtensa LX7 has no hardware POPCNT
instruction (no primary source consulted; conjecture based on the
ESP32-S3 TRM not advertising one — to be confirmed by checking
the TRM
under bit-manipulation extensions); the table-lookup scalar
baseline is the right starting point and is already what
BinaryQuantized falls back to on architectures without a SIMD
POPCNT path (vendor/ruvector/crates/ruvector-core/src/quantization.rs,
lines 332–340).RV_EDGE_BANK_SIZE slots × RV_EDGE_VECTOR_DIM_BYTES bytes.
For the default Layer 4 feature dimension of 56 (matching the
subcarrier-selection / interpolation target widely used in this
codebase), the ring at the default 32 slots costs
32 × 7 = 224 bytes. A 64-slot ring at 56 d costs 448 bytes — both
sit comfortably inside the existing static-memory budget on either
the 4 MB or 8 MB Waveshare AMOLED ESP32-S3 board, well clear of
ADR-081 Layer 4's existing window buffers. Eviction is FIFO; on
each new sketch the oldest is overwritten.For each completed Layer 4 feature window:
1. compute feature vector (existing)
2. sketch = sign_quantize(feature_vector) // new
3. nearest_hamming = ring_min_distance(sketch) // new
4. novelty = nearest_hamming / dim // 0..1, new
5. if novelty >= CONFIG_RV_EDGE_NOVELTY_THRESHOLD
OR suppressed_since_last >= CONFIG_RV_EDGE_MAX_CONSEC_SUPPRESS
OR CONFIG_RV_EDGE_FORCE_SEND:
ring_insert(sketch)
emit rv_feature_state_t v7 with suppressed_since_last
suppressed_since_last = 0
else:
suppressed_since_last += 1
// do not insert into ring — only confirmed-emitted sketches anchor the bank
Threshold default: CONFIG_RV_EDGE_NOVELTY_THRESHOLD = 500
basis-points (= 5.0 % of dimension). Kconfig does not accept floats
without contortion (the standard Espressif practice in our codebase
is to express thresholds as int basis-points or scaled fixed-point);
this preserves the Kconfig-as-truth discipline ADR-081 already
follows.
Suppression cap default:
CONFIG_RV_EDGE_MAX_CONSEC_SUPPRESS = 50. At 5 Hz that is 10 s of
forced silence at most before a "stuck gate" self-heals into a
forced send — comparable to ADR-081's slow-loop 30 s recalibration
cadence and well below any user-visible UI staleness threshold.
Default-off gate: CONFIG_RV_EDGE_NOVELTY_GATE_ENABLE = n. Existing
deployments behave identically until they opt in.
Bump the rv_feature_state_t magic to 0xC5110007 and add three
bytes by reusing the existing 2-byte reserved field plus one byte
borrowed from the 16-bit quality_flags budget (only 8 of 16 flags
are defined today; we narrow to uint8_t quality_flags):
| Offset (v7) | Field | Notes |
|---|---|---|
| 0..3 | magic = 0xC5110007 | new; differentiates from 0xC5110006 |
| 4 | node_id | unchanged |
| 5 | mode | unchanged |
| 6..7 | seq | unchanged |
| 8..15 | ts_us | unchanged |
| 16..51 | nine float features | unchanged |
| 52 | quality_flags (uint8_t) | narrowed from u16 — see Open Q3 |
| 53 | gate_version (uint8_t) | new |
| 54..55 | suppressed_since_last | new (uint16_t LE) |
| 56..59 | crc32 | unchanged, computed over [0..56) |
Total size: still 60 bytes, wire-compatible at packet length but
not at field semantics — magic is the discriminator. Cluster-Pi
receivers that recognize 0xC5110007 interpret the new fields;
receivers that recognize 0xC5110006 continue to work but do not
see the suppression count. The receiver gracefully falls back when
it sees the v6 magic; this is the explicit graceful-fallback contract
ADR-081 already established for Layer 5 stream parsing.
The choice to narrow quality_flags from 16 to 8 bits relies on the
fact that rv_feature_state.h defines exactly 8 RV_QFLAG_* bits
today (lines 33–40); future flag growth is a separate ADR slot, and
the alternative — adding a 4th uint8_t and growing the packet to
64 bytes — costs a recompute of every Layer 5 parser and is more
intrusive than the magic bump.
docs/research/architecture/three-tier-rust-node.md §3.3 power
budget shows ~80 mA active-CSI baseline with TX-burst spikes at
~150 mA peak; the gate primarily cuts the burst-frequency rather
than the baseline). ≥30 % TX-energy reduction in steady-state quiet
rooms is the validation target.suppressed_since_last is observable. The cluster-Pi can
detect a node that has been suppressing for too long, a node
whose suppression rate suddenly drops (occupant entered the
room — the right behaviour), and a node whose suppression cap is
triggering frequently (gate is mistuned). All three are useful
signals and all three live in fields the receiver already parses.CONFIG_RV_EDGE_FORCE_SEND_BURST (default 32) frames per
Layer 2 slow-loop recalibration window — but this lives outside
the ADR-086 baseline and is called out as a follow-up if needed.bash scripts/generate-witness-bundle.sh and confirm 7/7 PASS
via dist/witness-bundle-ADR028-*/VERIFY.sh.CONFIG_RV_EDGE_FORCE_SEND Kconfig flag bypasses the gate
entirely and is the right tool for diffing
with-gate vs without-gate behaviour during a deployment. Required.rv_feature_state_t see fewer inputs but the same shape;
Sites 6 (swarm routing) and 7 (event-stream anomaly) will be
slightly less sensitive under v7. Re-measurement is recommended
but is not a blocker for ADR-086.Six numbered passes, ordered cheapest-first / lowest-risk-first. Each is independently shippable, each has a one-line acceptance criterion that must pass before the next pass starts. Default-off Kconfig means none of these passes can break a deployment that has not opted in.
| # | Pass | Target | Acceptance |
|---|---|---|---|
| 1 | no_std sketch primitive port (firmware/esp32-csi-node/main/rv_sketch.{h,c}) | sensor-MCU C | QEMU unit test: 56-d sign-quantize of a fixed seed produces the bit-pattern matching the host-side reference; hamming distance round-trips. |
| 2 | IRAM ring + insert/min-distance API | sensor-MCU C | On-target benchmark on COM7: insert + ring-min on 32 slots ≤ 200 µs at 240 MHz. |
| 3 | Kconfig flags (CONFIG_RV_EDGE_NOVELTY_GATE_ENABLE, _THRESHOLD, _MAX_CONSEC_SUPPRESS, _FORCE_SEND) | firmware/esp32-csi-node/main/Kconfig.projbuild | Build with each flag toggled produces the expected sdkconfig.defaults merge; unit test asserts threshold of 500 bps maps to 5.0 % decision boundary. |
| 4 | rv_feature_state_t v7 wire format + finalize() update | firmware/esp32-csi-node/main/rv_feature_state.{h,c} | _Static_assert(sizeof == 60) still holds; CRC32 over the new layout round-trips; v6 receiver test reads a v7 packet without panic and ignores the new fields. |
| 5 | Cluster-Pi reconciliation | crates/wifi-densepose-sensing-server/ UDP intake + ADR-084 Pass 3 novelty bank | A v7 packet with suppressed_since_last = N causes the Pi-side bank to interpret the gap as low-novelty stable-baseline contribution rather than as missing data; integration test on a synthetic v7 stream. |
| 6 | QEMU + COM7 hardware-in-loop validation | end-to-end | Stable-room recording: ≥50 % suppression rate; cluster-Pi novelty top-K coverage regression ≤ 5 pp vs unsuppressed baseline; stuck-gate self-heal exercised in a unit test. |
Pass 1 deliberately does not depend on
vendor/ruvector/crates/ruvector-core::BinaryQuantized. That crate
is std-bound (Vec<u8>, is_x86_feature_detected!, NEON
intrinsics — quantization.rs lines 289–340) and porting it to
no_std Xtensa LX7 is not a one-line #![no_std] flip. The clean
path is a fresh minimal C primitive that matches the
BinaryQuantized behaviour (sign quantization, byte-table popcount
fallback, (dim+7)/8 packed bytes); the host-side reference becomes
a spec, not a dependency. A future no_std-clean Rust port may
unify both once esp-radio / esp-csi-rs matures (three-tier node
research §7.3) — out of scope here.
This ADR is Proposed. Acceptance requires every numbered Pass to meet its acceptance criterion and the following system-level numbers to hold on the COM7 hardware-in-loop run:
cargo test --workspace --no-default-features stays green; python v1/data/proof/verify.py
stays green (the proof harness sees no firmware-side change and
the SHA-256 should not move because the proof exercises Python
pipeline math, not firmware behaviour); the witness bundle
(scripts/generate-witness-bundle.sh) runs and the resulting
VERIFY.sh reports 7/7 PASS — the bundle's own SHA-256 will
differ, which is the witness-chain signal that firmware
changed.If any system-level number fails, the gate ships behind
CONFIG_RV_EDGE_NOVELTY_GATE_ENABLE = n (default-off) and the ADR
moves to Rejected for that hardware target while the wire-format
v7 changes are kept (they cost nothing dormant). If only the cluster-
Pi accuracy number fails, the gate is allowed to ship at a more
conservative CONFIG_RV_EDGE_NOVELTY_THRESHOLD until the cluster-
Pi-side reconciliation logic catches up.
gate_version: u8 come from? Three options:
(a) Kconfig-pinned at firmware build time;
(b) NVS-stored and bumped at provision time;
(c) embedded as a build-id byte derived from the firmware
manifest. Default: option (a), Kconfig-pinned. Rationale: the
gate version is part of the firmware contract, not the per-
deployment configuration. NVS is the wrong namespace; the build-
id approach is more robust to provisioning slips but harder to
compare across deployments. The decision is reversible — the
field width is fixed at 8 bits regardless of source.motion_score >= 0.05 as an unconditional force-send override
inside the gate. Open Q because the right mitigation depends on
the measured regression.The user prompt that produced this ADR identified two further follow-ups that should land as their own ADRs if and when the triggering condition occurs. They are recorded here as pointer-stubs rather than full ADRs because each is a one-paragraph commitment, not a structured decision; opening a full ADR for either prematurely would inflate the ledger without buying decision resolution.
ADR-084 §"Decision" lists "mesh-exchange compression" between sensor
nodes when reporting cross-cluster events as the fourth of its five
sites. The binding intent of that text is cluster-Pi to cluster-Pi
exchange — i.e., the ADR-066 swarm-bridge channel between peer
Cognitum Seeds — not sensor-MCU to cluster-Pi UDP traffic. The two
are different problems: cluster-to-cluster is std Rust on Linux/Mac
and reuses BinaryQuantized directly; sensor-to-Pi is what ADR-086
addresses. If the team later reinterprets Pass 4 as
sensor→cluster-Pi UDP compression, that would be ADR-086's twin and
should land as ADR-087 with its own firmware release, distinct
from ADR-086's release. The clarification is one paragraph because
the only decision is "which interpretation does ADR-084's Pass 4
mean", and the answer is currently the cluster-to-cluster reading.
ADR-087 only opens if that reading is contested.
Issues #386 and #396 (firmware-only fixes — the MGMT-only promiscuous filter and the 50 Hz callback-rate gate) demonstrate that the firmware can need a release independent of any cluster-Pi ADR work. ADR-086 is itself an example: it requires a firmware release that is not driven by ADR-084 or ADR-085, both of which are cluster-Pi-only. Today the implicit policy is "firmware releases when something firmware-only ships." That works but is undocumented. ADR-088 would formalize when a firmware release is required vs deferred, with concrete examples: a Kconfig flag flip (#386 / #396) must release; a Pi-side parser-only addition (ADR-085 Sites 1–7) must not; a wire-format magic bump (ADR-086) must release and must re-cut the witness bundle; a feature-flag-default flip on a shipped v7 firmware should release a config bundle but not a firmware binary. ADR-088 opens when the next firmware-only change after ADR-086 lands and forces the decision; it is recorded here as a slot rather than written speculatively because the actual release- gating questions only become concrete in the presence of a real shipping change.