From 240ca3ac144b6626a1b03207e78542583b9bdf74 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Mar 2026 21:15:49 +0000 Subject: [PATCH] Add system architecture and prototype design research MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GOAP Agent 10 output: End-to-end architecture with pipeline diagrams, existing crate integration mapping, new rf_topology module design (DDD aggregate roots), 100ms latency budget breakdown, 3-phase prototype plan (4-node POC → 16-node room → 72-node multi-room), benchmark design with 8 metrics, ADR-044 draft, and Rust trait definitions (EdgeWeightComputer, TopologyGraph, MinCutSolver, BoundaryInterpolator). Part of RF Topological Sensing research swarm (12 agents). https://claude.ai/code/session_01DGUAowNScGVp88bK2eiuRv --- .../10-system-architecture-prototype.md | 1625 +++++++++++++++++ 1 file changed, 1625 insertions(+) create mode 100644 docs/research/10-system-architecture-prototype.md diff --git a/docs/research/10-system-architecture-prototype.md b/docs/research/10-system-architecture-prototype.md new file mode 100644 index 00000000..02196f56 --- /dev/null +++ b/docs/research/10-system-architecture-prototype.md @@ -0,0 +1,1625 @@ +# Research Document 10: RF Topological Sensing — System Architecture and Prototype + +**Date**: 2026-03-08 +**Status**: Draft +**Author**: Research Agent +**Scope**: End-to-end architecture for RF topological sensing using ESP32 mesh networks + +--- + +## Table of Contents + +1. [End-to-End Architecture](#1-end-to-end-architecture) +2. [Existing Crate Integration](#2-existing-crate-integration) +3. [New Module Design](#3-new-module-design) +4. [Real-Time Pipeline](#4-real-time-pipeline) +5. [Prototype Phases](#5-prototype-phases) +6. [Benchmark](#6-benchmark) +7. [ADR-044 Draft](#7-adr-044-draft) +8. [Rust Trait Definitions](#8-rust-trait-definitions) + +--- + +## 1. End-to-End Architecture + +### 1.1 Core Concept + +RF topological sensing treats a mesh of ESP32 nodes as a "radio nervous system." +Every transmitter-receiver pair defines a graph edge. The Channel State Information +(CSI) measured on each edge encodes how the radio environment between those two +nodes has been perturbed — by walls, furniture, and most importantly, by human +bodies. When a person stands between two nodes, the CSI coherence on that link +drops. The collection of all such drops defines a cut in the graph that traces the +physical boundary of the person. + +The system does not estimate pose directly. Instead it answers a more fundamental +question: *where are the boundaries between occupied and unoccupied space?* Pose +estimation, activity recognition, and room segmentation are all downstream +consumers of this boundary information. + +### 1.2 Data Flow Summary + +``` +ESP32 Node A ──CSI──> Edge (A,B) ──weight──> Graph G ──mincut──> Boundaries ──render──> UI +ESP32 Node B ──CSI──> Edge (B,C) ──weight──> | | | +ESP32 Node C ──CSI──> Edge (A,C) ──weight──> | | | + ... ... v v v +ESP32 Node N Edge (i,j) RfGraph CutBoundary WebSocket +``` + +### 1.3 Pipeline Diagram + +``` ++============================================================================+ +| RF TOPOLOGICAL SENSING PIPELINE | ++============================================================================+ + + STAGE 1: CSI EXTRACTION STAGE 2: EDGE WEIGHT + ~~~~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~ + +-------------+ +-------------+ +-----------------+ + | ESP32 Node | | ESP32 Node | | Edge Weight | + | (TX) |--->| (RX) |--[ raw CSI ]->| Computation | + | ch_hop() | | extract() | | | + +-------------+ +-------------+ | - phase_align() | + | | | - coherence() | + | TDM slot | 52-subcarrier | - amplitude() | + | assignment | CSI frame | - temporal_avg | + v v +---------+-------+ + +-------------+ +-------------+ | + | TDM | | CSI Frame | weight: f64 + | Scheduler | | Buffer | [0.0 .. 1.0] + | (hardware) | | (ring buf) | | + +-------------+ +-------------+ v + + STAGE 3: GRAPH CONSTRUCTION STAGE 4: DYNAMIC MINCUT + ~~~~~~~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~ + +-----------------+ +------------------+ + | RfGraph | | Mincut Solver | + | |<----[ edge weights ]---------| | + | - add_edge() | | - stoer_wagner() | + | - update_wt() | | or | + | - prune_stale() | | - karger() | + | - adjacency mat |----[ graph snapshot ]------->| - push_relabel() | + | | | | + +-----------------+ +--------+---------+ + | + CutBoundary { + cut_edges, + cut_value, + partitions + } + | + v + + STAGE 5: BOUNDARY VISUALIZATION + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +------------------+ +-------------------+ +----------------+ + | Boundary | | Sensing Server | | Browser UI | + | Interpolation |------>| (Axum WebSocket) |------>| (Canvas/WebGL) | + | | | | | | + | - contour_from() | | - ws_broadcast() | | - draw_room() | + | - smooth() | | - /api/topology | | - draw_cuts() | + | - to_polygon() | | - /api/stream | | - animate() | + +------------------+ +-------------------+ +----------------+ +``` + +### 1.4 Data Structures at Each Stage + +``` +Stage 1 Output: CsiFrame { tx_id, rx_id, subcarriers: [Complex; 52], timestamp_us } +Stage 2 Output: EdgeWeight { tx_id, rx_id, weight: f64, confidence: f64, updated_at } +Stage 3 Output: RfGraph { nodes: Vec, edges: HashMap<(NodeId,NodeId), EdgeWeight> } +Stage 4 Output: CutBoundary { cut_edges: Vec<(NodeId,NodeId)>, partitions: (Vec, Vec) } +Stage 5 Output: BoundaryPolygon { vertices: Vec<(f64,f64)>, confidence: f64 } +``` + +### 1.5 Communication Protocol + +Nodes communicate using TDM (Time Division Multiplexing) as defined in +ADR-028. Each node is assigned a transmit slot. During its slot, a node +transmits on a known subcarrier pattern. All other nodes simultaneously +receive and extract CSI. This yields N*(N-1)/2 unique edges for N nodes. + +``` +Time --> + Slot 0 Slot 1 Slot 2 Slot 3 Slot 0 Slot 1 ... + [Node A] [Node B] [Node C] [Node D] [Node A] [Node B] + TX TX TX TX TX TX + B,C,D RX A,C,D RX A,B,D RX A,B,C RX B,C,D RX A,C,D RX + + One full cycle = N slots = one complete graph snapshot + At 1ms slots, 4-node cycle = 4ms, 16-node cycle = 16ms +``` + +--- + +## 2. Existing Crate Integration + +### 2.1 Integration Map + +``` ++---------------------------+ +-----------------------------+ +| wifi-densepose-hardware | | wifi-densepose-signal | +| (ESP32 TDM, CSI extract) | | (ruvsense modules) | ++------------+--------------+ +-------------+---------------+ + | | + | CsiFrame | coherence, phase + v v ++------------------------------------------------------------------+ +| rf_topology (NEW MODULE) | +| RfGraph, EdgeWeight, CutBoundary, TopologyEvent | ++------------------------------------------------------------------+ + | | + | graph memory | boundary data + v v ++-----------------------------+ +-----------------------------+ +| wifi-densepose-ruvector | | wifi-densepose-sensing- | +| (graph memory, attention) | | server (UI, WebSocket) | ++-----------------------------+ +-----------------------------+ +``` + +### 2.2 wifi-densepose-signal / ruvsense + +The signal crate contains the RuvSense modules that provide the mathematical +foundation for edge weight computation. + +**coherence.rs** — Z-score coherence scoring with DriftProfile. This module +already computes a coherence metric between CSI frames. For RF topology, we +use coherence as the primary edge weight: high coherence means the link is +unobstructed, low coherence means something (a person) is in the path. + +``` +Usage in rf_topology: + - coherence::ZScoreCoherence::score(baseline_csi, current_csi) -> f64 + - coherence::DriftProfile tracks long-term drift per edge + - coherence_gate::CoherenceGate decides if a measurement is reliable +``` + +**phase_align.rs** — Iterative LO phase offset estimation using circular mean. +ESP32 local oscillators drift, which corrupts phase measurements. Phase +alignment is a prerequisite for meaningful coherence computation. + +``` +Usage in rf_topology: + - phase_align::align_frames(tx_csi, rx_csi) -> AlignedCsiPair + - Must be called BEFORE coherence scoring + - Runs per-edge, per-frame +``` + +**multiband.rs** — Multi-band CSI frame fusion. When nodes operate on multiple +WiFi channels (via channel hopping), this module fuses the measurements into +a single coherent view. + +``` +Usage in rf_topology: + - multiband::fuse_channels(ch1_csi, ch5_csi, ch11_csi) -> FusedCsiFrame + - Increases spatial resolution of edge weights + - Optional: single-channel operation is sufficient for prototype +``` + +**multistatic.rs** — Attention-weighted fusion with geometric diversity. This +module already performs multi-link fusion, which is conceptually close to what +rf_topology needs. The key difference is that multistatic.rs fuses for pose +estimation, while rf_topology fuses for boundary detection. + +``` +Usage in rf_topology: + - multistatic::GeometricDiversity provides link quality weighting + - Reuse attention weights for graph edge confidence scoring +``` + +**adversarial.rs** — Physically impossible signal detection. This module +detects when CSI measurements violate physical constraints (e.g., signal +strength increases when a person is blocking the path). Essential for +filtering bad edges in the graph. + +``` +Usage in rf_topology: + - adversarial::PhysicsChecker::validate(edge_measurement) -> Result<(), Violation> + - Edges that fail validation are marked low-confidence +``` + +### 2.3 wifi-densepose-ruvector + +The ruvector crate provides graph-based data structures and attention mechanisms +that can be repurposed for RF topology. + +**viewpoint/attention.rs** — CrossViewpointAttention with GeometricBias and +softmax. The attention mechanism computes importance weights across multiple +viewpoints. In RF topology, each TX-RX pair is a "viewpoint" and the attention +mechanism can prioritize the most informative edges. + +``` +Usage in rf_topology: + - CrossViewpointAttention can weight edges by geometric diversity + - GeometricBias accounts for node placement geometry + - Softmax normalization produces valid probability distribution over edges +``` + +**viewpoint/geometry.rs** — GeometricDiversityIndex and Cramer-Rao bounds. +This module quantifies how much geometric information a set of links provides. +RF topology uses this to determine if the current node placement can resolve +a boundary at a given location. + +``` +Usage in rf_topology: + - GeometricDiversityIndex tells us if we have enough angular coverage + - Cramer-Rao bound gives theoretical position error lower bound + - Fisher Information matrix guides optimal node placement +``` + +**viewpoint/coherence.rs** — Phase phasor coherence with hysteresis gate. +Already provides a gating mechanism for coherence measurements. RF topology +reuses this to prevent boundary flicker from noisy measurements. + +``` +Usage in rf_topology: + - Hysteresis gate prevents rapid edge weight oscillation + - Smooths boundary detection over time +``` + +**viewpoint/fusion.rs** — MultistaticArray aggregate root with domain events. +This is a DDD aggregate root that manages a collection of multistatic links. +RF topology can extend this pattern for graph-level aggregate management. + +``` +Usage in rf_topology: + - MultistaticArray pattern informs RfGraph aggregate design + - Domain events (LinkAdded, LinkDropped) map to TopologyEvent +``` + +### 2.4 wifi-densepose-hardware + +The hardware crate manages ESP32 devices and the TDM protocol. + +**esp32/tdm.rs** — Time Division Multiplexing scheduler. Assigns transmit +slots to nodes, ensures collision-free CSI extraction. + +``` +Usage in rf_topology: + - TdmScheduler provides the frame timing that drives the pipeline + - Each TDM cycle produces one complete graph snapshot + - Cycle period = N_nodes * slot_duration +``` + +**esp32/channel_hop.rs** — Channel hopping firmware control. Allows nodes to +measure CSI on multiple WiFi channels for improved spatial resolution. + +``` +Usage in rf_topology: + - Channel diversity increases edge weight accuracy + - Feeds into multiband.rs fusion +``` + +**esp32/csi_extract.rs** — Raw CSI extraction from ESP32 hardware registers. +Produces CsiFrame structs that are the input to the entire pipeline. + +``` +Usage in rf_topology: + - CsiFrame is the fundamental input type + - 52 subcarriers per frame on 20MHz channels + - Timestamp synchronization via NTP or TDM slot timing +``` + +### 2.5 wifi-densepose-sensing-server + +The sensing server provides the web UI and WebSocket streaming. + +``` +Usage in rf_topology: + - WebSocket endpoint broadcasts CutBoundary updates to browser + - REST endpoint /api/topology returns current graph state + - Static file serving for visualization JavaScript + - Axum router integrates new topology endpoints +``` + +### 2.6 Integration Summary Table + +| Existing Module | What It Provides | How rf_topology Uses It | +|------------------------------|-------------------------------|-------------------------------| +| signal/ruvsense/coherence | Z-score coherence scoring | Primary edge weight metric | +| signal/ruvsense/phase_align | LO phase offset correction | Pre-processing for coherence | +| signal/ruvsense/multiband | Multi-channel fusion | Improved edge resolution | +| signal/ruvsense/multistatic | Geometric diversity weighting | Edge confidence scoring | +| signal/ruvsense/adversarial | Physics violation detection | Bad edge filtering | +| signal/ruvsense/coherence_gate | Hysteresis gating | Boundary flicker prevention | +| ruvector/viewpoint/attention | Cross-viewpoint attention | Edge importance weighting | +| ruvector/viewpoint/geometry | Geometric diversity index | Resolution analysis | +| ruvector/viewpoint/fusion | DDD aggregate root pattern | RfGraph aggregate design | +| hardware/esp32/tdm | TDM slot scheduling | Frame timing, cycle control | +| hardware/esp32/csi_extract | Raw CSI extraction | Pipeline input | +| sensing-server | Axum WebSocket + REST | Visualization delivery | + +--- + +## 3. New Module Design + +### 3.1 Module Location + +``` +rust-port/wifi-densepose-rs/crates/wifi-densepose-signal/src/ruvsense/ + rf_topology.rs <-- New module (primary) + rf_topology/ + graph.rs <-- RfGraph aggregate root + edge_weight.rs <-- EdgeWeight computation + mincut.rs <-- Dynamic mincut solver + boundary.rs <-- CutBoundary -> spatial polygon + events.rs <-- TopologyEvent domain events + mod.rs <-- Module re-exports +``` + +Alternatively, rf_topology could be a standalone crate: + +``` +rust-port/wifi-densepose-rs/crates/wifi-densepose-topology/ + src/ + lib.rs + graph.rs + edge_weight.rs + mincut.rs + boundary.rs + events.rs + Cargo.toml +``` + +The standalone crate approach is preferred because RF topology has distinct +bounded-context semantics and its own aggregate root (RfGraph). It depends on +wifi-densepose-signal for coherence computation and wifi-densepose-core for +shared types. + +### 3.2 Key Types + +#### RfGraph — Aggregate Root + +RfGraph is the central aggregate root. It owns the complete graph state: nodes, +edges, weights, and metadata. All mutations go through RfGraph methods, which +emit TopologyEvents for downstream consumers. + +``` +RfGraph { + id: GraphId, + nodes: HashMap, + edges: HashMap, + adjacency: AdjacencyMatrix, + epoch: u64, // incremented on each full TDM cycle + last_updated: Instant, + config: TopologyConfig, +} +``` + +Invariants enforced by RfGraph: +- No self-loops (tx_id != rx_id) +- Edge weights are in [0.0, 1.0] +- Stale edges (no update in N cycles) are pruned +- Graph is always connected (disconnected subgraphs trigger alert) + +#### EdgeWeight — Value Object + +``` +EdgeWeight { + tx_id: NodeId, + rx_id: NodeId, + weight: f64, // 0.0 = fully obstructed, 1.0 = clear + raw_coherence: f64, // pre-normalization coherence + confidence: f64, // measurement quality [0.0, 1.0] + sample_count: u32, // number of CSI frames averaged + baseline_deviation: f64, // how far from calibrated baseline + updated_at: Instant, +} +``` + +EdgeWeight is a value object: immutable after creation. Each TDM cycle produces +a new EdgeWeight for each edge, which replaces the previous one in RfGraph. + +#### CutBoundary — Value Object + +``` +CutBoundary { + cut_edges: Vec, // edges that cross the boundary + cut_value: f64, // total weight of cut edges + partition_a: Vec, // nodes on one side + partition_b: Vec, // nodes on the other side + spatial_boundary: Option, // interpolated physical boundary + confidence: f64, // based on edge confidences + detected_at: Instant, +} +``` + +CutBoundary represents the output of the mincut solver. Multiple CutBoundaries +can exist simultaneously when multiple people are detected. + +#### TopologyEvent — Domain Event + +``` +TopologyEvent { + id: EventId, + timestamp: Instant, + kind: TopologyEventKind, +} + +enum TopologyEventKind { + NodeJoined { node_id: NodeId, position: (f64, f64) }, + NodeLeft { node_id: NodeId, reason: LeaveReason }, + EdgeWeightChanged { edge_id: EdgeId, old: f64, new: f64 }, + BoundaryDetected { boundary: CutBoundary }, + BoundaryMoved { boundary_id: BoundaryId, displacement: (f64, f64) }, + BoundaryLost { boundary_id: BoundaryId }, + GraphPartitioned { components: Vec> }, + CalibrationRequired { reason: String }, +} +``` + +Events are published to an event bus. The sensing server subscribes and +forwards relevant events to the browser UI via WebSocket. + +### 3.3 DDD Aggregate Root Design + +``` ++-------------------------------------------------------------------+ +| RfGraph (Aggregate Root) | +| | +| +------------------+ +-----------------+ +---------------+ | +| | NodeRegistry | | EdgeRegistry | | CutSolver | | +| | | | | | | | +| | - register() | | - update_wt() | | - solve() | | +| | - deregister() | | - prune_stale() | | - track() | | +| | - get_position() | | - get_weight() | | - boundaries | | +| +------------------+ +-----------------+ +---------------+ | +| | +| Command Interface: | +| fn ingest_csi_frame(&mut self, frame: CsiFrame) -> Vec | +| fn tick(&mut self) -> Vec | +| fn calibrate(&mut self, baseline: &Baseline) -> Vec | +| fn add_node(&mut self, node: NodeInfo) -> Vec | +| fn remove_node(&mut self, node_id: NodeId) -> Vec | +| | +| Query Interface: | +| fn current_boundaries(&self) -> &[CutBoundary] | +| fn edge_weight(&self, a: NodeId, b: NodeId) -> Option | +| fn graph_snapshot(&self) -> GraphSnapshot | +| fn node_count(&self) -> usize | +| fn is_connected(&self) -> bool | ++-------------------------------------------------------------------+ + | + | emits + v + Vec + | + v + +---------------------+ + | Event Bus | + | (tokio broadcast) | + +---------------------+ + | | + v v + Sensing Server Pose Tracker + (WebSocket) (ruvsense) +``` + +### 3.4 Module Responsibilities + +| File | Responsibility | LOC Estimate | +|------------------|---------------------------------------|--------------| +| graph.rs | RfGraph aggregate, node/edge registry | ~200 | +| edge_weight.rs | Weight computation from CSI coherence | ~120 | +| mincut.rs | Stoer-Wagner and incremental mincut | ~180 | +| boundary.rs | Cut-to-polygon interpolation | ~150 | +| events.rs | TopologyEvent types and bus | ~80 | +| mod.rs | Public API re-exports | ~30 | +| **Total** | | **~760** | + +All files stay under the 500-line limit by splitting graph.rs if needed. + +--- + +## 4. Real-Time Pipeline + +### 4.1 Latency Budget + +The system must produce updated boundary estimates within 100ms of a CSI +frame arrival. This enables responsive real-time visualization and is +sufficient for human-speed movement tracking. + +``` ++============================================================================+ +| LATENCY BUDGET: 100ms TOTAL | ++============================================================================+ + + Stage Budget Actual Target Notes + ~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~ ~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~ + 1. CSI Extraction 5 ms 3-5 ms ESP32 hardware, fixed + 2. Phase Alignment 3 ms 1-2 ms Per-edge, parallelizable + 3. Edge Weight Comp 10 ms 5-8 ms Coherence + normalization + 4. Graph Update 2 ms 0.5-1 ms HashMap insert/update + 5. Mincut Solver 5 ms 2-5 ms Stoer-Wagner on N<64 + 6. Boundary Interp 5 ms 2-3 ms Polygon from cut edges + 7. Serialization 2 ms 0.5-1 ms serde_json or bincode + 8. WebSocket TX 3 ms 1-2 ms Local network + 9. Browser Render 20 ms 10-16 ms Canvas 2D at 60fps + ~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~ ~~~~~~~~~~~~~~ + TOTAL 55 ms 26-43 ms ~50ms headroom + + Margin for safety: 45 ms Absorbs GC, jitter, WiFi +``` + +### 4.2 Stage Details + +#### Stage 1: CSI Extraction (5ms budget) + +The ESP32 extracts CSI from each received packet. This happens in firmware +and is bounded by the WiFi hardware. The output is a 52-element complex +vector plus metadata (RSSI, noise floor, timestamp). + +``` +Input: WiFi packet on air +Output: CsiFrame { subcarriers: [Complex; 52], rssi: i8, ... } +Cost: Fixed by hardware. ~3ms on ESP32-S3, ~5ms on ESP32. +``` + +#### Stage 2: Phase Alignment (3ms budget) + +Phase alignment corrects for local oscillator drift between TX and RX nodes. +Uses the circular mean algorithm from ruvsense/phase_align.rs. This runs +once per edge per frame. + +``` +Input: CsiFrame pair (TX reference, RX measurement) +Output: AlignedCsiPair with corrected phase +Cost: ~50us per edge. For 16 nodes (120 edges): 6ms sequential, <1ms parallel +Note: Embarrassingly parallel across edges. Use rayon par_iter. +``` + +#### Stage 3: Edge Weight Computation (10ms budget) + +Compute coherence between current CSI and baseline CSI. Apply temporal +averaging (exponential moving average over last K frames). Normalize to +[0.0, 1.0] range. Apply adversarial physics check. + +``` +Input: AlignedCsiPair + baseline reference +Output: EdgeWeight { weight, confidence, ... } +Cost: ~80us per edge. For 120 edges: 9.6ms sequential, <2ms parallel +Pipeline: + 1. coherence::ZScoreCoherence::score() ~30us + 2. temporal_average() ~10us + 3. adversarial::PhysicsChecker::validate() ~20us + 4. normalize_and_gate() ~20us +``` + +#### Stage 4: Graph Update (2ms budget) + +Insert new edge weights into RfGraph. Prune stale edges. Check connectivity. +This is a simple HashMap operation. + +``` +Input: Vec from current TDM cycle +Output: Updated RfGraph, list of changed edges +Cost: O(E) where E = number of edges. <1ms for E < 500. +``` + +#### Stage 5: Mincut Solver (5ms budget) + +Run Stoer-Wagner minimum cut on the weighted graph. For small graphs (N < 64), +Stoer-Wagner runs in O(V * E + V^2 * log V) which is well within budget. + +``` +Input: RfGraph adjacency matrix with weights +Output: CutBoundary (minimum cut edges + partitions) +Cost: 4-node: ~0.1ms + 16-node: ~2ms + 64-node: ~15ms (exceeds budget -- use incremental solver) +``` + +For graphs larger than ~40 nodes, use incremental mincut: only recompute +the cut in the neighborhood of changed edges. This keeps the cost under +5ms regardless of total graph size. + +#### Stage 6: Boundary Interpolation (5ms budget) + +Convert the cut edges into a spatial polygon by interpolating between the +known positions of the nodes on either side of the cut. + +``` +Input: CutBoundary + node positions +Output: BoundaryPolygon { vertices: Vec<(f64, f64)> } +Cost: Convex hull + smoothing. <3ms for typical boundaries. +``` + +#### Stage 7-9: Serialization, Transport, Render (25ms budget) + +Serialize boundary polygon to JSON, send over WebSocket, render in browser. + +``` +Serialization: serde_json::to_string(&boundary) -- <1ms +WebSocket TX: axum tungstenite broadcast -- <2ms local +Browser render: Canvas 2D path drawing -- 10-16ms at 60fps +``` + +### 4.3 Timing Diagram + +``` +Time (ms) 0 5 10 15 20 25 30 35 40 45 50 + | | | | | | | | | | | + [CSI ] + [Phs][ Edge Weight ] + [GU][Cut ] + [Bnd][Ser][WS] + [Render....] + |<-- ESP32 firmware --|<------ Rust pipeline -------->|<-- Browser ->| + | 5ms | ~25ms | ~16ms | + |<---------------------- Total: ~46ms ------------------------------>| +``` + +### 4.4 Parallelism Strategy + +``` ++-- rayon thread pool (4 threads on server, 1 on ESP32) --+ +| | +| Edge 0: [phase_align] -> [coherence] -> [weight] | +| Edge 1: [phase_align] -> [coherence] -> [weight] | +| Edge 2: [phase_align] -> [coherence] -> [weight] | +| ... | +| Edge N: [phase_align] -> [coherence] -> [weight] | +| | ++-- barrier: all edges complete --------+ | + | | + [graph_update] (single thread) | + [mincut_solve] (single thread) | + [boundary_interp] (single thread) | + [serialize + broadcast] | ++----------------------------------------------------------+ +``` + +Edge weight computation is embarrassingly parallel and dominates the pipeline +cost. Using rayon reduces this from O(E * cost_per_edge) to +O(E * cost_per_edge / num_threads). + +--- + +## 5. Prototype Phases + +### 5.1 Phase 1: 4-Node Proof of Concept + +**Goal**: Detect a single person entering a square region bounded by 4 ESP32 nodes. + +``` + Node A ─────────── Node B + | \ / | + | \ / | + | \ / | + | [X] | X = person standing here + | / \ | + | / \ | + | / \ | + Node D ─────────── Node C + + Edges: A-B, A-C, A-D, B-C, B-D, C-D (6 total) + Room size: 3m x 3m +``` + +**Setup**: +- 4x ESP32-S3 DevKitC boards +- Nodes at corners of a 3m x 3m room +- Single WiFi channel (channel 6, 2.437 GHz) +- TDM with 1ms slots = 4ms cycle = 250 Hz update rate + +**Success Criteria**: +- Detect person presence within 500ms of entering the room +- Correctly identify which quadrant the person is in +- No false positives when room is empty (over 10-minute test) +- Mincut correctly separates the person from at least one node + +**Deliverables**: +- Working TDM firmware on 4 ESP32 boards +- Rust pipeline processing CSI in real-time +- Web UI showing graph with highlighted cut edges +- Calibration procedure documented + +**Timeline**: 4 weeks + +``` +Week 1: TDM firmware bring-up, CSI extraction verified +Week 2: Edge weight pipeline, baseline calibration +Week 3: Mincut integration, boundary detection logic +Week 4: Web UI, end-to-end test, benchmark +``` + +### 5.2 Phase 2: 16-Node Room Scale + +**Goal**: Track the spatial boundaries of 1-3 people moving through a room. + +``` + A ── B ── C ── D + | \ | /\ | /\ | + E ── F ── G ── H + | / | \/ | \/ | + I ── J ── K ── L + | \ | /\ | /\ | + M ── N ── O ── P + + 16 nodes, 4x4 grid, 1.5m spacing + Edges: up to 120 (each node connects to all others within range) + Room size: 4.5m x 4.5m +``` + +**New Capabilities**: +- Multi-person detection via multi-way mincut (k-cut) +- Boundary tracking across frames (temporal association) +- Adaptive baseline recalibration (furniture changes) +- Channel hopping for improved resolution + +**Success Criteria**: +- Track 1-3 people simultaneously +- Boundary position error < 50cm (compared to ground truth) +- Update rate >= 30 Hz (33ms per cycle) +- Handle person entry/exit without false boundaries +- Recover from node failure (1 of 16 goes offline) + +**Deliverables**: +- Scalable TDM scheduler for 16 nodes +- Multi-cut solver with temporal tracking +- Boundary tracking with ID assignment +- Performance dashboard showing latency breakdown +- Comparison against camera ground truth + +**Timeline**: 8 weeks + +``` +Week 1-2: Scale TDM to 16 nodes, test reliability +Week 3-4: Multi-cut solver, k-way partitioning +Week 5-6: Temporal tracking, boundary ID persistence +Week 7: Channel hopping, multi-band fusion +Week 8: Benchmark suite, ground truth comparison +``` + +### 5.3 Phase 3: Multi-Room Mesh + +**Goal**: Extend to multi-room deployment with hierarchical graph structure. + +``` + +------------------+ +------------------+ + | Room A (16 nodes)| | Room B (16 nodes)| + | | | | + | Local RfGraph | | Local RfGraph | + | | | | + +--------+---------+ +--------+---------+ + | | + | gateway edges | gateway edges + | | + +--------+-------------------------+--------+ + | Hallway (8 nodes) | + | Corridor RfGraph | + +--------+-------------------------+--------+ + | | + +--------+---------+ +--------+---------+ + | Room C (16 nodes)| | Room D (16 nodes)| + | | | | + +------------------+ +------------------+ + + Total: 72 nodes across 5 zones + Hierarchical mincut: local cuts + cross-zone cuts +``` + +**New Capabilities**: +- Hierarchical graph: room-level graphs with inter-room gateway edges +- Cross-room person tracking (handoff between local graphs) +- Distributed processing: each room runs its own mincut, global coordinator + merges boundaries +- Environment fingerprinting (reuse ruvsense/cross_room.rs) +- Fault tolerance: room operates independently if gateway fails + +**Success Criteria**: +- Track people across room transitions +- Latency < 100ms even with 72 nodes (via hierarchical decomposition) +- Handle node failures gracefully (degrade, don't crash) +- Boundary accuracy < 50cm within rooms, < 1m across transitions + +**Timeline**: 16 weeks + +### 5.4 Phase Summary + +``` +Phase Nodes Edges People Accuracy Update Rate Duration +~~~~~~ ~~~~~~ ~~~~~~ ~~~~~~~ ~~~~~~~~~ ~~~~~~~~~~~ ~~~~~~~~ + 1 4 6 1 Quadrant 250 Hz 4 weeks + 2 16 120 1-3 < 50cm 30 Hz 8 weeks + 3 72 ~500 5-10 < 50cm 30 Hz 16 weeks +``` + +--- + +## 6. Benchmark + +### 6.1 Primary Benchmark: Person Moving Through Room + +**Scenario**: A single person walks a known path through the 16-node room +(Phase 2 setup). Ground truth is captured by an overhead camera with +ArUco markers on the person's shoulders. + +``` + A ── B ── C ── D + | | | | + E ── F ── G ── H + | | | | Person path: start at (+), walk to (*), + I ── J ── K ── L then to (#), then exit + | | | | + M ── N ── O ── P + + Path: (+) near F + | + v + (*) near K + | + v + (#) near O + | + v + exit past P +``` + +### 6.2 Setup + +**Hardware**: +- 16x ESP32-S3 DevKitC, mounted at 1.2m height on stands +- Grid spacing: 1.5m +- Room dimensions: 4.5m x 4.5m, cleared of furniture for baseline +- 1x overhead USB camera, 30fps, for ground truth +- 4x ArUco markers on person (shoulders, hips) + +**Software**: +- TDM cycle: 16ms (16 nodes x 1ms slots) +- Update rate: 62.5 Hz +- Mincut solver: Stoer-Wagner +- Edge weight: exponential moving average, alpha = 0.3 +- Baseline: 60 seconds of empty room calibration + +**Environment**: +- Standard office room, concrete walls +- WiFi channel 6 (2.437 GHz), no other AP on same channel +- Temperature: 20-25C (stable) +- Test duration: 5 minutes per run, 10 runs total + +### 6.3 Metrics + +| Metric | Definition | Target | +|-------------------------------|---------------------------------------------------------|-------------| +| **Boundary Position Error** | Distance from detected boundary centroid to GT position | < 50cm | +| **Detection Latency** | Time from person entering room to first boundary detect | < 500ms | +| **Tracking Continuity** | % of frames where boundary is detected while person present | > 95% | +| **False Positive Rate** | Boundaries detected per minute when room is empty | < 0.1/min | +| **Pipeline Latency (P95)** | 95th percentile CSI-to-boundary time | < 100ms | +| **Pipeline Latency (P50)** | Median CSI-to-boundary time | < 50ms | +| **Update Throughput** | Boundary updates delivered to UI per second | > 30/s | +| **Node Failure Recovery** | Time to stable operation after 1 node goes offline | < 5s | + +### 6.4 Success Criteria + +The benchmark PASSES if ALL of the following hold over 10 runs: + +1. Mean boundary position error < 50cm +2. 95th percentile boundary position error < 75cm +3. Detection latency < 500ms in 9/10 runs +4. Tracking continuity > 95% in 9/10 runs +5. Zero false positives in empty room (10-minute test) +6. Pipeline latency P95 < 100ms in all runs +7. No crashes or hangs during any run + +### 6.5 Data Collection + +``` +Output files per run: + benchmark_run_{N}/ + csi_raw/ # Raw CSI frames, timestamped + edge_weights/ # Computed weights per edge per frame + boundaries/ # Detected boundaries with timestamps + ground_truth/ # Camera-derived positions with timestamps + latency_log.csv # Per-frame pipeline timing breakdown + summary.json # Aggregate metrics for this run +``` + +### 6.6 Analysis + +Post-benchmark analysis computes: + +1. **Error distribution**: Histogram of boundary position errors +2. **Error vs. position**: Heat map of error across the room (corner vs. center) +3. **Latency breakdown**: Stacked bar chart of pipeline stages +4. **Temporal stability**: Boundary position over time vs. ground truth +5. **Edge weight visualization**: Animation of edge weights during walk + +Expected failure modes: +- Higher error near room edges (fewer surrounding nodes) +- Brief detection gaps during fast movement +- Increased error when person is exactly between two nodes (ambiguous cut) + +--- + +## 7. ADR-044 Draft + +### ADR-044: RF Topological Sensing + +**Status**: Proposed + +**Date**: 2026-03-08 + +#### Context + +The wifi-densepose system currently estimates human pose by processing CSI +data through neural network models (wifi-densepose-nn). This approach requires +training data, GPU inference, and per-environment calibration of the neural +model. The RuvSense multistatic sensing mode (ADR-029) improved robustness +through multi-link fusion but still treats each link independently before +fusion. + +A fundamentally different approach is possible: treat the entire ESP32 mesh +as a graph where TX-RX pairs are edges and CSI coherence determines edge +weights. A minimum cut of this graph reveals physical boundaries — the +locations where radio propagation is disrupted by human bodies. This is +"RF topological sensing." + +Key motivations: +- **No training data required**: The mincut is a pure graph algorithm, not a + learned model. It works out of the box after baseline calibration. +- **Physics-grounded**: The approach directly exploits the physical fact that + human bodies attenuate and scatter radio waves. +- **Graceful degradation**: If nodes fail, the graph simply has fewer edges. + The mincut still works, with reduced resolution. +- **Complementary to neural approach**: Topological boundaries can provide + spatial priors to the neural pose estimator, improving accuracy. + +#### Decision + +We will implement RF topological sensing as a new module in the workspace. +The module will: + +1. Define an RfGraph aggregate root that maintains a weighted graph of all + TX-RX links in the mesh. + +2. Compute edge weights from CSI coherence using existing ruvsense modules + (coherence.rs, phase_align.rs). + +3. Run dynamic minimum cut to detect physical boundaries in real time. + +4. Expose boundaries via the sensing server WebSocket for visualization. + +5. Publish TopologyEvents that downstream modules (pose_tracker, intention) + can consume for spatial priors. + +The implementation will proceed in three phases: +- Phase 1: 4-node proof of concept (detect person presence) +- Phase 2: 16-node room scale (track boundaries with < 50cm error) +- Phase 3: Multi-room mesh with hierarchical graph decomposition + +#### Consequences + +**Positive**: +- Enables WiFi sensing without neural network inference or training data +- Provides spatial boundary information that is complementary to pose estimation +- Reuses existing ruvsense modules for coherence and phase alignment +- Follows DDD patterns established in ruvector/viewpoint/fusion.rs +- Gracefully degrades under node failure +- Sub-100ms latency enables real-time applications + +**Negative**: +- Requires minimum 4 ESP32 nodes (higher hardware cost than single-link) +- Mincut provides boundaries, not poses — pose still requires neural inference + or additional geometric reasoning +- Stoer-Wagner complexity O(V*E + V^2 log V) limits scalability beyond ~40 nodes + without incremental solver +- Additional firmware complexity for TDM synchronization across many nodes +- New testing infrastructure needed for graph algorithms + +**Neutral**: +- Does not replace existing neural pose estimation; supplements it +- Phase 1 can validate the approach before committing to full implementation +- May inform future ADRs on distributed sensing architecture + +#### References + +- ADR-029: RuvSense multistatic sensing mode +- ADR-028: ESP32 capability audit +- ADR-014: SOTA signal processing +- Research Doc 10: This document + +--- + +## 8. Rust Trait Definitions + +### 8.1 Core Traits + +```rust +/// Unique identifier for a node in the RF mesh. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct NodeId(pub u16); + +/// Unique identifier for an edge (ordered pair of nodes). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct EdgeId { + pub tx: NodeId, + pub rx: NodeId, +} + +impl EdgeId { + /// Create a canonical edge ID where tx < rx to avoid duplicates. + pub fn canonical(a: NodeId, b: NodeId) -> Self { + if a.0 <= b.0 { + Self { tx: a, rx: b } + } else { + Self { tx: b, rx: a } + } + } +} + +/// Physical position of a node in 2D space (meters). +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +pub struct Position2D { + pub x: f64, + pub y: f64, +} + +/// Information about a node in the mesh. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NodeInfo { + pub id: NodeId, + pub position: Position2D, + pub mac_address: [u8; 6], + pub tdm_slot: u8, + pub joined_at: u64, // unix timestamp ms +} +``` + +### 8.2 Edge Weight Trait + +```rust +/// Trait for computing edge weights from CSI measurements. +pub trait EdgeWeightComputer: Send + Sync { + /// Compute the weight for an edge given current and baseline CSI. + fn compute( + &self, + current: &CsiFrame, + baseline: &CsiFrame, + config: &EdgeWeightConfig, + ) -> Result; + + /// Update the temporal average for an edge. + fn update_average( + &self, + previous: &EdgeWeight, + new_sample: &EdgeWeight, + alpha: f64, + ) -> EdgeWeight; +} + +/// Configuration for edge weight computation. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EdgeWeightConfig { + /// Exponential moving average smoothing factor. + pub ema_alpha: f64, + /// Minimum confidence to accept a measurement. + pub min_confidence: f64, + /// Number of subcarriers to use (0 = all). + pub subcarrier_count: usize, + /// Enable adversarial physics check. + pub physics_check: bool, +} + +impl Default for EdgeWeightConfig { + fn default() -> Self { + Self { + ema_alpha: 0.3, + min_confidence: 0.5, + subcarrier_count: 0, + physics_check: true, + } + } +} +``` + +### 8.3 Graph Trait + +```rust +/// Trait for the RF topology graph. +pub trait TopologyGraph: Send + Sync { + /// Add a node to the graph. + fn add_node(&mut self, node: NodeInfo) -> Result, TopologyError>; + + /// Remove a node and all its edges. + fn remove_node(&mut self, id: NodeId) -> Result, TopologyError>; + + /// Update the weight of an edge. Creates the edge if it doesn't exist. + fn update_edge( + &mut self, + edge: EdgeId, + weight: EdgeWeight, + ) -> Result, TopologyError>; + + /// Remove edges that haven't been updated in `max_age` duration. + fn prune_stale(&mut self, max_age: std::time::Duration) -> Vec; + + /// Get the current weight of an edge. + fn edge_weight(&self, edge: EdgeId) -> Option<&EdgeWeight>; + + /// Get all edges as (EdgeId, weight) pairs. + fn edges(&self) -> Vec<(EdgeId, f64)>; + + /// Get the number of nodes. + fn node_count(&self) -> usize; + + /// Get the number of edges. + fn edge_count(&self) -> usize; + + /// Check if the graph is connected. + fn is_connected(&self) -> bool; + + /// Get a snapshot of the adjacency matrix for mincut computation. + fn adjacency_matrix(&self) -> AdjacencyMatrix; +} +``` + +### 8.4 Mincut Solver Trait + +```rust +/// Result of a minimum cut computation. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MinCutResult { + /// Edges that form the minimum cut. + pub cut_edges: Vec, + /// Total weight of the cut. + pub cut_value: f64, + /// Nodes in partition A. + pub partition_a: Vec, + /// Nodes in partition B. + pub partition_b: Vec, +} + +/// Trait for minimum cut solvers. +pub trait MinCutSolver: Send + Sync { + /// Compute the global minimum cut of the graph. + fn min_cut(&self, graph: &AdjacencyMatrix) -> Result; + + /// Compute a k-way minimum cut (for multi-person detection). + fn k_cut( + &self, + graph: &AdjacencyMatrix, + k: usize, + ) -> Result, TopologyError>; + + /// Incrementally update the cut after edge weight changes. + /// Returns None if the cut topology hasn't changed. + fn incremental_update( + &self, + previous_cut: &MinCutResult, + changed_edges: &[(EdgeId, f64, f64)], // (edge, old_weight, new_weight) + graph: &AdjacencyMatrix, + ) -> Result, TopologyError>; +} + +/// Stoer-Wagner implementation of MinCutSolver. +pub struct StoerWagnerSolver { + /// Cache the last contraction order for incremental updates. + last_contraction: Option>, +} + +impl MinCutSolver for StoerWagnerSolver { + fn min_cut(&self, graph: &AdjacencyMatrix) -> Result { + // Stoer-Wagner algorithm: + // 1. Start with arbitrary node + // 2. Repeatedly add "most tightly connected" node + // 3. Last two nodes define a cut candidate + // 4. Merge last two nodes, repeat + // 5. Return minimum cut found across all phases + todo!("Implement Stoer-Wagner") + } + + fn k_cut( + &self, + graph: &AdjacencyMatrix, + k: usize, + ) -> Result, TopologyError> { + // Recursive approach: + // 1. Find global mincut -> 2 partitions + // 2. Recursively find mincut in larger partition + // 3. Repeat until k partitions + todo!("Implement recursive k-cut") + } + + fn incremental_update( + &self, + previous_cut: &MinCutResult, + changed_edges: &[(EdgeId, f64, f64)], + graph: &AdjacencyMatrix, + ) -> Result, TopologyError> { + // Heuristic: if no changed edge crosses the previous cut, + // and no weight changed by more than threshold, keep previous cut. + let cut_edge_set: std::collections::HashSet<_> = + previous_cut.cut_edges.iter().collect(); + + let significant_change = changed_edges.iter().any(|(edge, old, new)| { + let delta = (new - old).abs(); + cut_edge_set.contains(edge) && delta > 0.1 + }); + + if !significant_change { + return Ok(None); // Cut unchanged + } + + // Recompute full mincut + self.min_cut(graph).map(Some) + } +} +``` + +### 8.5 Boundary Interpolation Trait + +```rust +/// A polygon representing a physical boundary in 2D space. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BoundaryPolygon { + /// Vertices of the boundary polygon (meters, room coordinates). + pub vertices: Vec, + /// Confidence of this boundary (0.0 to 1.0). + pub confidence: f64, + /// Unique ID for tracking across frames. + pub boundary_id: u64, + /// Timestamp of detection. + pub detected_at_ms: u64, +} + +/// Trait for converting graph cuts into spatial boundaries. +pub trait BoundaryInterpolator: Send + Sync { + /// Convert a minimum cut result into a spatial boundary polygon. + fn interpolate( + &self, + cut: &MinCutResult, + node_positions: &std::collections::HashMap, + ) -> Result; + + /// Smooth a boundary using previous frame's boundary (temporal filtering). + fn smooth( + &self, + current: &BoundaryPolygon, + previous: &BoundaryPolygon, + alpha: f64, + ) -> BoundaryPolygon; +} + +/// Midpoint interpolation: boundary passes through midpoints of cut edges. +pub struct MidpointInterpolator; + +impl BoundaryInterpolator for MidpointInterpolator { + fn interpolate( + &self, + cut: &MinCutResult, + node_positions: &std::collections::HashMap, + ) -> Result { + let mut midpoints: Vec = Vec::new(); + + for edge in &cut.cut_edges { + let pos_a = node_positions + .get(&edge.tx) + .ok_or(TopologyError::NodeNotFound(edge.tx))?; + let pos_b = node_positions + .get(&edge.rx) + .ok_or(TopologyError::NodeNotFound(edge.rx))?; + + midpoints.push(Position2D { + x: (pos_a.x + pos_b.x) / 2.0, + y: (pos_a.y + pos_b.y) / 2.0, + }); + } + + // Order midpoints to form a non-self-intersecting polygon + // using angular sort around centroid + let cx: f64 = midpoints.iter().map(|p| p.x).sum::() / midpoints.len() as f64; + let cy: f64 = midpoints.iter().map(|p| p.y).sum::() / midpoints.len() as f64; + + midpoints.sort_by(|a, b| { + let angle_a = (a.y - cy).atan2(a.x - cx); + let angle_b = (b.y - cy).atan2(b.x - cx); + angle_a.partial_cmp(&angle_b).unwrap() + }); + + Ok(BoundaryPolygon { + vertices: midpoints, + confidence: 1.0 - cut.cut_value, // lower cut value = more confident + boundary_id: 0, // assigned by tracker + detected_at_ms: 0, // set by caller + }) + } + + fn smooth( + &self, + current: &BoundaryPolygon, + previous: &BoundaryPolygon, + alpha: f64, + ) -> BoundaryPolygon { + // Simple vertex-wise EMA when vertex counts match + if current.vertices.len() != previous.vertices.len() { + return current.clone(); + } + + let smoothed: Vec = current + .vertices + .iter() + .zip(previous.vertices.iter()) + .map(|(c, p)| Position2D { + x: alpha * c.x + (1.0 - alpha) * p.x, + y: alpha * c.y + (1.0 - alpha) * p.y, + }) + .collect(); + + BoundaryPolygon { + vertices: smoothed, + confidence: alpha * current.confidence + (1.0 - alpha) * previous.confidence, + boundary_id: current.boundary_id, + detected_at_ms: current.detected_at_ms, + } + } +} +``` + +### 8.6 Pipeline Orchestrator + +```rust +/// The main pipeline that ties all stages together. +pub struct TopologyPipeline { + graph: Box, + weight_computer: Box, + mincut_solver: Box, + boundary_interpolator: Box, + event_tx: tokio::sync::broadcast::Sender, + config: PipelineConfig, + baselines: std::collections::HashMap, + last_cut: Option, + last_boundary: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PipelineConfig { + /// Maximum age before an edge is pruned. + pub stale_edge_timeout_ms: u64, + /// Edge weight computation config. + pub edge_weight: EdgeWeightConfig, + /// Minimum cut value change to trigger boundary update. + pub cut_change_threshold: f64, + /// Temporal smoothing factor for boundary polygon. + pub boundary_smoothing_alpha: f64, + /// Maximum number of simultaneous boundaries to track. + pub max_boundaries: usize, +} + +impl TopologyPipeline { + /// Process a batch of CSI frames from one TDM cycle. + /// + /// This is the main entry point, called once per TDM cycle. + /// Returns all topology events generated during processing. + pub async fn process_cycle( + &mut self, + frames: Vec, + ) -> Result, TopologyError> { + let mut all_events = Vec::new(); + + // Stage 2-3: Compute edge weights and update graph (parallel) + let weights: Vec<(EdgeId, EdgeWeight)> = frames + .par_iter() + .filter_map(|frame| { + let edge = EdgeId::canonical( + NodeId(frame.tx_id), + NodeId(frame.rx_id), + ); + let baseline = self.baselines.get(&edge)?; + let weight = self.weight_computer + .compute(frame, baseline, &self.config.edge_weight) + .ok()?; + Some((edge, weight)) + }) + .collect(); + + // Stage 3: Update graph + let mut changed_edges = Vec::new(); + for (edge_id, weight) in &weights { + let old_weight = self.graph + .edge_weight(*edge_id) + .map(|w| w.weight) + .unwrap_or(1.0); + let events = self.graph.update_edge(*edge_id, weight.clone())?; + changed_edges.push((*edge_id, old_weight, weight.weight)); + all_events.extend(events); + } + + // Prune stale edges + let stale_timeout = + std::time::Duration::from_millis(self.config.stale_edge_timeout_ms); + let prune_events = self.graph.prune_stale(stale_timeout); + all_events.extend(prune_events); + + // Stage 4: Mincut + let adjacency = self.graph.adjacency_matrix(); + let cut_result = if let Some(ref prev_cut) = self.last_cut { + self.mincut_solver + .incremental_update(prev_cut, &changed_edges, &adjacency)? + .unwrap_or_else(|| prev_cut.clone()) + } else { + self.mincut_solver.min_cut(&adjacency)? + }; + self.last_cut = Some(cut_result.clone()); + + // Stage 5: Boundary interpolation + let node_positions = self.node_position_map(); + let mut boundary = self + .boundary_interpolator + .interpolate(&cut_result, &node_positions)?; + + // Temporal smoothing + if let Some(ref prev_boundary) = self.last_boundary { + boundary = self.boundary_interpolator.smooth( + &boundary, + prev_boundary, + self.config.boundary_smoothing_alpha, + ); + } + self.last_boundary = Some(boundary.clone()); + + // Emit boundary event + all_events.push(TopologyEvent { + id: EventId::new(), + timestamp: std::time::Instant::now(), + kind: TopologyEventKind::BoundaryDetected { + boundary: CutBoundary { + cut_edges: cut_result.cut_edges, + cut_value: cut_result.cut_value, + partition_a: cut_result.partition_a, + partition_b: cut_result.partition_b, + spatial_boundary: Some(boundary), + confidence: cut_result.cut_value, + detected_at: std::time::Instant::now(), + }, + }, + }); + + // Broadcast events + for event in &all_events { + let _ = self.event_tx.send(event.clone()); + } + + Ok(all_events) + } + + fn node_position_map(&self) -> std::collections::HashMap { + // Build from graph's node registry + todo!("Extract node positions from graph") + } +} +``` + +### 8.7 Error Types + +```rust +/// Errors that can occur in the topology pipeline. +#[derive(Debug, thiserror::Error)] +pub enum TopologyError { + #[error("Node not found: {0:?}")] + NodeNotFound(NodeId), + + #[error("Edge not found: {0:?} -> {1:?}")] + EdgeNotFound(NodeId, NodeId), + + #[error("Graph is disconnected: {0} components")] + GraphDisconnected(usize), + + #[error("Insufficient nodes for mincut: need >= 2, have {0}")] + InsufficientNodes(usize), + + #[error("Baseline not available for edge {0:?}")] + NoBaseline(EdgeId), + + #[error("CSI frame invalid: {0}")] + InvalidCsiFrame(String), + + #[error("Mincut solver failed: {0}")] + SolverError(String), + + #[error("Calibration required: {0}")] + CalibrationRequired(String), +} +``` + +### 8.8 Adjacency Matrix + +```rust +/// Dense adjacency matrix for mincut computation. +/// +/// Uses a flat Vec for cache-friendly access. Indexed as +/// matrix[row * dimension + col]. +#[derive(Debug, Clone)] +pub struct AdjacencyMatrix { + /// Node IDs in index order. + pub nodes: Vec, + /// Flat weight matrix (dimension x dimension). + pub weights: Vec, + /// Matrix dimension (= nodes.len()). + pub dimension: usize, +} + +impl AdjacencyMatrix { + pub fn new(nodes: Vec) -> Self { + let dim = nodes.len(); + Self { + nodes, + weights: vec![0.0; dim * dim], + dimension: dim, + } + } + + pub fn get(&self, row: usize, col: usize) -> f64 { + self.weights[row * self.dimension + col] + } + + pub fn set(&mut self, row: usize, col: usize, value: f64) { + self.weights[row * self.dimension + col] = value; + self.weights[col * self.dimension + row] = value; // symmetric + } + + /// Find the index of a node, or None if not present. + pub fn node_index(&self, id: NodeId) -> Option { + self.nodes.iter().position(|n| *n == id) + } +} +``` + +--- + +## Appendix A: Glossary + +| Term | Definition | +|-----------------------|-------------------------------------------------------------------| +| CSI | Channel State Information -- per-subcarrier complex amplitude | +| TDM | Time Division Multiplexing -- collision-free TX scheduling | +| Mincut | Minimum cut -- partition of graph that minimizes total edge weight | +| Stoer-Wagner | Deterministic O(VE + V^2 log V) mincut algorithm | +| Edge weight | Coherence metric on a TX-RX link; low = obstructed | +| Boundary | Spatial region where mincut edges intersect physical space | +| Aggregate root | DDD pattern -- single entry point for a consistency boundary | +| EMA | Exponential Moving Average -- temporal smoothing filter | + +## Appendix B: Related ADRs + +| ADR | Title | Relevance | +|-------|----------------------------------------|------------------------------------| +| 014 | SOTA signal processing | Coherence and phase algorithms | +| 028 | ESP32 capability audit | Hardware constraints and TDM | +| 029 | RuvSense multistatic sensing | Multi-link fusion architecture | +| 030 | RuvSense persistent field model | Baseline calibration approach | +| 031 | RuView sensing-first RF mode | UI integration pattern | +| 044 | RF Topological Sensing (this doc) | Architecture decision | + +## Appendix C: Open Questions + +1. **Stoer-Wagner vs. Push-Relabel**: Which mincut algorithm is better for + incremental updates? Push-relabel may allow warm-starting from previous + flow solution. + +2. **Multi-person disambiguation**: When k-cut finds multiple boundaries, how + do we associate boundaries across frames? Nearest-neighbor in spatial + coordinates? Hungarian algorithm on boundary centroids? + +3. **3D extension**: The current design is 2D (nodes at fixed height). Can we + extend to 3D by placing nodes at multiple heights? How does this affect + mincut interpretation? + +4. **Furniture vs. people**: Both attenuate CSI. Baseline calibration handles + static furniture, but what about moved chairs? Adaptive baseline with slow + drift tracking (ruvsense/longitudinal.rs) may help. + +5. **Optimal node placement**: Given a room geometry, where should N nodes be + placed to maximize boundary resolution? This is related to sensor placement + optimization and Fisher Information from ruvector/viewpoint/geometry.rs. + +6. **Latency at scale**: The 100ms budget assumes local processing. If graph + data must traverse a network (multi-room, Phase 3), how do we maintain + latency? Hierarchical decomposition with local mincut per room is the + current proposal. + +--- + +*End of Research Document 10*