ADR-081: implement Layers 1/2/4 end-to-end + host tests + QEMU hooks

Turns the ADR-081 scaffolding into a working adaptive CSI mesh kernel:
Layer 1 radio abstraction has an ESP32 binding and a mock binding; Layer 2
adaptive controller runs on FreeRTOS timers; Layer 4 feature-state packet
is emitted at 5 Hz by default, replacing raw ADR-018 CSI as the default
upstream.

New files:
  firmware/esp32-csi-node/main/adaptive_controller_decide.c  (pure policy)
  firmware/esp32-csi-node/main/rv_radio_ops_mock.c           (QEMU binding)
  firmware/esp32-csi-node/tests/host/Makefile                (host tests)
  firmware/esp32-csi-node/tests/host/test_adaptive_controller.c
  firmware/esp32-csi-node/tests/host/test_rv_feature_state.c
  firmware/esp32-csi-node/tests/host/esp_err.h               (shim)
  firmware/esp32-csi-node/tests/host/.gitignore

Modified:
  adaptive_controller.c         — includes pure decide.c; emit_feature_state()
                                  wired into fast loop (200 ms = 5 Hz)
  rv_radio_ops_esp32.c          — get_health() fills pkt_yield + send_fail
  csi_collector.{c,h}           — pkt_yield/send_fail accessors (ADR-081 L1)
  rv_feature_state.h            — packed size corrected to 60 bytes
                                  (was incorrectly 80 in initial commit)
  main.c                        — mock binding registered under mock CSI
  CMakeLists.txt                — rv_radio_ops_mock.c under CSI_MOCK_ENABLED
  scripts/validate_qemu_output.py — 3 new ADR-081 checks (17/18/19)
  docs/adr/ADR-081-*.md         — status → Accepted (partial);
                                  implementation-status matrix; measured
                                  benchmarks (decide 3.2 ns, CRC32 614 ns);
                                  bandwidth 300 B/s @ 5 Hz (99.7% vs raw);
                                  verification section
  CHANGELOG.md                  — artifact-level entries

Tests (host, gcc -O2 -std=c11):
  test_adaptive_controller:  18/18 pass, decide() = 3.2 ns/call
  test_rv_feature_state:     15/15 pass, CRC32(56 B) = 614 ns/pkt, 87 MB/s
                             sizeof(rv_feature_state_t) == 60 asserted
                             IEEE CRC32 known vectors verified

Deferred (tracked in ADR-081 roadmap Phase 3/4):
  Layer 3 mesh-plane message types, role-assignment FSM, Rust-side mirror
  trait in crates/wifi-densepose-hardware/src/radio_ops.rs.
