Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 92 additions & 12 deletions firmware/esp32-csi-node/main/swarm_bridge.c
Original file line number Diff line number Diff line change
Expand Up @@ -60,11 +60,23 @@ static uint32_t s_cnt_heartbeats;
static uint32_t s_cnt_ingests;
static uint32_t s_cnt_errors;

/* ---- ADR-084 Pass 4: mesh-exchange compression state ----
* A 1-bit-per-dimension sign sketch of the last vector we actually sent
* over the wire. Only the ingest cycle (not the heartbeat) is gated —
* heartbeats stay unconditional because they're the Seed's liveness
* signal, not a happiness observation. */
static uint8_t s_last_sent_sketch;
static bool s_have_last_sketch;
static uint32_t s_last_full_send_s;
static uint32_t s_cnt_suppressed;

/* ---- Forward declarations ---- */
static void swarm_task(void *arg);
static esp_err_t swarm_post_json(esp_http_client_handle_t client,
const char *json, int json_len);
static void swarm_get_ip_str(char *buf, size_t buf_len);
static uint8_t swarm_sketch8(const float *v);
static uint8_t swarm_hamming8(uint8_t a, uint8_t b);

/* ------------------------------------------------------------------ */

Expand All @@ -85,6 +97,12 @@ esp_err_t swarm_bridge_init(const swarm_config_t *cfg, uint8_t node_id)
if (s_cfg.ingest_sec == 0) {
s_cfg.ingest_sec = 5;
}
if (s_cfg.novelty_threshold == 0) {
s_cfg.novelty_threshold = SWARM_NOVELTY_THRESHOLD_DEFAULT;
}
if (s_cfg.max_suppress_sec == 0) {
s_cfg.max_suppress_sec = SWARM_MAX_SUPPRESS_SEC_DEFAULT;
}

s_mutex = xSemaphoreCreateMutex();
if (s_mutex == NULL) {
Expand All @@ -98,6 +116,10 @@ esp_err_t swarm_bridge_init(const swarm_config_t *cfg, uint8_t node_id)
s_cnt_heartbeats = 0;
s_cnt_ingests = 0;
s_cnt_errors = 0;
s_have_last_sketch = false;
s_last_sent_sketch = 0;
s_last_full_send_s = 0;
s_cnt_suppressed = 0;

BaseType_t ret = xTaskCreatePinnedToCore(
swarm_task, "swarm", SWARM_TASK_STACK, NULL,
Expand All @@ -110,9 +132,10 @@ esp_err_t swarm_bridge_init(const swarm_config_t *cfg, uint8_t node_id)
return ESP_FAIL;
}

ESP_LOGI(TAG, "bridge init OK — seed=%s zone=%s hb=%us ingest=%us",
ESP_LOGI(TAG, "bridge init OK — seed=%s zone=%s hb=%us ingest=%us novelty_thr=%u max_suppress=%us",
s_cfg.seed_url, s_cfg.zone_name,
s_cfg.heartbeat_sec, s_cfg.ingest_sec);
s_cfg.heartbeat_sec, s_cfg.ingest_sec,
s_cfg.novelty_threshold, s_cfg.max_suppress_sec);
return ESP_OK;
}

Expand Down Expand Up @@ -152,6 +175,39 @@ void swarm_bridge_get_stats(uint32_t *regs, uint32_t *heartbeats,
if (errors) *errors = s_cnt_errors;
}

uint32_t swarm_bridge_get_suppressed_count(void)
{
return s_cnt_suppressed;
}

/* ---- ADR-084 Pass 4: sign sketch + hamming distance ----
* Same sign-quantization rule as wifi-densepose-ruvector::sketch::Sketch —
* one bit per dimension, 1 if the value is > 0.0. 8 dims fit in a single
* byte, so no allocation and no dependency on the Rust sketch crate is
* needed on the MCU side; this is the smallest useful instance of the
* same idea. */
static uint8_t swarm_sketch8(const float *v)
{
uint8_t bits = 0;
for (uint8_t i = 0; i < SWARM_VECTOR_DIM; i++) {
if (v[i] > 0.0f) {
bits |= (uint8_t)(1u << i);
}
}
return bits;
}

static uint8_t swarm_hamming8(uint8_t a, uint8_t b)
{
uint8_t x = (uint8_t)(a ^ b);
uint8_t count = 0;
while (x) {
count += (uint8_t)(x & 1);
x >>= 1;
}
return count;
}

/* ---- HTTP POST helper ---- */

