diff --git a/firmware/esp32-csi-node/main/swarm_bridge.c b/firmware/esp32-csi-node/main/swarm_bridge.c index 7e0d7951fe..234b1df864 100644 --- a/firmware/esp32-csi-node/main/swarm_bridge.c +++ b/firmware/esp32-csi-node/main/swarm_bridge.c @@ -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); /* ------------------------------------------------------------------ */ @@ -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) { @@ -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, @@ -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; } @@ -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, @@ -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++; } } } diff --git a/firmware/esp32-csi-node/main/swarm_bridge.h b/firmware/esp32-csi-node/main/swarm_bridge.h index c2aad3f634..5793f19a97 100644 --- a/firmware/esp32-csi-node/main/swarm_bridge.h +++ b/firmware/esp32-csi-node/main/swarm_bridge.h @@ -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"). */ @@ -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; /** @@ -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 */ diff --git a/v2/Cargo.lock b/v2/Cargo.lock index 8c094cf683..e81f447186 100644 --- a/v2/Cargo.lock +++ b/v2/Cargo.lock @@ -7533,7 +7533,6 @@ dependencies = [ "tokio-test", "toml 0.8.23", "tracing", - "wifi-densepose-core", ] [[package]] @@ -11154,6 +11153,7 @@ dependencies = [ "wifi-densepose-geo", "wifi-densepose-hardware", "wifi-densepose-rufield", + "wifi-densepose-ruvector", "wifi-densepose-signal", "wifi-densepose-wifiscan", "wifi-densepose-worldgraph", @@ -11277,17 +11277,6 @@ dependencies = [ "wifi-densepose-geo", ] -[[package]] -name = "wifi-densepose-worldmodel" -version = "0.3.1" -dependencies = [ - "serde", - "serde_json", - "thiserror 2.0.18", - "tokio", - "wifi-densepose-worldgraph", -] - [[package]] name = "winapi" version = "0.3.9" diff --git a/v2/crates/wifi-densepose-sensing-server/Cargo.toml b/v2/crates/wifi-densepose-sensing-server/Cargo.toml index 20925f15dd..10bb384bcc 100644 --- a/v2/crates/wifi-densepose-sensing-server/Cargo.toml +++ b/v2/crates/wifi-densepose-sensing-server/Cargo.toml @@ -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" } diff --git a/v2/crates/wifi-densepose-sensing-server/src/main.rs b/v2/crates/wifi-densepose-sensing-server/src/main.rs index 38a543d325..28ad411d23 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/main.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/main.rs @@ -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, + /// 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` 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 @@ -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. /// @@ -751,6 +763,7 @@ impl NodeState { ), ), last_novelty_score: None, + privacy_log: wifi_densepose_ruvector::PrivacyEventLog::new(PRIVACY_LOG_CAPACITY), active_grid: None, } } @@ -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 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,