This commit is contained in:
Claude
2026-04-19 03:43:08 +00:00
parent 9648a47fdc
commit d53e29506e
18 changed files with 966 additions and 107 deletions
@@ -14,7 +14,10 @@
#include "adaptive_controller.h"
#include "rv_radio_ops.h"
#include "rv_feature_state.h"
#include "edge_processing.h"
#include "stream_sender.h"
#include "csi_collector.h"
#include <string.h>
#include "freertos/FreeRTOS.h"
@@ -86,78 +89,10 @@ static void apply_defaults(adapt_config_t *cfg)
cfg->min_pkt_yield = CONFIG_ADAPTIVE_MIN_PKT_YIELD;
}
/* ---- Pure decision function (unit-testable) ---- */
void adaptive_controller_decide(const adapt_config_t *cfg,
adapt_state_t current,
const adapt_observation_t *obs,
adapt_decision_t *out)
{
if (cfg == NULL || obs == NULL || out == NULL) {
return;
}
memset(out, 0, sizeof(*out));
out->new_state = (uint8_t)current;
out->new_profile = RV_PROFILE_PASSIVE_LOW_RATE;
/* Degraded gate: any of pkt yield collapse, severe coherence loss → DEGRADED. */
if (obs->pkt_yield_per_sec < cfg->min_pkt_yield ||
obs->node_coherence < 0.20f) {
if (current != ADAPT_STATE_DEGRADED) {
out->change_state = true;
out->new_state = ADAPT_STATE_DEGRADED;
}
out->change_profile = (current != ADAPT_STATE_DEGRADED);
out->new_profile = RV_PROFILE_PASSIVE_LOW_RATE;
out->suggested_vital_interval_ms = 2000;
return;
}
/* Anomaly trumps motion. */
if (obs->anomaly_score >= cfg->anomaly_threshold) {
if (current != ADAPT_STATE_ALERT) {
out->change_state = true;
out->new_state = ADAPT_STATE_ALERT;
}
out->change_profile = true;
out->new_profile = RV_PROFILE_FAST_MOTION;
out->suggested_vital_interval_ms = 100;
return;
}
/* Motion → SENSE_ACTIVE with FAST_MOTION profile. */
if (obs->motion_score >= cfg->motion_threshold) {
if (current != ADAPT_STATE_SENSE_ACTIVE) {
out->change_state = true;
out->new_state = ADAPT_STATE_SENSE_ACTIVE;
}
out->change_profile = true;
out->new_profile = RV_PROFILE_FAST_MOTION;
out->suggested_vital_interval_ms = cfg->aggressive ? 100 : 200;
return;
}
/* Stable environment with valid presence → high-sensitivity respiration mode. */
if (obs->presence_score >= 0.5f && obs->motion_score < 0.05f) {
if (current != ADAPT_STATE_SENSE_IDLE) {
out->change_state = true;
out->new_state = ADAPT_STATE_SENSE_IDLE;
}
out->change_profile = true;
out->new_profile = RV_PROFILE_RESP_HIGH_SENS;
out->suggested_vital_interval_ms = 1000;
return;
}
/* Default: passive low rate. */
if (current != ADAPT_STATE_SENSE_IDLE) {
out->change_state = true;
out->new_state = ADAPT_STATE_SENSE_IDLE;
}
out->change_profile = (current != ADAPT_STATE_SENSE_IDLE);
out->new_profile = RV_PROFILE_PASSIVE_LOW_RATE;
out->suggested_vital_interval_ms = cfg->aggressive ? 500 : 1000;
}
/* Pure decision policy lives in its own file so it can link under
* host unit tests without FreeRTOS. It is part of this translation
* unit via #include to preserve a single object at build time. */
#include "adaptive_controller_decide.c"
/* ---- Observation collection ---- */
@@ -237,6 +172,12 @@ static void fast_loop_cb(TimerHandle_t t)
adapt_decision_t dec;
adaptive_controller_decide(&s_cfg, s_state, &obs, &dec);
apply_decision(&dec);
/* ADR-081 Layer 4/5: emit compact feature state on every fast tick
* (default 200 ms → 5 Hz, within the 110 Hz spec). Replaces raw
* ADR-018 CSI as the default upstream; raw remains available as a
* debug stream gated by the channel plan. */
emit_feature_state();
}
static void medium_loop_cb(TimerHandle_t t)
@@ -260,13 +201,81 @@ static void medium_loop_cb(TimerHandle_t t)
}
}
/* ADR-081 Layer 4: emit one rv_feature_state_t packet onto the wire.
*
* Pulls from the latest observation + latest vitals + the active capture
* profile. Send is best-effort — stream_sender will report its own
* failures; we don't re-queue. At 5 Hz default cadence this is 300 B/s
* per node, vs. ~100 KB/s for raw ADR-018 CSI. */
static uint16_t s_feature_state_seq = 0;
static void emit_feature_state(void)
{
rv_feature_state_t pkt;
memset(&pkt, 0, sizeof(pkt));
adapt_observation_t obs;
bool have_obs = false;
portENTER_CRITICAL(&s_obs_lock);
if (s_obs_valid) {
obs = s_last_obs;
have_obs = true;
}
portEXIT_CRITICAL(&s_obs_lock);
if (have_obs) {
pkt.motion_score = obs.motion_score;
pkt.presence_score = obs.presence_score;
pkt.anomaly_score = obs.anomaly_score;
pkt.node_coherence = obs.node_coherence;
}
/* Fill vitals from edge_processing's latest packet. */
edge_vitals_pkt_t v;
if (edge_get_vitals(&v)) {
pkt.respiration_bpm = (float)v.breathing_rate / 100.0f;
pkt.heartbeat_bpm = (float)v.heartrate / 10000.0f;
/* Confidence proxies: presence score for resp, 1.0 if heart BPM
* is within physiological range. */
pkt.respiration_conf = (v.breathing_rate > 0) ? v.presence_score : 0.0f;
pkt.heartbeat_conf = (v.heartrate > 400000u && v.heartrate < 1800000u)
? 0.8f : 0.0f;
if (pkt.respiration_bpm > 0.0f) pkt.quality_flags |= RV_QFLAG_RESPIRATION_VALID;
if (pkt.heartbeat_bpm > 0.0f) pkt.quality_flags |= RV_QFLAG_HEARTBEAT_VALID;
if (pkt.presence_score >= 0.5f) pkt.quality_flags |= RV_QFLAG_PRESENCE_VALID;
if (v.flags & 0x02) pkt.quality_flags |= RV_QFLAG_ANOMALY_TRIGGERED; /* fall bit */
}
if (s_state == ADAPT_STATE_DEGRADED) pkt.quality_flags |= RV_QFLAG_DEGRADED_MODE;
if (s_state == ADAPT_STATE_CALIBRATION) pkt.quality_flags |= RV_QFLAG_CALIBRATING;
/* Active profile, for receiver-side weighting. */
const rv_radio_ops_t *ops = rv_radio_ops_get();
uint8_t profile = RV_PROFILE_PASSIVE_LOW_RATE;
if (ops != NULL && ops->get_health != NULL) {
rv_radio_health_t h;
if (ops->get_health(&h) == ESP_OK) profile = h.current_profile;
}
rv_feature_state_finalize(&pkt,
csi_collector_get_node_id(),
s_feature_state_seq++,
(uint64_t)esp_timer_get_time(),
profile);
int sent = stream_sender_send((const uint8_t *)&pkt, sizeof(pkt));
if (sent < 0) {
ESP_LOGW(TAG, "feature_state emit failed");
}
}
static void slow_loop_cb(TimerHandle_t t)
{
(void)t;
/* Slow loop: publish a HEALTH message, request CALIBRATION_START on
* sustained drift. Both routed through swarm_bridge once the mesh
* plane lands. Today we log a rollover so operators see the cadence. */
ESP_LOGI(TAG, "slow tick (state=%u)", (unsigned)s_state);
/* Slow loop: log a heartbeat and (future Phase 3) publish HEALTH
* messages + request CALIBRATION_START on sustained drift. */
ESP_LOGI(TAG, "slow tick (state=%u, feature_state_seq=%u)",
(unsigned)s_state, (unsigned)s_feature_state_seq);
}
/* ---- Public API ---- */