static esp_err_t swarm_post_json(esp_http_client_handle_t client,
Expand Down Expand Up @@ -316,16 +372,40 @@ static void swarm_task(void *arg)

bool presence = vit_valid && (vit.flags & 0x01);
if (presence) {
/* Happiness ID: node_id * 1000000 + 200000 + ts_sec */
uint32_t h_id = (uint32_t)s_node_id * 1000000U + 200000U + (ts / 1000U % 100000U);
char json[SWARM_JSON_BUF];
int len = snprintf(json, sizeof(json),
"{\"vectors\":[[%lu,[%.4f,%.4f,%.4f,%.4f,%.4f,%.4f,%.4f,%.4f]]]}",
(unsigned long)h_id,
hv[0], hv[1], hv[2], hv[3], hv[4], hv[5], hv[6], hv[7]);

if (swarm_post_json(client, json, len) == ESP_OK) {
s_cnt_ingests++;
/* ADR-084 Pass 4: mesh-exchange compression. Sketch the
* current vector and compare against the last vector we
* actually put on the wire. A stable room repeats the same
* sign pattern every cycle — no reason to re-send 8 floats
* for it every 5 seconds. `max_suppress_sec` is the safety
* valve so a long-stable room still refreshes the Seed's
* view periodically instead of going silent forever. */
uint8_t sketch = swarm_sketch8(hv);
uint8_t distance = s_have_last_sketch
? swarm_hamming8(sketch, s_last_sent_sketch)
: SWARM_VECTOR_DIM;
uint32_t elapsed_since_full = uptime_s - s_last_full_send_s;

bool should_send = !s_have_last_sketch
|| distance >= s_cfg.novelty_threshold
|| elapsed_since_full >= s_cfg.max_suppress_sec;

if (should_send) {
/* Happiness ID: node_id * 1000000 + 200000 + ts_sec */
uint32_t h_id = (uint32_t)s_node_id * 1000000U + 200000U + (ts / 1000U % 100000U);
char json[SWARM_JSON_BUF];
int len = snprintf(json, sizeof(json),
"{\"vectors\":[[%lu,[%.4f,%.4f,%.4f,%.4f,%.4f,%.4f,%.4f,%.4f]]]}",
(unsigned long)h_id,
hv[0], hv[1], hv[2], hv[3], hv[4], hv[5], hv[6], hv[7]);

if (swarm_post_json(client, json, len) == ESP_OK) {
s_cnt_ingests++;
s_last_sent_sketch = sketch;
s_have_last_sketch = true;
s_last_full_send_s = uptime_s;
}
} else {
s_cnt_suppressed++;
}
}
}
Expand Down
29 changes: 29 additions & 0 deletions firmware/esp32-csi-node/main/swarm_bridge.h
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,19 @@
/** Happiness vector dimension. */
#define SWARM_VECTOR_DIM 8

/**
* ADR-084 Pass 4 defaults — mesh-exchange compression.
*
* A happiness vector is only sent in full when its 1-bit-per-dimension
* sign sketch differs from the last sent sketch by at least this many
* bits, or when `max_suppress_sec` has elapsed since the last full send
* (whichever comes first). This keeps a stable room from re-sending the
* same 8 floats every 5 seconds while still guaranteeing the Seed's view
* never goes stale for more than `max_suppress_sec`.
*/
#define SWARM_NOVELTY_THRESHOLD_DEFAULT 1
#define SWARM_MAX_SUPPRESS_SEC_DEFAULT 300 /* 5 minutes */

/** Swarm bridge configuration. */
typedef struct {
char seed_url[64]; /**< Cognitum Seed base URL (e.g. "http://192.168.1.10:8080"). */
Expand All @@ -25,6 +38,14 @@ typedef struct {
uint16_t heartbeat_sec; /**< Heartbeat interval in seconds (default 30). */
uint16_t ingest_sec; /**< Happiness ingest interval in seconds (default 5). */
uint8_t enabled; /**< 1 = bridge active, 0 = disabled. */

/** ADR-084 Pass 4. Hamming distance (0-8) that forces a full send
* regardless of the suppress timer. 0 = use SWARM_NOVELTY_THRESHOLD_DEFAULT. */
uint8_t novelty_threshold;
/** ADR-084 Pass 4. Force a full send after this many seconds even if
* novelty stayed below threshold the whole time. 0 = use
* SWARM_MAX_SUPPRESS_SEC_DEFAULT. */
uint16_t max_suppress_sec;
} swarm_config_t;

/**
Expand Down Expand Up @@ -64,4 +85,12 @@ void swarm_bridge_update_happiness(const float *vector, uint8_t dim);
void swarm_bridge_get_stats(uint32_t *regs, uint32_t *heartbeats,
uint32_t *ingests, uint32_t *errors);

/**
* ADR-084 Pass 4. Number of happiness-ingest cycles skipped because the
* sign-sketch novelty stayed below `novelty_threshold` and the
* `max_suppress_sec` floor hadn't elapsed yet. A rising count on a stable
* node is the compression working as intended, not a fault.
*/
uint32_t swarm_bridge_get_suppressed_count(void);

#endif /* SWARM_BRIDGE_H */
13 changes: 1 addition & 12 deletions v2/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions v2/crates/wifi-densepose-sensing-server/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ wifi-densepose-wifiscan = { version = "0.3.0", path = "../wifi-densepose-wifisca
# `--no-default-features` at the workspace root can produce a Windows-friendly
# build without vcpkg/openblas (issue #366, #415).
wifi-densepose-signal = { version = "0.3.1", path = "../wifi-densepose-signal", default-features = false }
wifi-densepose-ruvector = { version = "0.3.2", path = "../wifi-densepose-ruvector" }

# Hardware crate — SyncPacket decoder for ADR-110 §A0.12 mesh-aligned timestamps.
wifi-densepose-hardware = { version = "0.3.0", path = "../wifi-densepose-hardware" }
Expand Down
28 changes: 28 additions & 0 deletions v2/crates/wifi-densepose-sensing-server/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -488,6 +488,14 @@ struct NodeState {
/// Most recent novelty score in [0.0, 1.0] (0 = exact-match in bank,
/// 1 = no overlap). Consumed by the model-wake gate downstream.
pub(crate) last_novelty_score: Option<f32>,
/// ADR-084 Pass 5 — privacy-preserving audit trail of this node's
/// novelty events. Stores `(sketch_bytes, sketch_version, novelty,
/// witness_sha256)` tuples instead of raw `Vec<f32>` embeddings, so
/// anyone with read access to this log (or a future serialization of
/// it) cannot reconstruct the source CSI. Populated alongside
/// `last_novelty_score` in `update_novelty`; `last_novelty_score`
/// itself stays as the cheap ephemeral read, this is the durable trail.
pub(crate) privacy_log: wifi_densepose_ruvector::PrivacyEventLog,
/// ADR-110 / issue #1005: the `(n_subcarriers, ppdu_type)` grid this
/// node's rolling windows were built on. ESP32-C6 nodes interleave
/// HE-SU 256-bin frames with HT 64-bin frames on one socket; mixing
Expand Down Expand Up @@ -517,6 +525,10 @@ const NOVELTY_HISTORY_CAPACITY: usize = 64;
/// ADR-084 Pass 3 — feature-vector schema version. Bump on changes to
/// subcarrier ordering / normalisation so banks reject stale data.
const NOVELTY_SKETCH_VERSION: u16 = 1;
/// ADR-084 Pass 5 — max events retained per-node in the privacy-preserving
/// audit log before FIFO eviction. 256 events at ~6 bytes/sketch (56-dim
/// packed to 7 bytes) + ~50 fixed bytes is well under 15 KiB per node.
const PRIVACY_LOG_CAPACITY: usize = 256;

/// Lower plausibility floor (seconds) for a CSI inter-frame delta.
///
Expand Down Expand Up @@ -751,6 +763,7 @@ impl NodeState {
),
),
last_novelty_score: None,
privacy_log: wifi_densepose_ruvector::PrivacyEventLog::new(PRIVACY_LOG_CAPACITY),
active_grid: None,
}
}
Expand Down Expand Up @@ -803,6 +816,21 @@ impl NodeState {
// Score before insert so a query doesn't see itself.
self.last_novelty_score = history.novelty(&feature);

// ADR-084 Pass 5 — privacy-preserving event log. Sketch the same
// feature vector `novelty()` just scored (never the raw `feature`
// Vec<f32> itself) and push it to the durable audit trail.
if let Some(score) = self.last_novelty_score {
let sketch = wifi_densepose_ruvector::Sketch::from_embedding(
&feature,
NOVELTY_SKETCH_VERSION,
);
let timestamp_us = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_micros() as u64)
.unwrap_or(0);
self.privacy_log.push(&sketch, score, timestamp_us);
}

let _ = history.push(
wifi_densepose_signal::ruvsense::longitudinal::EmbeddingEntry {
person_id: 0,
Expand Down