feat: ADR-074/075/076 — SNN + MinCut + CNN Spectrogram (ruvector advanced sensing)

feat: ADR-074/075/076 — SNN + MinCut + CNN Spectrogram (ruvector advanced sensing)
This commit is contained in:
rUv 2026-04-03 08:00:07 -04:00 committed by GitHub
commit 4e9e92d713
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 3930 additions and 0 deletions

View File

@ -176,6 +176,20 @@ Channel hopping may cause the ESP32 to lose connection to the home AP (ruv.net o
3. **Wider bandwidth (HT40)**: using 40 MHz channels doubles subcarrier count per channel. Rejected because: (a) HT40 requires a secondary channel, reducing available channels for hopping; (b) many neighbor APs use HT20, so their illumination only covers 20 MHz.
## SNN Integration (ADR-074)
Multi-frequency scanning produces subcarrier data across 6 channels, creating temporal patterns that are well-suited for spiking neural network processing. ADR-074 introduces an SNN with STDP learning that consumes the multi-channel CSI stream.
**Key interactions with multi-frequency data:**
1. **Null diversity as SNN input**: subcarriers that are null on one channel but active on another produce a distinctive spike pattern (spikes only during certain channel dwells). STDP learns to associate these cross-channel patterns with specific objects or zones — something a single-channel SNN cannot do.
2. **Channel-interleaved temporal coding**: because each node dwells on 3 channels in a 750ms rotation, the SNN receives subcarrier data in a repeating temporal pattern (ch1 → ch2 → ch3 → ch1 ...). The SNN's LIF membrane dynamics integrate spikes across the rotation, naturally performing cross-channel fusion through temporal summation. A hidden neuron that receives spikes from subcarrier 15 on channel 1 AND subcarrier 15 on channel 6 will fire more strongly than one receiving either alone.
3. **Expanded input mode**: on the server (not constrained by ESP32 memory), the SNN can use 384 input neurons (6 channels x 64 subcarriers) instead of 128. This provides maximum spectral diversity per frame but requires ~150 KB of weight storage. The `snn-csi-processor.js` script supports this via the `--hidden` flag to scale the network.
4. **Illuminator fingerprinting**: different neighbor APs have different beamforming patterns and power levels. The SNN learns which subcarrier patterns belong to which illuminator, enabling it to distinguish AP-specific signatures from human-caused perturbations. This is especially useful for the NETGEAR dual-AP setup on channel 9, where two illuminators from different positions create stereo-like RF coverage.
## References
- ADR-018: CSI binary frame format
@ -183,5 +197,6 @@ Channel hopping may cause the ESP32 to lose connection to the home AP (ruv.net o
- ADR-039: Edge processing pipeline
- ADR-060: Channel override provisioning
- ADR-069: Cognitum Seed CSI pipeline
- ADR-074: Spiking neural network for CSI sensing
- IEEE 802.11-2020, Section 21 (OFDM PHY)
- ESP-IDF CSI Guide: https://docs.espressif.com/projects/esp-idf/en/v5.4/esp32s3/api-guides/wifi.html#wi-fi-channel-state-information

View File

@ -0,0 +1,208 @@
# ADR-074: Spiking Neural Network for CSI Sensing
| Field | Value |
|-------------|--------------------------------------------|
| **Status** | Proposed |
| **Date** | 2026-04-02 |
| **Authors** | ruv |
| **Depends** | ADR-018 (binary frame), ADR-029 (channel hopping), ADR-069 (Cognitum Seed), ADR-073 (multi-frequency mesh) |
## Context
The current WiFi-DensePose CSI sensing pipeline uses two approaches for interpreting subcarrier data:
1. **Static thresholds** — presence detection fires when subcarrier variance exceeds a fixed value. This works in calibrated environments but fails when the RF landscape changes (furniture moved, new objects, temperature drift). Recalibration requires manual intervention or batch retraining.
2. **Batch-trained FC encoder** — the neural network in `wifi-densepose-nn` maps CSI frames to 8-dimensional feature vectors. It requires labeled training data, offline training epochs, and model deployment. The encoder cannot adapt to a new environment without collecting new data and retraining.
Neither approach handles online adaptation. When an ESP32 node is deployed in a new room, the first hours produce noisy, unreliable output until the thresholds are tuned or a model is trained. In disaster scenarios (ADR MAT), there is no time for calibration.
**Spiking Neural Networks (SNNs)** offer an alternative. Unlike traditional ANNs that process continuous values in batch mode, SNNs communicate through discrete spike events and learn online via Spike-Timing-Dependent Plasticity (STDP). This is a natural fit for CSI data:
- CSI subcarrier amplitudes are temporal signals sampled at 12-22 fps
- Amplitude changes (not absolute values) carry the information about motion, breathing, and presence
- STDP learns temporal correlations between subcarriers without labels
- Event-driven processing means idle rooms (no motion) consume near-zero compute
The `@ruvector/spiking-neural` package (vendored at `vendor/ruvector/npm/packages/spiking-neural/`) provides production-ready LIF neurons, STDP learning, lateral inhibition, and SIMD-optimized vector math in pure JavaScript with zero dependencies.
## Decision
Integrate `@ruvector/spiking-neural` into the CSI sensing pipeline as an online unsupervised pattern learner that runs alongside the existing FC encoder. The SNN provides real-time adaptation while the FC encoder provides stable baseline predictions.
### Network Architecture
```
CSI Frame (128 subcarriers)
|
v
[ Rate Encoding ] -----> 128 input neurons (one per subcarrier)
| amplitude delta -> spike rate
v
[ LIF Hidden Layer ] ---> 64 hidden neurons (tau=20ms)
| STDP learns subcarrier correlations
| lateral inhibition -> sparse codes
v
[ LIF Output Layer ] ---> 8 output neurons
|
v
presence | motion | breathing | heart_rate | phase_var | persons | fall | rssi
```
**Layer parameters:**
| Layer | Neurons | tau (ms) | v_thresh (mV) | Function |
|-------|---------|----------|---------------|----------|
| Input | 128 | N/A | N/A | Rate-coded spike generation from subcarrier deltas |
| Hidden | 64 | 20.0 | -50.0 | STDP learns correlated subcarrier groups |
| Output | 8 | 25.0 | -50.0 | Each neuron specializes in one sensing modality |
**Synapse parameters:**
| Connection | Count | a_plus | a_minus | w_init | Lateral Inhibition |
|------------|-------|--------|---------|--------|-------------------|
| Input -> Hidden | 8,192 | 0.005 | 0.005 | 0.3 | No |
| Hidden -> Output | 512 | 0.003 | 0.003 | 0.2 | Yes (strength=15.0) |
Total synapses: 8,704. At 4 bytes per weight, this is 34 KB — fits in ESP32 SRAM.
### Input Encoding
CSI amplitudes are converted to spike rates using rate coding:
1. Compute per-subcarrier amplitude: `amp[i] = sqrt(I[i]^2 + Q[i]^2)` from the ADR-018 binary frame
2. Compute amplitude delta from previous frame: `delta[i] = |amp[i] - prev_amp[i]|`
3. Normalize deltas to [0, 1] range: `norm[i] = min(delta[i] / max_delta, 1.0)`
4. Feed `norm` to `rateEncoding(norm, dt, max_rate)` which produces Poisson spikes
Higher amplitude changes produce more spikes. Static subcarriers (no motion) produce few or no spikes. This is the key energy advantage: an empty room generates almost no spikes, so the SNN does almost no work.
### STDP Learning Rule
STDP strengthens connections between neurons that fire together (within a time window) and weakens connections between neurons that fire out of sync:
- **LTP (Long-Term Potentiation)**: if a presynaptic neuron fires before a postsynaptic neuron within 20ms, the weight increases by `a_plus * exp(-dt/tau_stdp)`
- **LTD (Long-Term Depression)**: if a postsynaptic neuron fires before a presynaptic neuron, the weight decreases by `a_minus * exp(-dt/tau_stdp)`
Over time, this causes the hidden layer neurons to specialize. Subcarriers that consistently change together (e.g., subcarriers 10-20 affected by a person walking through zone A) become strongly connected to the same hidden neuron. Different motion patterns activate different hidden neuron clusters.
### Lateral Inhibition (Winner-Take-All)
The output layer uses lateral inhibition with strength 15.0. When one output neuron fires, it suppresses all others. This forces each output neuron to specialize in a distinct pattern:
- Output 0: presence (any subcarrier activity above baseline)
- Output 1: motion (widespread subcarrier changes, high spike rate)
- Output 2: breathing (periodic 0.1-0.5 Hz modulation on chest-area subcarriers)
- Output 3: heart rate (periodic 0.8-2.0 Hz modulation, lower amplitude than breathing)
- Output 4: phase variance (phase instability across subcarriers)
- Output 5: person count (number of distinct active subcarrier clusters)
- Output 6: fall (sudden high-amplitude burst followed by silence)
- Output 7: RSSI trend (overall signal strength change)
The neuron-to-label mapping is not fixed by training. Instead, the mapping is discovered by observing which output neuron fires most for each known condition during an optional calibration phase. If no calibration is available, the output is reported as raw spike counts per output neuron, and downstream consumers (Cognitum Seed, SONA) interpret the patterns.
### Integration with Existing Pipeline
The SNN does not replace the FC encoder. It runs in parallel:
```
CSI Frame ----+----> FC Encoder --------> 8-dim feature vector (stable, trained)
|
+----> SNN (STDP) --------> 8-dim spike rate vector (adaptive, online)
|
+----> SONA Adapter -------> Weighted fusion of both signals
```
SONA (Self-Optimizing Neural Architecture) receives both signals and learns which source is more reliable for each output dimension. In a new environment where the FC encoder has not been retrained, SONA automatically weights the SNN output higher because it adapts faster. As the FC encoder is retrained on local data, SONA shifts weight back toward it.
### Energy and Compute Budget
| Metric | FC Encoder | SNN (STDP) | Ratio |
|--------|-----------|------------|-------|
| Compute per frame (idle room) | 8,192 MACs | ~50 spike events | ~160x less |
| Compute per frame (active room) | 8,192 MACs | ~500 spike events | ~16x less |
| Memory | 34 KB weights | 34 KB weights | Equal |
| Adaptation | Offline retraining | Online, continuous | SNN wins |
| Stability | High (frozen weights) | Lower (weights drift) | FC wins |
| Latency to first useful output | Hours (needs training data) | ~30 seconds | SNN wins |
The SNN's event-driven nature means it processes only spikes, not every subcarrier on every frame. In an idle room with no motion, subcarrier deltas are near zero, spike rates drop to near zero, and the SNN consumes negligible compute. This is ideal for battery-powered or thermally constrained deployments (ESP32, Cognitum Seed Pi Zero).
### Deployment Targets
| Platform | Runtime | Notes |
|----------|---------|-------|
| Node.js server | `require('@ruvector/spiking-neural')` | Primary. Receives UDP frames, runs SNN. |
| Cognitum Seed (Pi Zero) | Node.js ARM | 34 KB model fits. ~0.06ms per step at 100 neurons. |
| ESP32-S3 (WASM) | wasm3 interpreter | Optional. SNN weights exported as flat Float32Array. |
| Browser | WebAssembly or JS | Via `wifi-densepose-wasm` crate's JS bindings. |
### Multi-Channel SNN (ADR-073 Integration)
With multi-frequency mesh scanning (ADR-073), the SNN input expands:
- **Single-channel mode**: 128 input neurons (64 subcarriers x 2 for I/Q or amplitude/phase)
- **Multi-channel mode**: 128 input neurons, but the subcarrier index rotates across channels. Each channel's subcarriers map to the same neuron indices, but at different time slots. The SNN's temporal dynamics naturally integrate cross-channel information because STDP operates across time.
Alternatively, for maximum spectral diversity, a wider SNN (384 input neurons for 6 channels x 64 subcarriers) can be used on the server where memory is not constrained.
## Performance Targets
| Metric | Target | Method |
|--------|--------|--------|
| SNN step latency | <0.1ms | 128-64-8 network, ~8,700 synapses |
| STDP convergence | <30 seconds | ~360 frames at 12 fps, patterns stabilize |
| Output accuracy (after adaptation) | >80% | Compared to manually labeled ground truth |
| Memory footprint | <50 KB | Weights + neuron state |
| Idle room spike rate | <10 spikes/frame | Event-driven: near-zero compute when nothing moves |
| Adaptation to new environment | <2 minutes | STDP relearns subcarrier correlations |
## Risks
### Weight Drift
STDP learning never stops. In a stable environment, weights can slowly drift as the network over-fits to the current RF landscape. Mitigation: implement weight decay (multiply all weights by 0.999 per second) and clamp weights to [w_min, w_max].
### Output Neuron Reassignment
If the RF environment changes significantly (new furniture, different room), output neurons may reassign their specialization. The mapping from output neuron index to label (presence, motion, etc.) may change. Mitigation: periodically log the output neuron activity and detect reassignment events. Downstream consumers should use the spike pattern, not the neuron index, for classification.
### Interference with FC Encoder
If SONA naively averages the SNN and FC encoder outputs, a poorly adapted SNN could degrade overall accuracy. Mitigation: SONA uses confidence-weighted fusion. The SNN output includes a confidence signal (total spike count / expected spike count). Low confidence = low weight.
### STDP Learning Rate Sensitivity
If `a_plus` and `a_minus` are too high, the SNN oscillates and never converges. If too low, adaptation takes too long. The default values (0.005 and 0.003) are conservative. The script includes a `--learning-rate` flag for tuning.
## Alternatives Considered
1. **Online gradient descent on FC encoder** — backprop through the FC network with each new frame. Rejected because: (a) requires a loss function, which requires labels; (b) continuous gradient updates on a small model lead to catastrophic forgetting of the pretrained representations.
2. **Adaptive thresholds only** — replace fixed thresholds with exponentially-weighted moving averages. Rejected because: (a) single-variable thresholds cannot capture multi-subcarrier correlations; (b) no representation learning — each subcarrier is still processed independently.
3. **Reservoir computing (Echo State Network)** — use a fixed random recurrent network as a temporal feature extractor. Partially viable, but: (a) requires a linear readout layer trained with labels; (b) the random reservoir does not adapt to the specific RF environment.
4. **Train SNN with supervision** — use surrogate gradient methods to train the SNN on labeled data. Rejected because: (a) defeats the purpose of online unsupervised learning; (b) the `@ruvector/spiking-neural` package does not implement surrogate gradients.
## Implementation
The integration is implemented in `scripts/snn-csi-processor.js`, a standalone Node.js script that:
1. Receives live CSI frames via UDP (port 5006, ADR-018 binary format)
2. Decodes subcarrier I/Q data and computes amplitude deltas
3. Feeds deltas through rate encoding into the SNN
4. Applies STDP learning on every frame (online, unsupervised)
5. Maps output neuron spike counts to sensing labels
6. Prints real-time ASCII visualization of SNN activity
7. Optionally forwards learned patterns to Cognitum Seed
## References
- ADR-018: CSI binary frame format
- ADR-029: Channel hopping infrastructure
- ADR-069: Cognitum Seed CSI pipeline
- ADR-073: Multi-frequency mesh scanning
- Maass, W. (1997). "Networks of spiking neurons: The third generation of neural network models." Neural Networks, 10(9), 1659-1671.
- Bi, G. & Poo, M. (1998). "Synaptic modifications in cultured hippocampal neurons: Dependence on spike timing." Journal of Neuroscience, 18(24), 10464-10472.
- `@ruvector/spiking-neural` v1.0.1 — LIF, STDP, lateral inhibition, SIMD

View File

@ -0,0 +1,195 @@
# ADR-075: Min-Cut Based Person Separation from Subcarrier Correlation
- **Status:** Proposed
- **Date:** 2026-04-02
- **Issue:** #348`n_persons` always reports 4 regardless of actual occupancy
- **Depends on:** ADR-016 (RuVector integration), ADR-041 (person tracking), ADR-073 (multifrequency mesh scan)
## Context
### The Bug
Issue #348 reports that the ESP32 firmware's multi-person counting always reports
`n_persons = 4`. The root cause is in the WASM edge module
`sig_mincut_person_match.rs`, which uses a fixed `MAX_PERSONS = 4` constant and a
threshold-based variance classifier to populate person slots. The classifier bins
subcarriers into "dynamic" vs "static" using a single fixed variance threshold
(`DYNAMIC_VAR_THRESH = 0.15`). In practice:
1. The threshold is miscalibrated for real-world CSI data — almost any room with
multipath reflections pushes a majority of subcarriers above 0.15 variance.
2. The subcarrier-to-person assignment uses a greedy Hungarian-lite matcher that
fills all 4 slots once there are >= 4 dynamic subcarriers (which is nearly
always the case).
3. There is no mechanism to determine how many independent movers exist — the
algorithm assumes all 4 slots should be filled.
### Prior Art
The Rust crate `ruvector-mincut` (vendored at `vendor/ruvector/crates/ruvector-mincut/`)
implements a full dynamic min-cut algorithm with O(n^{o(1)}) amortized update time,
Stoer-Wagner exact min-cut, and online edge insert/delete. It is already integrated
in the training pipeline (`wifi-densepose-train/src/metrics.rs`) via
`DynamicPersonMatcher`.
### WiFi Sensing Insight
When a person moves through a room, they perturb the Fresnel zones of specific
subcarrier frequencies. Subcarriers whose Fresnel zones overlap the person's body
change **together** — their amplitudes are temporally correlated. When two people
move independently, they create two **separate** groups of correlated subcarriers.
This correlation structure forms a natural graph partitioning problem.
## Decision
Replace the fixed-threshold person counter with a spectral min-cut algorithm
operating on the subcarrier temporal correlation graph. This runs in the bridge
script (`scripts/mincut-person-counter.js`) or on Cognitum Seed, and feeds the
corrected person count back to the feature vector before ingest.
### Algorithm
1. **Sliding window accumulation**: Maintain the last 2 seconds of subcarrier
amplitude data (~40 frames at 20 fps). Each frame provides a 64-element
amplitude vector (one per subcarrier).
2. **Pairwise Pearson correlation**: For all subcarrier pairs (i, j), compute
the Pearson correlation coefficient over the sliding window:
```
r(i,j) = cov(amp_i, amp_j) / (std(amp_i) * std(amp_j))
```
This produces a 64x64 correlation matrix.
3. **Graph construction**: Build a weighted undirected graph:
- **Nodes** = subcarriers (64 for single-antenna ESP32-S3, up to 128 for dual)
- **Edges** = pairs with |r(i,j)| > 0.3 (correlation threshold)
- **Weight** = |r(i,j)| (correlation strength)
- Discard null subcarriers (amplitude consistently near zero)
- Expected: ~1500-2500 edges for 64 active subcarriers
4. **Iterative Stoer-Wagner min-cut**: Apply the Stoer-Wagner algorithm to find
the global minimum cut. If the min-cut weight is below a separation threshold
(empirically 2.0), the cut represents a real boundary between independent
movers. Split the graph at the cut and recurse on each partition.
5. **Person count**: The number of partitions after all valid cuts = number of
independent movers = person count. A single connected component with high
internal correlation and no low-weight cut = 1 person (or 0 if variance is
also low).
6. **Empty room detection**: If the total variance across all subcarriers is
below a noise floor threshold, report 0 persons regardless of graph structure.
### Stoer-Wagner Algorithm
Stoer-Wagner finds the exact global minimum cut of an undirected weighted graph
in O(V * E) time using a sequence of "minimum cut phases":
```
function stoerWagner(G):
best_cut = infinity
while |V(G)| > 1:
(s, t, cut_of_phase) = minimumCutPhase(G)
if cut_of_phase < best_cut:
best_cut = cut_of_phase
best_partition = partition induced by t
merge(s, t) // contract vertices s and t
return best_cut, best_partition
function minimumCutPhase(G):
A = {arbitrary start vertex}
while A != V(G):
z = vertex most tightly connected to A
// "most tightly connected" = max sum of edge weights to A
add z to A
s = second-to-last vertex added
t = last vertex added (most tightly connected)
cut_of_phase = sum of weights of edges incident to t
return (s, t, cut_of_phase)
```
For V=64 subcarriers and E~2000 edges, this runs in ~8 million operations,
well under 1ms on modern hardware and under 10ms even on ESP32-S3.
### Integration Points
```
ESP32 Node 1 ──UDP 5006──┐
├──> mincut-person-counter.js ──> corrected n_persons
ESP32 Node 2 ──UDP 5006──┘ │
├──> seed_csi_bridge.py (feature dim 5 override)
└──> csi-graph-visualizer.js (debug view)
```
The person counter runs as a standalone Node.js process alongside the existing
`rf-scan.js` and `seed_csi_bridge.py` bridge scripts. It can also replay
recorded `.csi.jsonl` files for offline analysis.
## Alternatives Considered
### 1. Threshold-based peak counting (current, broken)
Count subcarriers with variance above a threshold, then cluster by proximity.
**Problem:** threshold is environment-dependent, miscalibrates easily, and
cannot distinguish correlated from independent motion.
### 2. PCA / spectral clustering on correlation matrix
Compute eigenvectors of the correlation matrix; the number of large eigenvalues
indicates the number of independent sources. **Problem:** requires choosing an
eigenvalue gap threshold, which is as fragile as the current variance threshold.
Also does not give per-person subcarrier assignments.
### 3. Min-cut on correlation graph (this ADR)
**Advantages:**
- Directly models the physical structure (Fresnel zone groupings)
- Threshold-free person counting (cut weight is a natural separation metric)
- Produces per-person subcarrier groups as a side effect
- Stoer-Wagner is simple to implement (~100 lines) and runs in polynomial time
- Already validated in Rust via `ruvector-mincut` integration
## Performance
| Metric | Value |
|--------|-------|
| Graph size | V=64, E~2000 |
| Stoer-Wagner complexity | O(V * E) = O(128,000) per cut |
| Iterative cuts (max 4) | O(512,000) total |
| Wall time (Node.js) | < 5 ms per 2-second window |
| Wall time (Rust/WASM) | < 0.5 ms |
| Memory | ~32 KB for correlation matrix + graph |
| Sliding window | 2 seconds = ~40 frames * 64 subcarriers * 8 bytes = 20 KB |
## Consequences
### Positive
- Fixes #348: person count now reflects actual independent movers
- Robust across environments (no per-room threshold calibration)
- Per-person subcarrier groups enable per-person feature extraction
- Graph visualization aids debugging and room mapping
- Algorithm is well-understood (Stoer-Wagner, 1997)
### Negative
- Adds a new process to the sensing pipeline
- 2-second latency for person count changes (sliding window)
- Correlation-based: cannot detect stationary persons (no motion = no signal)
- Assumes independent motion — two people walking in sync may be counted as one
### Migration
1. Deploy `scripts/mincut-person-counter.js` alongside existing bridge
2. Override feature vector dimension 5 (`n_persons`) with corrected count
3. Once validated, port Stoer-Wagner to C for direct ESP32-S3 firmware integration
4. Deprecate the fixed-threshold `PersonMatcher` in `sig_mincut_person_match.rs`
## References
- Stoer, M. & Wagner, F. (1997). "A Simple Min-Cut Algorithm." JACM 44(4).
- `vendor/ruvector/crates/ruvector-mincut/src/algorithm/mod.rs` — DynamicMinCut API
- `rust-port/.../sig_mincut_person_match.rs` — current (broken) WASM edge matcher
- `scripts/rf-scan.js` — CSI packet parsing and subcarrier classification

View File

@ -0,0 +1,259 @@
# ADR-076: CSI Spectrogram Embeddings via CNN + Graph Transformer
| Field | Value |
|-------------|--------------------------------------------|
| **Status** | Proposed |
| **Date** | 2026-04-02 |
| **Authors** | ruv |
| **Depends** | ADR-018 (binary frame), ADR-024 (AETHER contrastive embeddings), ADR-029 (RuvSense), ADR-069 (Cognitum Seed bridge), ADR-073 (multi-frequency mesh scan) |
## Context
The current CSI processing pipeline extracts an 8-dimensional hand-crafted feature vector per frame: mean amplitude, amplitude variance, max amplitude, mean phase, phase variance, bandwidth, spectral centroid, and RSSI. These features are effective for basic presence detection and room fingerprinting but discard the rich spatial-frequency structure present in the raw subcarrier data.
A single CSI frame from an ESP32-S3 contains 64 subcarriers (or 128 in HT40 mode), each with I/Q components. When stacked over time, 20 consecutive frames form a **64x20 subcarrier-by-time matrix** — effectively a grayscale spectrogram image. This matrix encodes:
1. **Frequency-selective fading** — metal objects create persistent null zones at specific subcarrier indices (visible as dark vertical stripes)
2. **Doppler signatures** — human motion produces time-varying amplitude patterns across subcarriers (visible as horizontal wave patterns)
3. **Multipath structure** — room geometry creates characteristic interference patterns unique to each environment
4. **Activity fingerprints** — walking, sitting, breathing, and falling produce distinct 2D texture patterns in the subcarrier-time matrix
These 2D structural patterns are invisible to the 8-dim feature vector, which collapses all subcarrier information into scalar statistics. A CNN embedding can preserve this spatial structure.
### Existing Vendor Libraries
**@ruvector/cnn** (v0.1.0) provides:
- WASM-based CNN feature extraction (~5ms per 224x224 image, ~900KB model)
- Configurable embedding dimension (default 512, we use 128 for compact storage)
- L2-normalized embeddings with cosine similarity search
- Contrastive training via InfoNCE and triplet loss
- SIMD-optimized layer operations (batch norm, global average pooling, ReLU)
- Works in both Node.js and browser environments
**ruvector-graph-transformer** provides:
- Sublinear O(n log n) graph attention via LSH bucketing and PPR sampling
- Proof-gated mutation substrate for verified computations
- Temporal causal attention with Granger causality (relevant for CSI time series)
- Manifold attention on product spaces S^n x H^m x R^k
**@ruvector/graph-wasm** (v2.0.2) provides:
- Neo4j-compatible property graph database in WASM
- Node/edge creation with arbitrary properties and embeddings
- Hyperedge support for multi-node relationships
- Cypher query language
### Current Limitations of 8-dim Features
| Limitation | Impact |
|------------|--------|
| No subcarrier-level information | Cannot distinguish frequency-selective vs broadband fading |
| No temporal pattern encoding | Walking gait (periodic) looks identical to random motion (aperiodic) |
| No 2D structure | Room fingerprint reduced to 8 numbers; two rooms with similar statistics are indistinguishable |
| No cross-subcarrier correlation | Cannot detect standing waves, node patterns, or multipath clusters |
| Poor kNN discrimination | 8 dimensions provides limited hypersphere surface area for separating environments |
## Decision
Treat the CSI subcarrier-by-time matrix as a grayscale spectrogram image and apply CNN embedding to produce a 128-dimensional representation that preserves 2D spatial-frequency structure. Use a graph transformer to fuse embeddings across multiple ESP32 nodes.
### Architecture
```
ESP32 Node 1 ESP32 Node 2
| |
v v
UDP 5006 UDP 5006
| |
v v
[64 subcarriers] [64 subcarriers]
[20-frame window] [20-frame window]
| |
v v
64x20 amplitude 64x20 amplitude
matrix (grayscale) matrix (grayscale)
| |
v v
@ruvector/cnn @ruvector/cnn
CnnEmbedder CnnEmbedder
| |
v v
128-dim vector 128-dim vector
| |
+-------+ +----------+
| |
v v
Graph Transformer (2-node graph)
Edge weight = cross-node correlation
|
v
Fused 128-dim vector
|
+-------+-------+
| |
v v
Cognitum Seed kNN Search
(128-dim store) (similar rooms)
```
### Step 1: CSI-to-Spectrogram Conversion
Each ESP32 transmits CSI frames via UDP in ADR-018 binary format. The `iq_hex` field contains I/Q pairs for each subcarrier (2 bytes per subcarrier: I + Q as unsigned 8-bit values).
```
Amplitude[sc] = sqrt(I[sc]^2 + Q[sc]^2)
```
A sliding window of 20 frames produces a 64x20 matrix. Normalization to 0-255 grayscale:
```
pixel[sc][t] = clamp(255 * (amplitude[sc][t] - min) / (max - min), 0, 255)
```
Where `min` and `max` are computed over the entire 64x20 window for per-window contrast normalization. This ensures the CNN sees the relative structure regardless of absolute signal strength (which varies with distance, TX power, and environmental absorption).
### Step 2: CNN Embedding
The 64x20 grayscale matrix is resized to the CNN's expected input size (224x224 via nearest-neighbor upsampling, since we want to preserve the discrete subcarrier structure rather than blur it with bilinear interpolation). The input is replicated across 3 channels (RGB) since @ruvector/cnn expects RGB input.
Configuration:
- **Input**: 224x224x3 (upsampled from 64x20, grayscale replicated to RGB)
- **Embedding dimension**: 128 (reduced from default 512 for compact storage and faster kNN)
- **Normalization**: L2-enabled (cosine similarity = dot product on unit sphere)
- **Latency**: ~5ms per window on modern hardware
The 128-dim embedding encodes the 2D structure of the spectrogram: null zones, Doppler patterns, multipath signatures, and activity textures.
### Step 3: Graph Transformer for Multi-Node Fusion
With 2 ESP32 nodes (generalizable to N), we construct a graph:
```
Nodes: {Node_1, Node_2}
Edges: {(Node_1, Node_2, weight=cross_correlation)}
Node features: 128-dim CNN embedding per node
```
The graph attention mechanism learns which node is more informative for each prediction:
1. **Query/Key/Value** from each node's 128-dim embedding
2. **Edge weight** = Pearson cross-correlation between the two nodes' raw amplitude vectors (captures how much their CSI observations agree)
3. **Attention score** = softmax(Q_i * K_j / sqrt(d) + edge_weight_bias)
4. **Output** = weighted sum of value vectors
This produces a fused 128-dim vector that combines both nodes' perspectives, automatically weighting the node with cleaner signal (higher SNR, less fading) more heavily.
**Generalization to 3+ nodes**: Adding a third ESP32 adds one node and 2 edges to the graph. The attention mechanism handles variable-size graphs without architecture changes.
### Step 4: Storage and Search
The fused 128-dim embedding is stored in Cognitum Seed (ADR-069) alongside the existing 8-dim features:
| Store | Dimension | Content | Use Case |
|-------|-----------|---------|----------|
| `csi-features` | 8-dim | Hand-crafted statistics | Fast presence detection |
| `csi-spectrograms` | 128-dim | CNN spectrogram embedding | Environment fingerprinting, anomaly detection |
| `csi-spectrograms-fused` | 128-dim | Graph-fused multi-node embedding | Cross-viewpoint room signature |
kNN search on the 128-dim store finds past spectrograms that "look like" the current one:
- **Environment fingerprinting**: "What room does this RF pattern match?"
- **Cross-room transfer**: "Which training room is most similar to this deployment room?"
- **Anomaly detection**: Low similarity to all known patterns = unknown environment or novel activity
- **Temporal segmentation**: Similarity drops = activity transition boundaries
### Comparison: 8-dim vs 128-dim vs Combined
| Property | 8-dim hand-crafted | 128-dim CNN | Combined |
|----------|-------------------|-------------|----------|
| Subcarrier structure | Lost | Preserved | Both available |
| Temporal patterns | Lost | Preserved (20-frame window) | Both |
| Computation | ~0.1ms | ~5ms | ~5ms |
| Storage per vector | 32 bytes | 512 bytes | 544 bytes |
| kNN discrimination | Low (8-dim curse) | High (128-dim surface) | Highest |
| Interpretability | High (named features) | Low (learned) | Mixed |
| Training required | No | Optional (pre-trained works) | Optional |
| Multi-node fusion | Average/max | Graph attention | Graph attention |
### Contrastive Training (Optional Enhancement)
The CNN embedding works out-of-the-box with the pre-trained weights. For domain-specific improvements, contrastive training with CSI data:
1. **Positive pairs**: Same room, different time windows (should embed similarly)
2. **Negative pairs**: Different rooms or different activities (should embed differently)
3. **Loss**: InfoNCE with temperature 0.07 (standard SimCLR)
4. **Augmentation**: Time-shift (slide window by 1-5 frames), subcarrier dropout (zero 10% of rows), amplitude jitter (multiply by uniform [0.8, 1.2])
This teaches the CNN that "same room at different times" should produce similar embeddings, while "different rooms" should produce different embeddings.
## Consequences
### Positive
1. **Richer representation**: 128 dimensions capture 2D structure that 8 dimensions cannot
2. **Environment fingerprinting**: kNN on spectrograms can distinguish rooms that look identical in 8-dim feature space
3. **Activity detection**: Temporal patterns (gait periodicity, breathing frequency) are encoded in the spectrogram texture
4. **Multi-node fusion**: Graph attention automatically weights the most informative node, improving robustness to single-node occlusion or interference
5. **Incremental adoption**: 128-dim store operates alongside 8-dim store; no migration needed
6. **Browser-compatible**: WASM-based CNN runs in the sensing-server UI for live visualization
### Negative
1. **5ms latency per window**: Acceptable for 1.3 Hz update rate (750ms rotation from ADR-073), but constrains real-time applications
2. **900KB model download**: One-time cost, cached after first load
3. **128-dim storage**: 16x more bytes per vector than 8-dim; mitigated by the fact that we store one embedding per 20-frame window (not per frame)
4. **Opaque embeddings**: Unlike named 8-dim features, CNN embeddings are not human-interpretable
5. **Input size mismatch**: 64x20 matrix must be upsampled to 224x224; nearest-neighbor preserves structure but wastes computation on padded regions
### Risks and Mitigations
| Risk | Mitigation |
|------|------------|
| CNN embeddings not discriminative enough for CSI | Contrastive fine-tuning on CSI spectrograms; fall back to 8-dim if 128-dim kNN recall is worse |
| Graph transformer overhead for 2-node graph | Lightweight attention (single head, no MLP); O(1) for 2 nodes |
| Upsampling artifacts from 64x20 to 224x224 | Nearest-neighbor preserves discrete structure; consider training a smaller CNN on native 64x20 input |
| WASM initialization delay | Call `init()` at server startup, not per-request |
## Implementation
### Files
| File | Purpose |
|------|---------|
| `scripts/csi-spectrogram.js` | CSI-to-spectrogram pipeline with CNN embedding, ASCII visualization, Cognitum Seed ingest |
| `scripts/mesh-graph-transformer.js` | Multi-node graph attention fusion using @ruvector/graph-wasm |
| `docs/adr/ADR-076-csi-spectrogram-embeddings.md` | This ADR |
### Dependencies
| Package | Version | Source |
|---------|---------|--------|
| `@ruvector/cnn` | 0.1.0 | `vendor/ruvector/npm/packages/ruvector-cnn/` |
| `@ruvector/graph-wasm` | 2.0.2 | `vendor/ruvector/npm/packages/graph-wasm/` |
### Data Format
CSI JSONL frames from `data/recordings/pretrain-1775182186.csi.jsonl`:
```json
{
"timestamp": 1775182186.123,
"node_id": 1,
"magic": 3289481217,
"size": 148,
"rssi": -45,
"type": "CSI",
"iq_hex": "00000f030d030e040d030d030d030c020d020d01...",
"subcarriers": 64
}
```
`iq_hex` encoding: 2 hex characters per byte, 4 hex characters per subcarrier (I byte + Q byte). Total length = `subcarriers * 4` hex characters.
## References
- ADR-018: Binary CSI frame format
- ADR-024: AETHER contrastive CSI embeddings (Rust-side)
- ADR-029: RuvSense multistatic sensing mode
- ADR-069: Cognitum Seed RVF ingest bridge
- ADR-073: Multi-frequency mesh scanning
- SimCLR: Chen et al., "A Simple Framework for Contrastive Learning of Visual Representations" (2020)
- GATv2: Brody et al., "How Attentive are Graph Attention Networks?" (2021)

View File

@ -0,0 +1,674 @@
#!/usr/bin/env node
/**
* ADR-075: CSI Subcarrier Correlation Graph Visualizer
*
* ASCII visualization of the subcarrier correlation graph used by the
* min-cut person counter. Shows per-person subcarrier clusters, graph
* connectivity, and correlation heatmap in real-time.
*
* Usage:
* # Live from ESP32 nodes via UDP
* node scripts/csi-graph-visualizer.js --port 5006
*
* # Replay from recorded CSI data
* node scripts/csi-graph-visualizer.js --replay data/recordings/pretrain-1775182186.csi.jsonl
*
* # Show correlation heatmap only
* node scripts/csi-graph-visualizer.js --replay FILE --mode heatmap
*
* ADR: docs/adr/ADR-075-mincut-person-separation.md
*/
'use strict';
const dgram = require('dgram');
const fs = require('fs');
const readline = require('readline');
const { parseArgs } = require('util');
// ---------------------------------------------------------------------------
// CLI
// ---------------------------------------------------------------------------
const { values: args } = parseArgs({
options: {
port: { type: 'string', short: 'p', default: '5006' },
replay: { type: 'string', short: 'r' },
interval: { type: 'string', short: 'i', default: '2000' },
window: { type: 'string', short: 'w', default: '2000' },
mode: { type: 'string', short: 'm', default: 'all' },
node: { type: 'string', short: 'n', default: '0' },
'corr-threshold': { type: 'string', default: '0.3' },
'cut-threshold': { type: 'string', default: '2.0' },
'var-floor': { type: 'string', default: '0.5' },
width: { type: 'string', default: '80' },
},
strict: true,
});
const PORT = parseInt(args.port, 10);
const INTERVAL_MS = parseInt(args.interval, 10);
const WINDOW_MS = parseInt(args.window, 10);
const CORR_THRESHOLD = parseFloat(args['corr-threshold']);
const CUT_THRESHOLD = parseFloat(args['cut-threshold']);
const VAR_FLOOR = parseFloat(args['var-floor']);
const MODE = args.mode; // 'all', 'heatmap', 'clusters', 'spectrum'
const TARGET_NODE = parseInt(args.node, 10);
const WIDTH = parseInt(args.width, 10);
const CSI_MAGIC = 0xC5110001;
const HEADER_SIZE = 20;
// Color palette for person clusters (ANSI 256)
const PERSON_COLORS = [
'\x1b[31m', // red
'\x1b[32m', // green
'\x1b[34m', // blue
'\x1b[33m', // yellow
'\x1b[35m', // magenta
'\x1b[36m', // cyan
'\x1b[91m', // bright red
'\x1b[92m', // bright green
];
const RESET = '\x1b[0m';
const DIM = '\x1b[2m';
const BOLD = '\x1b[1m';
// Heatmap characters (11 levels of intensity)
const HEAT = [' ', '\u2591', '\u2591', '\u2592', '\u2592', '\u2593', '\u2593', '\u2588', '\u2588', '\u2588', '\u2588'];
// Bar chart characters
const BARS = ['\u2581', '\u2582', '\u2583', '\u2584', '\u2585', '\u2586', '\u2587', '\u2588'];
// ---------------------------------------------------------------------------
// Sliding window (same as mincut-person-counter.js)
// ---------------------------------------------------------------------------
class SubcarrierWindow {
constructor(maxAgeMs) {
this.maxAgeMs = maxAgeMs;
this.frames = [];
this.nSubcarriers = 0;
}
push(timestamp, amplitudes) {
this.nSubcarriers = amplitudes.length;
this.frames.push({ timestamp, amplitudes: Float64Array.from(amplitudes) });
const cutoff = timestamp - this.maxAgeMs;
while (this.frames.length > 0 && this.frames[0].timestamp < cutoff) {
this.frames.shift();
}
}
get length() { return this.frames.length; }
correlationMatrix() {
const nFrames = this.frames.length;
const nSc = this.nSubcarriers;
if (nFrames < 5 || nSc === 0) return null;
const mean = new Float64Array(nSc);
const std = new Float64Array(nSc);
for (let f = 0; f < nFrames; f++) {
const amp = this.frames[f].amplitudes;
for (let i = 0; i < nSc; i++) mean[i] += amp[i];
}
for (let i = 0; i < nSc; i++) mean[i] /= nFrames;
for (let f = 0; f < nFrames; f++) {
const amp = this.frames[f].amplitudes;
for (let i = 0; i < nSc; i++) {
const d = amp[i] - mean[i];
std[i] += d * d;
}
}
for (let i = 0; i < nSc; i++) std[i] = Math.sqrt(std[i] / (nFrames - 1));
const activeIndices = [];
for (let i = 0; i < nSc; i++) {
if (std[i] > VAR_FLOOR) activeIndices.push(i);
}
const n = activeIndices.length;
if (n < 2) return { matrix: null, n: 0, activeIndices, mean, std };
const matrix = new Float64Array(n * n);
for (let ai = 0; ai < n; ai++) {
matrix[ai * n + ai] = 1.0;
const si = activeIndices[ai];
for (let aj = ai + 1; aj < n; aj++) {
const sj = activeIndices[aj];
let cov = 0;
for (let f = 0; f < nFrames; f++) {
const amp = this.frames[f].amplitudes;
cov += (amp[si] - mean[si]) * (amp[sj] - mean[sj]);
}
cov /= (nFrames - 1);
const denom = std[si] * std[sj];
const r = denom > 1e-10 ? cov / denom : 0;
matrix[ai * n + aj] = r;
matrix[aj * n + ai] = r;
}
}
return { matrix, n, activeIndices, mean, std };
}
/** Get latest amplitudes */
latestAmplitudes() {
if (this.frames.length === 0) return null;
return this.frames[this.frames.length - 1].amplitudes;
}
}
// ---------------------------------------------------------------------------
// Graph + Stoer-Wagner (minimal copy from mincut-person-counter.js)
// ---------------------------------------------------------------------------
class WeightedGraph {
constructor(n) {
this.n = n;
this.adj = new Array(n);
for (let i = 0; i < n; i++) this.adj[i] = new Map();
this.edgeCount = 0;
}
addEdge(u, v, w) {
if (u === v) return;
if (!this.adj[u].has(v)) this.edgeCount++;
this.adj[u].set(v, w);
this.adj[v].set(u, w);
}
static fromCorrelation(matrix, n, threshold) {
const g = new WeightedGraph(n);
for (let i = 0; i < n; i++) {
for (let j = i + 1; j < n; j++) {
const r = Math.abs(matrix[i * n + j]);
if (r > threshold) g.addEdge(i, j, r);
}
}
return g;
}
connectedComponents() {
const visited = new Uint8Array(this.n);
const components = [];
for (let start = 0; start < this.n; start++) {
if (visited[start]) continue;
const comp = [];
const queue = [start];
visited[start] = 1;
while (queue.length > 0) {
const u = queue.shift();
comp.push(u);
for (const [v] of this.adj[u]) {
if (!visited[v]) { visited[v] = 1; queue.push(v); }
}
}
components.push(comp);
}
return components;
}
subgraph(vertices) {
const newIdx = new Map();
vertices.forEach((v, i) => newIdx.set(v, i));
const sub = new WeightedGraph(vertices.length);
for (const u of vertices) {
for (const [v, w] of this.adj[u]) {
if (newIdx.has(v) && u < v) sub.addEdge(newIdx.get(u), newIdx.get(v), w);
}
}
return { graph: sub, mapping: vertices };
}
}
function stoerWagner(graph) {
const n = graph.n;
if (n <= 1) return { minCutValue: Infinity, partition: [Array.from({length: n}, (_, i) => i), []] };
const adj = new Array(n);
for (let i = 0; i < n; i++) adj[i] = new Map(graph.adj[i]);
const groups = new Array(n);
for (let i = 0; i < n; i++) groups[i] = [i];
let activeVertices = Array.from({length: n}, (_, i) => i);
let bestCut = Infinity;
let bestPartitionSide = null;
while (activeVertices.length > 1) {
const key = new Float64Array(n);
const inA = new Uint8Array(n);
let s = -1, t = -1;
for (let iter = 0; iter < activeVertices.length; iter++) {
let best = -1, bestKey = -Infinity;
for (const v of activeVertices) {
if (!inA[v] && key[v] > bestKey) { bestKey = key[v]; best = v; }
}
if (best === -1) {
for (const v of activeVertices) { if (!inA[v]) { best = v; break; } }
}
s = t; t = best; inA[best] = 1;
if (adj[best]) {
for (const [nb, w] of adj[best]) {
if (activeVertices.includes(nb) && !inA[nb]) key[nb] += w;
}
}
}
let cutOfPhase = 0;
if (adj[t]) {
for (const [nb, w] of adj[t]) {
if (activeVertices.includes(nb) && nb !== t) cutOfPhase += w;
}
}
if (s === -1 || t === -1) break;
if (cutOfPhase < bestCut) { bestCut = cutOfPhase; bestPartitionSide = [...groups[t]]; }
if (adj[t]) {
for (const [nb, w] of adj[t]) {
if (nb === s) continue;
const ex = adj[s].get(nb) || 0;
adj[s].set(nb, ex + w);
adj[nb].delete(t);
adj[nb].set(s, ex + w);
}
}
adj[s].delete(t);
groups[s] = groups[s].concat(groups[t]);
groups[t] = [];
activeVertices = activeVertices.filter(v => v !== t);
}
if (!bestPartitionSide || bestPartitionSide.length === 0) {
return { minCutValue: Infinity, partition: [Array.from({length: n}, (_, i) => i), []] };
}
const sideSet = new Set(bestPartitionSide);
const sideA = [], sideB = [];
for (let i = 0; i < n; i++) { (sideSet.has(i) ? sideA : sideB).push(i); }
return { minCutValue: bestCut, partition: [sideA, sideB] };
}
function separatePersons(graph, cutThreshold, maxPersons) {
const components = graph.connectedComponents();
const personGroups = [];
for (const comp of components) {
if (comp.length < 2) continue;
_split(graph, comp, cutThreshold, maxPersons, personGroups);
}
return personGroups;
}
function _split(graph, vertices, cutThreshold, maxPersons, result) {
if (vertices.length < 2 || result.length >= maxPersons) {
if (vertices.length >= 2) result.push(vertices);
return;
}
const { graph: sub, mapping } = graph.subgraph(vertices);
const { minCutValue, partition } = stoerWagner(sub);
if (minCutValue >= cutThreshold || partition[0].length === 0 || partition[1].length === 0) {
result.push(vertices);
return;
}
_split(graph, partition[0].map(i => mapping[i]), cutThreshold, maxPersons, result);
_split(graph, partition[1].map(i => mapping[i]), cutThreshold, maxPersons, result);
}
// ---------------------------------------------------------------------------
// Visualization renderers
// ---------------------------------------------------------------------------
/**
* Render correlation heatmap (downsampled to fit terminal width).
* Rows and columns = active subcarrier indices.
*/
function renderHeatmap(corr, width) {
if (!corr || !corr.matrix) return [' (insufficient data for heatmap)'];
const { matrix, n, activeIndices } = corr;
const lines = [];
lines.push(`${BOLD}Correlation Heatmap${RESET} (${n} active subcarriers, threshold=${CORR_THRESHOLD})`);
// Downsample if needed
const maxCols = Math.min(n, width - 8);
const step = Math.max(1, Math.ceil(n / maxCols));
const displayN = Math.ceil(n / step);
// Header row: subcarrier indices
let header = ' ';
for (let j = 0; j < displayN; j++) {
const sc = activeIndices[j * step];
header += (sc < 10 ? `${sc} ` : `${sc}`).slice(0, 2);
}
lines.push(DIM + header + RESET);
for (let i = 0; i < displayN; i++) {
const sc = activeIndices[i * step];
let row = ` ${String(sc).padStart(3)} `;
for (let j = 0; j < displayN; j++) {
const ii = i * step, jj = j * step;
const val = Math.abs(matrix[ii * n + jj]);
const level = Math.min(10, Math.floor(val * 10));
if (val > CORR_THRESHOLD) {
row += `\x1b[33m${HEAT[level]}${RESET} `;
} else {
row += `${DIM}${HEAT[level]}${RESET} `;
}
}
lines.push(row);
}
return lines;
}
/**
* Render subcarrier spectrum bar with person cluster coloring.
*/
function renderSpectrum(window, personGroups, activeIndices) {
const amp = window.latestAmplitudes();
if (!amp) return [' (no data)'];
const lines = [];
const nSc = window.nSubcarriers;
// Build subcarrier-to-person mapping
const scToPerson = new Int8Array(nSc).fill(-1);
if (personGroups && activeIndices) {
for (let p = 0; p < personGroups.length; p++) {
for (const graphIdx of personGroups[p]) {
if (graphIdx < activeIndices.length) {
scToPerson[activeIndices[graphIdx]] = p;
}
}
}
}
// Find max amplitude for normalization
let maxAmp = 0;
for (let i = 0; i < nSc; i++) {
if (amp[i] > maxAmp) maxAmp = amp[i];
}
if (maxAmp === 0) maxAmp = 1;
lines.push(`${BOLD}Spectrum${RESET} (${nSc} subcarriers, colored by person cluster)`);
// Render bar
let bar = ' ';
for (let i = 0; i < nSc; i++) {
const level = Math.floor((amp[i] / maxAmp) * 7.99);
const ch = BARS[Math.max(0, Math.min(7, level))];
const personIdx = scToPerson[i];
if (personIdx >= 0 && personIdx < PERSON_COLORS.length) {
bar += PERSON_COLORS[personIdx] + ch + RESET;
} else {
bar += DIM + ch + RESET;
}
}
lines.push(bar);
// Legend
let legend = ' ';
for (let i = 0; i < nSc; i++) {
const p = scToPerson[i];
if (p >= 0 && p < PERSON_COLORS.length) {
legend += PERSON_COLORS[p] + (p + 1) + RESET;
} else {
legend += DIM + '.' + RESET;
}
}
lines.push(legend);
return lines;
}
/**
* Render cluster summary with per-person statistics.
*/
function renderClusters(personGroups, activeIndices, corr) {
if (!personGroups || personGroups.length === 0) {
return [' No person clusters detected'];
}
const lines = [];
lines.push(`${BOLD}Person Clusters${RESET} (${personGroups.length} detected)`);
for (let p = 0; p < personGroups.length; p++) {
const group = personGroups[p];
const color = p < PERSON_COLORS.length ? PERSON_COLORS[p] : '';
// Map back to subcarrier indices
const scIds = group.map(i => activeIndices[i]);
const scStr = scIds.length <= 16
? scIds.join(', ')
: scIds.slice(0, 14).join(', ') + `, ...+${scIds.length - 14}`;
// Compute intra-cluster average correlation
let avgCorr = 0, count = 0;
if (corr && corr.matrix) {
for (let i = 0; i < group.length; i++) {
for (let j = i + 1; j < group.length; j++) {
avgCorr += Math.abs(corr.matrix[group[i] * corr.n + group[j]]);
count++;
}
}
if (count > 0) avgCorr /= count;
}
lines.push(` ${color}Person ${p + 1}${RESET}: ${group.length} subcarriers, avg intra-corr=${avgCorr.toFixed(3)}`);
lines.push(` ${DIM}SC: [${scStr}]${RESET}`);
}
return lines;
}
/**
* Render graph connectivity summary.
*/
function renderGraphStats(graph, corr) {
if (!graph) return [' (no graph)'];
const lines = [];
const components = graph.connectedComponents();
const density = graph.n > 1 ? (2 * graph.edgeCount) / (graph.n * (graph.n - 1)) : 0;
lines.push(`${BOLD}Graph${RESET}: ${graph.n} nodes, ${graph.edgeCount} edges, density=${density.toFixed(3)}, components=${components.length}`);
// Degree distribution summary
const degrees = new Array(graph.n);
let minDeg = Infinity, maxDeg = 0, sumDeg = 0;
for (let i = 0; i < graph.n; i++) {
degrees[i] = graph.adj[i].size;
if (degrees[i] < minDeg) minDeg = degrees[i];
if (degrees[i] > maxDeg) maxDeg = degrees[i];
sumDeg += degrees[i];
}
const avgDeg = graph.n > 0 ? sumDeg / graph.n : 0;
lines.push(` Degree: min=${minDeg} max=${maxDeg} avg=${avgDeg.toFixed(1)}`);
return lines;
}
// ---------------------------------------------------------------------------
// Full render
// ---------------------------------------------------------------------------
function render(window, nodeId) {
const corr = window.correlationMatrix();
const lines = [];
const ts = new Date().toISOString().slice(11, 19);
lines.push(`${BOLD}ADR-075 CSI Graph Visualizer${RESET} [${ts}] Node ${nodeId} | ${window.length} frames`);
lines.push('═'.repeat(WIDTH));
let graph = null;
let personGroups = null;
let activeIndices = corr ? corr.activeIndices : [];
if (corr && corr.matrix && corr.n >= 2) {
graph = WeightedGraph.fromCorrelation(corr.matrix, corr.n, CORR_THRESHOLD);
personGroups = separatePersons(graph, CUT_THRESHOLD, 8);
}
const personCount = personGroups ? personGroups.length : 0;
lines.push(`${BOLD}Persons: ${personCount}${RESET} | Active subcarriers: ${activeIndices.length}/${window.nSubcarriers}`);
lines.push('');
if (MODE === 'all' || MODE === 'spectrum') {
lines.push(...renderSpectrum(window, personGroups, activeIndices));
lines.push('');
}
if (MODE === 'all' || MODE === 'clusters') {
lines.push(...renderClusters(personGroups, activeIndices, corr));
lines.push('');
}
if (MODE === 'all' || MODE === 'heatmap') {
lines.push(...renderHeatmap(corr, WIDTH));
lines.push('');
}
if (graph) {
lines.push(...renderGraphStats(graph, corr));
}
lines.push('═'.repeat(WIDTH));
lines.push(`${DIM}Thresholds: corr=${CORR_THRESHOLD} cut=${CUT_THRESHOLD} var-floor=${VAR_FLOOR}${RESET}`);
return lines.join('\n');
}
// ---------------------------------------------------------------------------
// Packet parsing
// ---------------------------------------------------------------------------
function parseIqHex(iqHex, nSubcarriers) {
const bytes = Buffer.from(iqHex, 'hex');
const amplitudes = new Float64Array(nSubcarriers);
for (let sc = 0; sc < nSubcarriers; sc++) {
const offset = 2 + sc * 2;
if (offset + 1 >= bytes.length) break;
let I = bytes[offset]; let Q = bytes[offset + 1];
if (I > 127) I -= 256;
if (Q > 127) Q -= 256;
amplitudes[sc] = Math.sqrt(I * I + Q * Q);
}
return amplitudes;
}
function parseUdpPacket(buf) {
if (buf.length < HEADER_SIZE) return null;
const magic = buf.readUInt32LE(0);
if (magic !== CSI_MAGIC) return null;
const nodeId = buf.readUInt8(4);
const nSubcarriers = buf.readUInt16LE(6);
const amplitudes = new Float64Array(nSubcarriers);
for (let sc = 0; sc < nSubcarriers; sc++) {
const offset = HEADER_SIZE + sc * 2;
if (offset + 1 >= buf.length) break;
const I = buf.readInt8(offset);
const Q = buf.readInt8(offset + 1);
amplitudes[sc] = Math.sqrt(I * I + Q * Q);
}
return { nodeId, nSubcarriers, amplitudes, timestamp: Date.now() };
}
// ---------------------------------------------------------------------------
// Main: live mode
// ---------------------------------------------------------------------------
function startLive() {
const windows = new Map();
const server = dgram.createSocket('udp4');
server.on('message', (buf) => {
const frame = parseUdpPacket(buf);
if (!frame) return;
if (!windows.has(frame.nodeId)) {
windows.set(frame.nodeId, new SubcarrierWindow(WINDOW_MS));
}
windows.get(frame.nodeId).push(frame.timestamp, frame.amplitudes);
});
setInterval(() => {
process.stdout.write('\x1b[2J\x1b[H');
for (const [nodeId, window] of windows) {
if (TARGET_NODE !== 0 && nodeId !== TARGET_NODE) continue;
console.log(render(window, nodeId));
console.log();
}
if (windows.size === 0) {
console.log('Waiting for CSI frames on UDP port ' + PORT + '...');
}
}, INTERVAL_MS);
server.bind(PORT, () => {
console.log(`CSI Graph Visualizer listening on UDP port ${PORT}`);
});
}
// ---------------------------------------------------------------------------
// Main: replay mode
// ---------------------------------------------------------------------------
async function startReplay(filePath) {
if (!fs.existsSync(filePath)) {
console.error(`File not found: ${filePath}`);
process.exit(1);
}
const windows = new Map();
const rl = readline.createInterface({
input: fs.createReadStream(filePath),
crlfDelay: Infinity,
});
let lastRenderTs = 0;
let frameCount = 0;
for await (const line of rl) {
if (!line.trim()) continue;
let record;
try { record = JSON.parse(line); } catch { continue; }
if (record.type !== 'raw_csi' || !record.iq_hex) continue;
const nSc = record.subcarriers || 64;
const amplitudes = parseIqHex(record.iq_hex, nSc);
const nodeId = record.node_id;
const tsMs = record.timestamp * 1000;
if (!windows.has(nodeId)) {
windows.set(nodeId, new SubcarrierWindow(WINDOW_MS));
}
windows.get(nodeId).push(tsMs, amplitudes);
frameCount++;
if (lastRenderTs === 0) lastRenderTs = tsMs;
if (tsMs - lastRenderTs >= INTERVAL_MS) {
process.stdout.write('\x1b[2J\x1b[H');
for (const [nid, window] of windows) {
if (TARGET_NODE !== 0 && nid !== TARGET_NODE) continue;
console.log(render(window, nid));
console.log();
}
lastRenderTs = tsMs;
// Small delay for visual effect during replay
await new Promise(r => setTimeout(r, 100));
}
}
// Final render
console.log();
console.log('═'.repeat(WIDTH));
console.log(`${BOLD}Replay complete${RESET}: ${frameCount} frames`);
for (const [nodeId, window] of windows) {
if (TARGET_NODE !== 0 && nodeId !== TARGET_NODE) continue;
console.log();
console.log(render(window, nodeId));
}
}
// ---------------------------------------------------------------------------
// Entry point
// ---------------------------------------------------------------------------
if (args.replay) {
startReplay(args.replay);
} else {
startLive();
}

672
scripts/csi-spectrogram.js Normal file
View File

@ -0,0 +1,672 @@
#!/usr/bin/env node
/**
* ADR-076: CSI Spectrogram Embedding Pipeline
*
* Converts raw CSI frames into 128-dim CNN embeddings by treating the
* subcarrier x time matrix as a grayscale spectrogram image.
*
* Modes:
* --live Listen on UDP for real-time CSI frames
* --file FILE Read from a .csi.jsonl recording
* --ascii Print ASCII spectrogram visualization
* --ingest Send 128-dim embeddings to Cognitum Seed
* --knn K Find K most similar past spectrograms
*
* Usage:
* node scripts/csi-spectrogram.js --file data/recordings/pretrain-1775182186.csi.jsonl --ascii
* node scripts/csi-spectrogram.js --live --port 5006 --ingest --seed-url https://169.254.42.1:8443
* node scripts/csi-spectrogram.js --file data/recordings/pretrain-1775182186.csi.jsonl --knn 5
*
* ADR: docs/adr/ADR-076-csi-spectrogram-embeddings.md
*/
'use strict';
const dgram = require('dgram');
const fs = require('fs');
const path = require('path');
const readline = require('readline');
const { parseArgs } = require('util');
// ---------------------------------------------------------------------------
// CLI
// ---------------------------------------------------------------------------
const { values: args } = parseArgs({
options: {
file: { type: 'string', short: 'f' },
live: { type: 'boolean', default: false },
port: { type: 'string', short: 'p', default: '5006' },
ascii: { type: 'boolean', default: false },
ingest: { type: 'boolean', default: false },
knn: { type: 'string', short: 'k' },
'seed-url': { type: 'string', default: 'https://169.254.42.1:8443' },
'seed-token': { type: 'string', default: '' },
window: { type: 'string', short: 'w', default: '20' },
stride: { type: 'string', short: 's', default: '10' },
dim: { type: 'string', short: 'd', default: '128' },
json: { type: 'boolean', default: false },
limit: { type: 'string', short: 'l' },
},
strict: true,
});
const WINDOW_SIZE = parseInt(args.window, 10); // frames per spectrogram
const STRIDE = parseInt(args.stride, 10); // frames between windows
const EMBED_DIM = parseInt(args.dim, 10); // CNN output dimension
const KNN_K = args.knn ? parseInt(args.knn, 10) : 0;
const LIMIT = args.limit ? parseInt(args.limit, 10) : Infinity;
const PORT = parseInt(args.port, 10);
const JSON_OUTPUT = args.json;
// ADR-018 packet constants
const CSI_MAGIC = 0xC5110001;
const HEADER_SIZE = 20;
// CNN input size (ruvector/cnn expects 224x224 RGB)
const CNN_INPUT_SIZE = 224;
// ASCII visualization characters (8 intensity levels)
const BARS = [' ', '\u2581', '\u2582', '\u2583', '\u2584', '\u2585', '\u2586', '\u2587', '\u2588'];
// ---------------------------------------------------------------------------
// IQ Hex Parsing
// ---------------------------------------------------------------------------
/**
* Parse iq_hex string into subcarrier amplitudes.
* Format: 4 hex chars per subcarrier (I byte + Q byte).
* @param {string} iqHex - Hex-encoded I/Q data
* @param {number} nSubcarriers - Expected number of subcarriers
* @returns {Float32Array} Amplitude per subcarrier
*/
function parseIqHex(iqHex, nSubcarriers) {
const amps = new Float32Array(nSubcarriers);
for (let sc = 0; sc < nSubcarriers; sc++) {
const offset = sc * 4;
if (offset + 4 > iqHex.length) break;
const iVal = parseInt(iqHex.substring(offset, offset + 2), 16);
const qVal = parseInt(iqHex.substring(offset + 2, offset + 4), 16);
amps[sc] = Math.sqrt(iVal * iVal + qVal * qVal);
}
return amps;
}
/**
* Parse an ADR-018 binary UDP packet into subcarrier amplitudes.
* @param {Buffer} buf - Raw UDP packet
* @returns {{ nodeId: number, rssi: number, nSubcarriers: number, amplitudes: Float32Array } | null}
*/
function parseBinaryFrame(buf) {
if (buf.length < HEADER_SIZE) return null;
const magic = buf.readUInt32LE(0);
if (magic !== CSI_MAGIC) return null;
const nodeId = buf.readUInt8(4);
const rssi = buf.readInt8(5);
const nSubcarriers = buf.readUInt16LE(6);
const payloadSize = buf.readUInt16LE(8);
if (buf.length < HEADER_SIZE + payloadSize) return null;
const amps = new Float32Array(nSubcarriers);
for (let sc = 0; sc < nSubcarriers; sc++) {
const off = HEADER_SIZE + sc * 2;
if (off + 2 > buf.length) break;
const iVal = buf[off];
const qVal = buf[off + 1];
amps[sc] = Math.sqrt(iVal * iVal + qVal * qVal);
}
return { nodeId, rssi, nSubcarriers, amplitudes: amps };
}
// ---------------------------------------------------------------------------
// Spectrogram Window
// ---------------------------------------------------------------------------
class SpectrogramWindow {
/**
* @param {number} nSubcarriers - Number of subcarriers per frame
* @param {number} windowSize - Number of time frames per window
*/
constructor(nSubcarriers, windowSize) {
this.nSubcarriers = nSubcarriers;
this.windowSize = windowSize;
/** @type {Float32Array[]} Ring buffer of amplitude vectors */
this.frames = [];
this.totalPushed = 0;
}
/** Push a new amplitude vector. */
push(amplitudes) {
if (amplitudes.length !== this.nSubcarriers) {
// Pad or truncate to expected size
const padded = new Float32Array(this.nSubcarriers);
padded.set(amplitudes.subarray(0, Math.min(amplitudes.length, this.nSubcarriers)));
this.frames.push(padded);
} else {
this.frames.push(new Float32Array(amplitudes));
}
if (this.frames.length > this.windowSize) {
this.frames.shift();
}
this.totalPushed++;
}
/** @returns {boolean} True when window is full */
isFull() {
return this.frames.length >= this.windowSize;
}
/**
* Get the subcarrier x time matrix as a flat grayscale image (0-255).
* Layout: row-major, rows = subcarriers, cols = time frames.
* @returns {{ pixels: Uint8Array, width: number, height: number }}
*/
toGrayscale() {
const h = this.nSubcarriers;
const w = this.windowSize;
const pixels = new Uint8Array(h * w);
// Find min/max across entire window for normalization
let min = Infinity;
let max = -Infinity;
for (let t = 0; t < w; t++) {
const frame = this.frames[t];
for (let sc = 0; sc < h; sc++) {
const v = frame[sc];
if (v < min) min = v;
if (v > max) max = v;
}
}
const range = max - min || 1;
for (let sc = 0; sc < h; sc++) {
for (let t = 0; t < w; t++) {
const v = this.frames[t][sc];
pixels[sc * w + t] = Math.round(255 * (v - min) / range);
}
}
return { pixels, width: w, height: h };
}
/**
* Upsample grayscale to CNN input size using nearest-neighbor interpolation.
* Replicates to 3-channel RGB as required by @ruvector/cnn.
* @returns {Uint8Array} RGB pixel data (CNN_INPUT_SIZE * CNN_INPUT_SIZE * 3)
*/
toCnnInput() {
const { pixels, width, height } = this.toGrayscale();
const out = new Uint8Array(CNN_INPUT_SIZE * CNN_INPUT_SIZE * 3);
for (let y = 0; y < CNN_INPUT_SIZE; y++) {
const srcY = Math.min(Math.floor(y * height / CNN_INPUT_SIZE), height - 1);
for (let x = 0; x < CNN_INPUT_SIZE; x++) {
const srcX = Math.min(Math.floor(x * width / CNN_INPUT_SIZE), width - 1);
const gray = pixels[srcY * width + srcX];
const dstIdx = (y * CNN_INPUT_SIZE + x) * 3;
out[dstIdx] = gray;
out[dstIdx + 1] = gray;
out[dstIdx + 2] = gray;
}
}
return out;
}
}
// ---------------------------------------------------------------------------
// ASCII Visualization
// ---------------------------------------------------------------------------
/**
* Print an ASCII spectrogram of the current window.
* Rows = subcarrier index (downsampled), columns = time.
*/
function printAsciiSpectrogram(window, meta = {}) {
const { pixels, width, height } = window.toGrayscale();
// Downsample rows to fit terminal (max 32 rows)
const maxRows = Math.min(height, 32);
const rowStep = Math.ceil(height / maxRows);
const lines = [];
lines.push(`--- Spectrogram [${height}sc x ${width}t] node=${meta.nodeId || '?'} rssi=${meta.rssi || '?'} ---`);
for (let r = 0; r < maxRows; r++) {
const sc = r * rowStep;
const label = String(sc).padStart(3);
let row = `sc${label} |`;
for (let t = 0; t < width; t++) {
const v = pixels[sc * width + t];
const level = Math.min(Math.floor(v / 29), BARS.length - 1);
row += BARS[level];
}
row += '|';
lines.push(row);
}
lines.push(` ${''.padStart(width + 2, '-')}`);
lines.push(` t=0${''.padStart(width - 6)}t=${width - 1}`);
console.log(lines.join('\n'));
}
// ---------------------------------------------------------------------------
// CNN Embedding
// ---------------------------------------------------------------------------
let cnnEmbedder = null;
let cnnInitialized = false;
/**
* Initialize the CNN embedder from vendor WASM.
*/
async function initCnn() {
if (cnnInitialized) return;
// Load WASM bindings directly to work around the CnnEmbedder wrapper bug:
// The wrapper's constructor calls `new wasm.WasmCnnEmbedder(wasmConfig)` which
// consumes (destroys) the EmbedderConfig pointer, then tries to read
// `wasmConfig.embedding_dim` from the now-null pointer. We use the WASM
// classes directly and track the dimension ourselves.
const wasmPath = path.resolve(
__dirname, '..', 'vendor', 'ruvector', 'npm', 'packages', 'ruvector-cnn'
);
const wasmModule = require(path.join(wasmPath, 'ruvector_cnn_wasm.js'));
const wasmBuffer = fs.readFileSync(path.join(wasmPath, 'ruvector_cnn_wasm_bg.wasm'));
await wasmModule.default(wasmBuffer);
const config = new wasmModule.EmbedderConfig();
config.input_size = CNN_INPUT_SIZE;
config.embedding_dim = EMBED_DIM;
config.normalize = true;
// Save dim before construction (constructor consumes config)
const savedDim = EMBED_DIM;
const inner = new wasmModule.WasmCnnEmbedder(config);
// Wrap in a compatible interface
cnnEmbedder = {
_inner: inner,
embeddingDim: savedDim,
extract(imageData, width, height) {
return new Float32Array(inner.extract(imageData, width, height));
},
cosineSimilarity(a, b) {
return inner.cosine_similarity(a, b);
},
};
cnnInitialized = true;
if (!JSON_OUTPUT) {
console.log(`[cnn] Initialized: embeddingDim=${savedDim}, inputSize=${CNN_INPUT_SIZE}x${CNN_INPUT_SIZE}`);
}
}
/**
* Extract CNN embedding from a spectrogram window.
* @param {SpectrogramWindow} window
* @returns {Float32Array} 128-dim embedding
*/
function extractEmbedding(window) {
const rgbPixels = window.toCnnInput();
return cnnEmbedder.extract(rgbPixels, CNN_INPUT_SIZE, CNN_INPUT_SIZE);
}
// ---------------------------------------------------------------------------
// Embedding Store (in-memory kNN)
// ---------------------------------------------------------------------------
class EmbeddingStore {
constructor() {
/** @type {{ embedding: Float32Array, timestamp: number, nodeId: number, windowIdx: number }[]} */
this.entries = [];
}
add(embedding, meta) {
this.entries.push({ embedding, ...meta });
}
/**
* Find k nearest neighbors by cosine similarity.
* @param {Float32Array} query
* @param {number} k
* @returns {{ index: number, similarity: number, meta: object }[]}
*/
knn(query, k) {
const scores = this.entries.map((entry, index) => ({
index,
similarity: cosineSimilarity(query, entry.embedding),
timestamp: entry.timestamp,
nodeId: entry.nodeId,
windowIdx: entry.windowIdx,
}));
scores.sort((a, b) => b.similarity - a.similarity);
return scores.slice(0, k);
}
get size() { return this.entries.length; }
}
function cosineSimilarity(a, b) {
let dot = 0, normA = 0, normB = 0;
for (let i = 0; i < a.length; i++) {
dot += a[i] * b[i];
normA += a[i] * a[i];
normB += b[i] * b[i];
}
const denom = Math.sqrt(normA) * Math.sqrt(normB);
return denom > 0 ? dot / denom : 0;
}
// ---------------------------------------------------------------------------
// Cognitum Seed Ingest
// ---------------------------------------------------------------------------
/**
* Send a 128-dim embedding to Cognitum Seed's RVF vector store.
* @param {Float32Array} embedding
* @param {object} meta
*/
async function ingestToSeed(embedding, meta) {
const seedUrl = args['seed-url'];
const token = args['seed-token'] || process.env.SEED_TOKEN;
if (!token) {
console.error('[seed] No token provided (--seed-token or $SEED_TOKEN)');
return;
}
const https = require('https');
const payload = JSON.stringify({
store: 'csi-spectrograms',
vectors: [{
id: `spectrogram-${meta.nodeId}-${meta.windowIdx}`,
values: Array.from(embedding),
metadata: {
node_id: meta.nodeId,
timestamp: meta.timestamp,
window_idx: meta.windowIdx,
rssi: meta.rssi,
subcarriers: meta.nSubcarriers,
},
}],
});
return new Promise((resolve, reject) => {
const url = new URL('/v1/vectors/upsert', seedUrl);
const req = https.request(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
'Content-Length': Buffer.byteLength(payload),
},
rejectUnauthorized: false,
}, (res) => {
let body = '';
res.on('data', (chunk) => body += chunk);
res.on('end', () => {
if (res.statusCode >= 200 && res.statusCode < 300) {
resolve(JSON.parse(body));
} else {
reject(new Error(`Seed HTTP ${res.statusCode}: ${body}`));
}
});
});
req.on('error', reject);
req.write(payload);
req.end();
});
}
// ---------------------------------------------------------------------------
// File Mode: Read JSONL Recording
// ---------------------------------------------------------------------------
async function processFile(filePath) {
await initCnn();
const store = new EmbeddingStore();
const windows = new Map(); // nodeId -> SpectrogramWindow
const rl = readline.createInterface({
input: fs.createReadStream(filePath),
crlfDelay: Infinity,
});
let frameCount = 0;
let windowCount = 0;
let lastNodeId = 0;
let lastRssi = 0;
for await (const line of rl) {
if (frameCount >= LIMIT) break;
let frame;
try {
frame = JSON.parse(line);
} catch {
continue;
}
const nodeId = frame.node_id || 0;
const nSubcarriers = frame.subcarriers || 64;
const iqHex = frame.iq_hex || '';
if (!iqHex) continue;
const amplitudes = parseIqHex(iqHex, nSubcarriers);
lastNodeId = nodeId;
lastRssi = frame.rssi || 0;
if (!windows.has(nodeId)) {
windows.set(nodeId, new SpectrogramWindow(nSubcarriers, WINDOW_SIZE));
}
const win = windows.get(nodeId);
win.push(amplitudes);
frameCount++;
// Check if this window is ready and stride condition met
if (win.isFull() && (win.totalPushed - WINDOW_SIZE) % STRIDE === 0) {
const t0 = Date.now();
const embedding = extractEmbedding(win);
const embedMs = Date.now() - t0;
const meta = {
timestamp: frame.timestamp,
nodeId,
windowIdx: windowCount,
rssi: frame.rssi || 0,
nSubcarriers,
};
store.add(embedding, meta);
if (args.ascii) {
printAsciiSpectrogram(win, { nodeId, rssi: frame.rssi });
}
if (JSON_OUTPUT) {
console.log(JSON.stringify({
type: 'embedding',
windowIdx: windowCount,
nodeId,
dim: embedding.length,
embedMs,
embedding: Array.from(embedding).map(v => +v.toFixed(6)),
}));
} else {
const embSnippet = Array.from(embedding.subarray(0, 4)).map(v => v.toFixed(4)).join(', ');
console.log(`[window ${windowCount}] node=${nodeId} embed=[${embSnippet}, ...] (${embedMs}ms)`);
}
// kNN search against previous windows
if (KNN_K > 0 && store.size > 1) {
const neighbors = store.knn(embedding, KNN_K + 1);
// Skip self (first result)
const results = neighbors.filter(n => n.windowIdx !== windowCount).slice(0, KNN_K);
if (JSON_OUTPUT) {
console.log(JSON.stringify({ type: 'knn', query: windowCount, results }));
} else {
console.log(` kNN(${KNN_K}): ${results.map(r => `w${r.windowIdx}(${r.similarity.toFixed(3)})`).join(' ')}`);
}
}
// Cognitum Seed ingest
if (args.ingest) {
try {
await ingestToSeed(embedding, meta);
if (!JSON_OUTPUT) console.log(` -> ingested to Seed`);
} catch (err) {
console.error(` -> Seed ingest failed: ${err.message}`);
}
}
windowCount++;
}
}
if (!JSON_OUTPUT) {
console.log(`\nProcessed ${frameCount} frames -> ${windowCount} spectrogram windows`);
console.log(`Store contains ${store.size} embeddings of dimension ${EMBED_DIM}`);
}
return store;
}
// ---------------------------------------------------------------------------
// Live Mode: UDP Listener
// ---------------------------------------------------------------------------
async function processLive() {
await initCnn();
const store = new EmbeddingStore();
const windows = new Map();
let windowCount = 0;
const server = dgram.createSocket('udp4');
server.on('message', async (msg, rinfo) => {
// Try binary ADR-018 format first
let parsed = parseBinaryFrame(msg);
let nodeId, nSubcarriers, amplitudes, rssi;
if (parsed) {
nodeId = parsed.nodeId;
nSubcarriers = parsed.nSubcarriers;
amplitudes = parsed.amplitudes;
rssi = parsed.rssi;
} else {
// Try JSONL format
try {
const frame = JSON.parse(msg.toString());
nodeId = frame.node_id || 0;
nSubcarriers = frame.subcarriers || 64;
amplitudes = parseIqHex(frame.iq_hex || '', nSubcarriers);
rssi = frame.rssi || 0;
} catch {
return; // Unknown format
}
}
if (!windows.has(nodeId)) {
windows.set(nodeId, new SpectrogramWindow(nSubcarriers, WINDOW_SIZE));
}
const win = windows.get(nodeId);
win.push(amplitudes);
if (win.isFull() && (win.totalPushed - WINDOW_SIZE) % STRIDE === 0) {
const t0 = Date.now();
const embedding = extractEmbedding(win);
const embedMs = Date.now() - t0;
const meta = {
timestamp: Date.now() / 1000,
nodeId,
windowIdx: windowCount,
rssi,
nSubcarriers,
};
store.add(embedding, meta);
if (args.ascii) {
printAsciiSpectrogram(win, { nodeId, rssi });
}
if (JSON_OUTPUT) {
console.log(JSON.stringify({
type: 'embedding',
windowIdx: windowCount,
nodeId,
dim: embedding.length,
embedMs,
embedding: Array.from(embedding).map(v => +v.toFixed(6)),
}));
} else {
const embSnippet = Array.from(embedding.subarray(0, 4)).map(v => v.toFixed(4)).join(', ');
console.log(`[window ${windowCount}] node=${nodeId} rssi=${rssi} embed=[${embSnippet}, ...] (${embedMs}ms)`);
}
if (KNN_K > 0 && store.size > 1) {
const neighbors = store.knn(embedding, KNN_K + 1);
const results = neighbors.filter(n => n.windowIdx !== windowCount).slice(0, KNN_K);
if (!JSON_OUTPUT) {
console.log(` kNN(${KNN_K}): ${results.map(r => `w${r.windowIdx}(${r.similarity.toFixed(3)})`).join(' ')}`);
}
}
if (args.ingest) {
try {
await ingestToSeed(embedding, meta);
} catch (err) {
console.error(` -> Seed ingest failed: ${err.message}`);
}
}
windowCount++;
}
});
server.on('listening', () => {
const addr = server.address();
console.log(`[live] Listening for CSI on UDP ${addr.address}:${addr.port}`);
console.log(`[live] Window: ${WINDOW_SIZE} frames, stride: ${STRIDE}, embed dim: ${EMBED_DIM}`);
if (KNN_K > 0) console.log(`[live] kNN search: k=${KNN_K}`);
if (args.ingest) console.log(`[live] Ingesting to Cognitum Seed at ${args['seed-url']}`);
});
server.bind(PORT);
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
if (!args.file && !args.live) {
console.error('Usage: node scripts/csi-spectrogram.js --file <path> [--ascii] [--knn K]');
console.error(' node scripts/csi-spectrogram.js --live [--port 5006] [--ingest]');
process.exit(1);
}
if (args.file) {
const filePath = path.resolve(args.file);
if (!fs.existsSync(filePath)) {
console.error(`File not found: ${filePath}`);
process.exit(1);
}
await processFile(filePath);
} else {
await processLive();
}
}
main().catch((err) => {
console.error('Fatal:', err);
process.exit(1);
});

View File

@ -0,0 +1,666 @@
#!/usr/bin/env node
/**
* ADR-076: Multi-Node Graph Transformer for CSI Fusion
*
* Builds a graph from multiple ESP32 nodes and applies graph attention to
* fuse their CSI feature vectors (either 8-dim hand-crafted or 128-dim CNN)
* into a single multi-viewpoint representation.
*
* The graph structure:
* - Each ESP32 node = graph node with a feature vector
* - Edge between nodes weighted by cross-node correlation
* - Attention learns which node to trust more per prediction
*
* Modes:
* --live Listen on UDP for real-time multi-node CSI
* --file FILE Read from a .csi.jsonl recording with multiple node_ids
* --dim DIM Feature dimension (8 for hand-crafted, 128 for CNN)
* --heads H Number of attention heads (default: 4)
* --json JSON output
*
* Usage:
* node scripts/mesh-graph-transformer.js --file data/recordings/pretrain-1775182186.csi.jsonl
* node scripts/mesh-graph-transformer.js --live --port 5006 --dim 128
*
* ADR: docs/adr/ADR-076-csi-spectrogram-embeddings.md
*/
'use strict';
const dgram = require('dgram');
const fs = require('fs');
const path = require('path');
const readline = require('readline');
const { parseArgs } = require('util');
// ---------------------------------------------------------------------------
// CLI
// ---------------------------------------------------------------------------
const { values: args } = parseArgs({
options: {
file: { type: 'string', short: 'f' },
live: { type: 'boolean', default: false },
port: { type: 'string', short: 'p', default: '5006' },
dim: { type: 'string', short: 'd', default: '8' },
heads: { type: 'string', short: 'h', default: '4' },
window: { type: 'string', short: 'w', default: '20' },
json: { type: 'boolean', default: false },
limit: { type: 'string', short: 'l' },
},
strict: true,
});
const FEAT_DIM = parseInt(args.dim, 10);
const NUM_HEADS = parseInt(args.heads, 10);
const WINDOW_SIZE = parseInt(args.window, 10);
const PORT = parseInt(args.port, 10);
const LIMIT = args.limit ? parseInt(args.limit, 10) : Infinity;
const JSON_OUTPUT = args.json;
// ADR-018 packet constants
const CSI_MAGIC = 0xC5110001;
const HEADER_SIZE = 20;
// ---------------------------------------------------------------------------
// IQ Parsing (shared with csi-spectrogram.js)
// ---------------------------------------------------------------------------
function parseIqHex(iqHex, nSubcarriers) {
const amps = new Float32Array(nSubcarriers);
for (let sc = 0; sc < nSubcarriers; sc++) {
const offset = sc * 4;
if (offset + 4 > iqHex.length) break;
const iVal = parseInt(iqHex.substring(offset, offset + 2), 16);
const qVal = parseInt(iqHex.substring(offset + 2, offset + 4), 16);
amps[sc] = Math.sqrt(iVal * iVal + qVal * qVal);
}
return amps;
}
// ---------------------------------------------------------------------------
// 8-dim Hand-Crafted Feature Extraction
// ---------------------------------------------------------------------------
/**
* Extract 8-dim feature vector from subcarrier amplitudes.
* Matches the features used by seed_csi_bridge.py (ADR-069).
* @param {Float32Array} amplitudes
* @param {number} rssi
* @returns {Float32Array}
*/
function extract8DimFeatures(amplitudes, rssi) {
const n = amplitudes.length;
if (n === 0) return new Float32Array(8);
let sum = 0, sumSq = 0, maxAmp = 0;
for (let i = 0; i < n; i++) {
const v = amplitudes[i];
sum += v;
sumSq += v * v;
if (v > maxAmp) maxAmp = v;
}
const mean = sum / n;
const variance = sumSq / n - mean * mean;
// Phase: approximate from I/Q sign pattern (simplified)
const phaseMean = 0; // Would need raw I/Q for true phase
const phaseVariance = 0;
// Bandwidth: number of subcarriers above noise floor
const noiseFloor = mean * 0.1;
let bw = 0;
for (let i = 0; i < n; i++) {
if (amplitudes[i] > noiseFloor) bw++;
}
// Spectral centroid
let weightedSum = 0;
for (let i = 0; i < n; i++) {
weightedSum += i * amplitudes[i];
}
const centroid = sum > 0 ? weightedSum / sum : n / 2;
return new Float32Array([
mean,
variance,
maxAmp,
phaseMean,
phaseVariance,
bw / n, // normalized bandwidth
centroid / n, // normalized centroid
Math.abs(rssi) / 100, // normalized RSSI
]);
}
// ---------------------------------------------------------------------------
// Graph Attention Layer (Pure JS, no WASM dependency)
// ---------------------------------------------------------------------------
/**
* Multi-head graph attention network (GATv2-style).
*
* For a graph with N nodes each having D-dimensional features:
* 1. Project features to Q, K, V using learned weights
* 2. Compute attention scores with edge weight bias
* 3. Aggregate via softmax-weighted sum
* 4. Produce fused D-dimensional output
*/
class GraphAttentionLayer {
/**
* @param {number} inputDim - Feature dimension per node
* @param {number} numHeads - Number of attention heads
*/
constructor(inputDim, numHeads) {
this.inputDim = inputDim;
this.numHeads = numHeads;
this.headDim = Math.max(1, Math.floor(inputDim / numHeads));
// Initialize projection weights (Xavier uniform)
this.Wq = this._initWeights(inputDim, this.headDim * numHeads);
this.Wk = this._initWeights(inputDim, this.headDim * numHeads);
this.Wv = this._initWeights(inputDim, this.headDim * numHeads);
this.Wo = this._initWeights(this.headDim * numHeads, inputDim);
// Edge weight bias scale
this.edgeBiasScale = 0.5;
}
/** Xavier-uniform weight initialization. */
_initWeights(rows, cols) {
const limit = Math.sqrt(6 / (rows + cols));
const w = new Float32Array(rows * cols);
for (let i = 0; i < w.length; i++) {
w[i] = (Math.random() * 2 - 1) * limit;
}
return { data: w, rows, cols };
}
/** Matrix-vector multiply: out = W * x. */
_matvec(W, x) {
const out = new Float32Array(W.rows);
for (let r = 0; r < W.rows; r++) {
let sum = 0;
for (let c = 0; c < W.cols; c++) {
sum += W.data[r * W.cols + c] * x[c];
}
out[r] = sum;
}
return out;
}
/**
* Compute attention-fused output for a set of nodes.
*
* @param {Float32Array[]} nodeFeatures - Array of D-dim feature vectors, one per node
* @param {Map<string, number>} edgeWeights - Map of "i-j" -> weight (cross-correlation)
* @returns {{ fused: Float32Array, attentionWeights: number[][] }}
*/
forward(nodeFeatures, edgeWeights) {
const N = nodeFeatures.length;
if (N === 0) return { fused: new Float32Array(this.inputDim), attentionWeights: [] };
if (N === 1) return { fused: new Float32Array(nodeFeatures[0]), attentionWeights: [[1.0]] };
const D = this.headDim;
const H = this.numHeads;
// Project to Q, K, V for each node
const queries = nodeFeatures.map(f => this._matvec(this.Wq, f));
const keys = nodeFeatures.map(f => this._matvec(this.Wk, f));
const values = nodeFeatures.map(f => this._matvec(this.Wv, f));
// Compute per-head attention scores with edge bias
const scale = 1 / Math.sqrt(D);
const allAttentionWeights = [];
// Aggregate output per node (we produce a fused vector for each node)
const nodeOutputs = [];
for (let i = 0; i < N; i++) {
const headOutputs = [];
for (let h = 0; h < H; h++) {
const hOff = h * D;
// Compute attention scores from node i to all other nodes
const scores = new Float32Array(N);
for (let j = 0; j < N; j++) {
let dot = 0;
for (let d = 0; d < D; d++) {
dot += queries[i][hOff + d] * keys[j][hOff + d];
}
// Add edge weight bias
const edgeKey = i < j ? `${i}-${j}` : `${j}-${i}`;
const ew = edgeWeights.get(edgeKey) || 0;
scores[j] = dot * scale + ew * this.edgeBiasScale;
}
// Softmax
let maxScore = -Infinity;
for (let j = 0; j < N; j++) {
if (scores[j] > maxScore) maxScore = scores[j];
}
let sumExp = 0;
const attn = new Float32Array(N);
for (let j = 0; j < N; j++) {
attn[j] = Math.exp(scores[j] - maxScore);
sumExp += attn[j];
}
for (let j = 0; j < N; j++) {
attn[j] /= sumExp;
}
if (i === 0 && h === 0) {
allAttentionWeights.push(Array.from(attn));
}
// Weighted sum of values
const headOut = new Float32Array(D);
for (let j = 0; j < N; j++) {
for (let d = 0; d < D; d++) {
headOut[d] += attn[j] * values[j][hOff + d];
}
}
headOutputs.push(headOut);
}
// Concatenate heads
const concat = new Float32Array(H * D);
for (let h = 0; h < H; h++) {
concat.set(headOutputs[h], h * D);
}
// Project back to input dimension
nodeOutputs.push(this._matvec(this.Wo, concat));
}
// Fuse all node outputs via mean pooling
const fused = new Float32Array(this.inputDim);
for (let i = 0; i < N; i++) {
for (let d = 0; d < this.inputDim; d++) {
fused[d] += nodeOutputs[i][d] / N;
}
}
return { fused, attentionWeights: allAttentionWeights };
}
}
// ---------------------------------------------------------------------------
// Cross-Node Correlation
// ---------------------------------------------------------------------------
/**
* Compute Pearson correlation between two amplitude vectors.
* Used as edge weight in the graph.
*/
function pearsonCorrelation(a, b) {
const n = Math.min(a.length, b.length);
if (n === 0) return 0;
let sumA = 0, sumB = 0, sumAB = 0, sumA2 = 0, sumB2 = 0;
for (let i = 0; i < n; i++) {
sumA += a[i];
sumB += b[i];
sumAB += a[i] * b[i];
sumA2 += a[i] * a[i];
sumB2 += b[i] * b[i];
}
const num = n * sumAB - sumA * sumB;
const den = Math.sqrt((n * sumA2 - sumA * sumA) * (n * sumB2 - sumB * sumB));
return den > 0 ? num / den : 0;
}
// ---------------------------------------------------------------------------
// Graph Builder
// ---------------------------------------------------------------------------
/**
* Build and maintain a graph of ESP32 nodes.
* Stores the latest feature vector per node and computes edge weights.
*/
class MeshGraph {
constructor(featDim, numHeads) {
this.featDim = featDim;
/** @type {Map<number, { features: Float32Array, amplitudes: Float32Array, rssi: number, timestamp: number }>} */
this.nodes = new Map();
this.attention = new GraphAttentionLayer(featDim, numHeads);
this.fusionCount = 0;
}
/**
* Update a node's features.
* @param {number} nodeId
* @param {Float32Array} features - D-dim feature vector
* @param {Float32Array} amplitudes - Raw subcarrier amplitudes (for cross-correlation)
* @param {number} rssi
* @param {number} timestamp
*/
updateNode(nodeId, features, amplitudes, rssi, timestamp) {
this.nodes.set(nodeId, { features, amplitudes, rssi, timestamp });
}
/**
* Compute edge weights between all node pairs.
* @returns {Map<string, number>}
*/
computeEdgeWeights() {
const weights = new Map();
const nodeIds = Array.from(this.nodes.keys()).sort();
for (let i = 0; i < nodeIds.length; i++) {
for (let j = i + 1; j < nodeIds.length; j++) {
const a = this.nodes.get(nodeIds[i]);
const b = this.nodes.get(nodeIds[j]);
const corr = pearsonCorrelation(a.amplitudes, b.amplitudes);
weights.set(`${i}-${j}`, corr);
}
}
return weights;
}
/**
* Run graph attention to produce a fused feature vector.
* @returns {{ fused: Float32Array, attentionWeights: number[][], nodeIds: number[], edgeWeights: Map<string, number> } | null}
*/
fuse() {
if (this.nodes.size < 2) return null;
const nodeIds = Array.from(this.nodes.keys()).sort();
const features = nodeIds.map(id => this.nodes.get(id).features);
const edgeWeights = this.computeEdgeWeights();
const { fused, attentionWeights } = this.attention.forward(features, edgeWeights);
this.fusionCount++;
return { fused, attentionWeights, nodeIds, edgeWeights };
}
/** Pretty-print graph state. */
toString() {
const nodeIds = Array.from(this.nodes.keys()).sort();
const lines = [`Graph: ${nodeIds.length} nodes [${nodeIds.join(', ')}]`];
if (nodeIds.length >= 2) {
const edgeWeights = this.computeEdgeWeights();
for (const [key, weight] of edgeWeights) {
const [i, j] = key.split('-').map(Number);
lines.push(` Edge ${nodeIds[i]}->${nodeIds[j]}: correlation=${weight.toFixed(4)}`);
}
}
return lines.join('\n');
}
}
// ---------------------------------------------------------------------------
// Optional: Graph-WASM Visualization
// ---------------------------------------------------------------------------
let graphDb = null;
/**
* Initialize @ruvector/graph-wasm for persistent graph storage.
* Optional -- only used if the WASM file exists.
*/
async function initGraphDb() {
try {
const graphWasmPath = path.resolve(
__dirname, '..', 'vendor', 'ruvector', 'npm', 'packages', 'graph-wasm'
);
const graphWasm = require(graphWasmPath);
await graphWasm.default();
graphDb = new graphWasm.GraphDB('cosine');
if (!JSON_OUTPUT) console.log('[graph-wasm] Initialized persistent graph DB');
return true;
} catch {
if (!JSON_OUTPUT) console.log('[graph-wasm] Not available, using in-memory graph only');
return false;
}
}
/**
* Persist the mesh graph to @ruvector/graph-wasm.
* @param {MeshGraph} mesh
* @param {object} fusionResult
*/
function persistToGraphDb(mesh, fusionResult) {
if (!graphDb) return;
const { nodeIds, edgeWeights, fused, attentionWeights } = fusionResult;
// Create/update nodes
for (const nodeId of nodeIds) {
const node = mesh.nodes.get(nodeId);
const existingId = `esp32-node-${nodeId}`;
try { graphDb.deleteNode(existingId); } catch { /* ignore */ }
graphDb.createNode(['ESP32', 'SensingNode'], {
id: existingId,
node_id: nodeId,
rssi: node.rssi,
timestamp: node.timestamp,
feature_dim: mesh.featDim,
});
}
// Create edges with correlation weights
for (const [key, weight] of edgeWeights) {
const [i, j] = key.split('-').map(Number);
try {
graphDb.createEdge(
`esp32-node-${nodeIds[i]}`,
`esp32-node-${nodeIds[j]}`,
'CSI_CORRELATION',
{ weight, fusion_count: mesh.fusionCount }
);
} catch { /* ignore duplicate edges */ }
}
}
// ---------------------------------------------------------------------------
// File Mode
// ---------------------------------------------------------------------------
async function processFile(filePath) {
await initGraphDb();
const mesh = new MeshGraph(FEAT_DIM, NUM_HEADS);
const rl = readline.createInterface({
input: fs.createReadStream(filePath),
crlfDelay: Infinity,
});
let frameCount = 0;
let fusionCount = 0;
const nodeFrameCounts = new Map();
for await (const line of rl) {
if (frameCount >= LIMIT) break;
let frame;
try {
frame = JSON.parse(line);
} catch {
continue;
}
const nodeId = frame.node_id || 0;
const nSubcarriers = frame.subcarriers || 64;
const iqHex = frame.iq_hex || '';
if (!iqHex) continue;
const amplitudes = parseIqHex(iqHex, nSubcarriers);
const rssi = frame.rssi || 0;
// Extract feature vector based on configured dimension
let features;
if (FEAT_DIM === 8) {
features = extract8DimFeatures(amplitudes, rssi);
} else {
// For CNN embeddings, we need the csi-spectrogram.js pipeline.
// In file mode without CNN, use padded 8-dim features as a placeholder.
const base = extract8DimFeatures(amplitudes, rssi);
features = new Float32Array(FEAT_DIM);
features.set(base.subarray(0, Math.min(8, FEAT_DIM)));
}
mesh.updateNode(nodeId, features, amplitudes, rssi, frame.timestamp || 0);
frameCount++;
const nc = (nodeFrameCounts.get(nodeId) || 0) + 1;
nodeFrameCounts.set(nodeId, nc);
// Attempt fusion every WINDOW_SIZE frames (when we have data from multiple nodes)
if (frameCount % WINDOW_SIZE === 0 && mesh.nodes.size >= 2) {
const result = mesh.fuse();
if (result) {
fusionCount++;
persistToGraphDb(mesh, result);
if (JSON_OUTPUT) {
console.log(JSON.stringify({
type: 'fusion',
fusionIdx: fusionCount,
nodeIds: result.nodeIds,
edgeWeights: Object.fromEntries(result.edgeWeights),
attentionWeights: result.attentionWeights,
fused: Array.from(result.fused).map(v => +v.toFixed(6)),
}));
} else {
console.log(`\n[fusion ${fusionCount}] ${mesh.toString()}`);
if (result.attentionWeights.length > 0) {
const aw = result.attentionWeights[0].map(w => w.toFixed(3));
console.log(` Attention (head 0): [${aw.join(', ')}]`);
}
const fusedSnippet = Array.from(result.fused.subarray(0, 4)).map(v => v.toFixed(4)).join(', ');
console.log(` Fused: [${fusedSnippet}, ...] (dim=${FEAT_DIM})`);
}
}
}
}
if (!JSON_OUTPUT) {
console.log(`\nProcessed ${frameCount} frames from ${nodeFrameCounts.size} nodes`);
console.log(`Produced ${fusionCount} fusions with ${NUM_HEADS}-head attention`);
for (const [nodeId, count] of nodeFrameCounts) {
console.log(` Node ${nodeId}: ${count} frames`);
}
if (graphDb) {
const stats = graphDb.stats();
console.log(`Graph DB: ${stats.nodeCount} nodes, ${stats.edgeCount} edges`);
}
}
}
// ---------------------------------------------------------------------------
// Live Mode
// ---------------------------------------------------------------------------
async function processLive() {
await initGraphDb();
const mesh = new MeshGraph(FEAT_DIM, NUM_HEADS);
let frameCount = 0;
let fusionCount = 0;
const server = dgram.createSocket('udp4');
server.on('message', (msg) => {
let nodeId, nSubcarriers, amplitudes, rssi;
// Try binary ADR-018 format
if (msg.length >= HEADER_SIZE && msg.readUInt32LE(0) === CSI_MAGIC) {
nodeId = msg.readUInt8(4);
rssi = msg.readInt8(5);
nSubcarriers = msg.readUInt16LE(6);
amplitudes = new Float32Array(nSubcarriers);
for (let sc = 0; sc < nSubcarriers; sc++) {
const off = HEADER_SIZE + sc * 2;
if (off + 2 > msg.length) break;
amplitudes[sc] = Math.sqrt(msg[off] ** 2 + msg[off + 1] ** 2);
}
} else {
// Try JSONL
try {
const frame = JSON.parse(msg.toString());
nodeId = frame.node_id || 0;
nSubcarriers = frame.subcarriers || 64;
amplitudes = parseIqHex(frame.iq_hex || '', nSubcarriers);
rssi = frame.rssi || 0;
} catch {
return;
}
}
let features;
if (FEAT_DIM === 8) {
features = extract8DimFeatures(amplitudes, rssi);
} else {
const base = extract8DimFeatures(amplitudes, rssi);
features = new Float32Array(FEAT_DIM);
features.set(base.subarray(0, Math.min(8, FEAT_DIM)));
}
mesh.updateNode(nodeId, features, amplitudes, rssi, Date.now() / 1000);
frameCount++;
if (frameCount % WINDOW_SIZE === 0 && mesh.nodes.size >= 2) {
const result = mesh.fuse();
if (result) {
fusionCount++;
persistToGraphDb(mesh, result);
if (JSON_OUTPUT) {
console.log(JSON.stringify({
type: 'fusion',
fusionIdx: fusionCount,
nodeIds: result.nodeIds,
edgeWeights: Object.fromEntries(result.edgeWeights),
attentionWeights: result.attentionWeights,
fused: Array.from(result.fused).map(v => +v.toFixed(6)),
}));
} else {
console.log(`[fusion ${fusionCount}] nodes=${result.nodeIds.join(',')}` +
` corr=${Array.from(result.edgeWeights.values()).map(v => v.toFixed(3)).join(',')}`);
}
}
}
});
server.on('listening', () => {
const addr = server.address();
console.log(`[live] Mesh graph transformer on UDP ${addr.address}:${addr.port}`);
console.log(`[live] Feature dim: ${FEAT_DIM}, heads: ${NUM_HEADS}, window: ${WINDOW_SIZE}`);
});
server.bind(PORT);
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
if (!args.file && !args.live) {
console.error('Usage: node scripts/mesh-graph-transformer.js --file <path> [--dim 8|128] [--heads 4]');
console.error(' node scripts/mesh-graph-transformer.js --live [--port 5006] [--dim 128]');
process.exit(1);
}
if (args.file) {
const filePath = path.resolve(args.file);
if (!fs.existsSync(filePath)) {
console.error(`File not found: ${filePath}`);
process.exit(1);
}
await processFile(filePath);
} else {
await processLive();
}
}
main().catch((err) => {
console.error('Fatal:', err);
process.exit(1);
});

View File

@ -0,0 +1,766 @@
#!/usr/bin/env node
/**
* ADR-075: Min-Cut Person Counter Subcarrier correlation graph partitioning
*
* Fixes issue #348: n_persons always shows 4. Instead of threshold-based
* counting, builds a subcarrier correlation graph and uses Stoer-Wagner
* min-cut to find naturally independent groups of correlated subcarriers.
* Each group = one person's Fresnel zone perturbation.
*
* Usage:
* # Live from ESP32 nodes via UDP
* node scripts/mincut-person-counter.js --port 5006
*
* # Replay from recorded CSI data
* node scripts/mincut-person-counter.js --replay data/recordings/pretrain-1775182186.csi.jsonl
*
* # JSON output for piping to seed bridge
* node scripts/mincut-person-counter.js --replay FILE --json
*
* # Override feature vector dim 5 and forward to seed bridge
* node scripts/mincut-person-counter.js --port 5006 --forward 5007
*
* ADR: docs/adr/ADR-075-mincut-person-separation.md
*/
'use strict';
const dgram = require('dgram');
const fs = require('fs');
const readline = require('readline');
const { parseArgs } = require('util');
// ---------------------------------------------------------------------------
// CLI
// ---------------------------------------------------------------------------
const { values: args } = parseArgs({
options: {
port: { type: 'string', short: 'p', default: '5006' },
replay: { type: 'string', short: 'r' },
json: { type: 'boolean', default: false },
forward: { type: 'string', short: 'f' },
interval: { type: 'string', short: 'i', default: '2000' },
window: { type: 'string', short: 'w', default: '2000' },
'corr-threshold': { type: 'string', default: '0.3' },
'cut-threshold': { type: 'string', default: '2.0' },
'var-floor': { type: 'string', default: '0.5' },
'max-persons': { type: 'string', default: '8' },
},
strict: true,
});
const PORT = parseInt(args.port, 10);
const INTERVAL_MS = parseInt(args.interval, 10);
const WINDOW_MS = parseInt(args.window, 10);
const CORR_THRESHOLD = parseFloat(args['corr-threshold']);
const CUT_THRESHOLD = parseFloat(args['cut-threshold']);
const VAR_FLOOR = parseFloat(args['var-floor']);
const MAX_PERSONS = parseInt(args['max-persons'], 10);
const JSON_OUTPUT = args.json;
const FORWARD_PORT = args.forward ? parseInt(args.forward, 10) : null;
// ---------------------------------------------------------------------------
// ADR-018 packet constants
// ---------------------------------------------------------------------------
const CSI_MAGIC = 0xC5110001;
const HEADER_SIZE = 20;
// ---------------------------------------------------------------------------
// Per-node sliding window of subcarrier amplitudes
// ---------------------------------------------------------------------------
class SubcarrierWindow {
constructor(maxAgeMs) {
this.maxAgeMs = maxAgeMs;
this.frames = []; // { timestamp, amplitudes: Float64Array }
this.nSubcarriers = 0;
}
push(timestamp, amplitudes) {
this.nSubcarriers = amplitudes.length;
this.frames.push({ timestamp, amplitudes: Float64Array.from(amplitudes) });
this._prune(timestamp);
}
_prune(now) {
const cutoff = now - this.maxAgeMs;
while (this.frames.length > 0 && this.frames[0].timestamp < cutoff) {
this.frames.shift();
}
}
get length() { return this.frames.length; }
/**
* Compute pairwise Pearson correlation matrix for all subcarrier pairs.
* Returns { matrix: Float64Array (n*n row-major), n, activeIndices }
*/
correlationMatrix() {
const nFrames = this.frames.length;
const nSc = this.nSubcarriers;
if (nFrames < 5 || nSc === 0) return null;
// Compute mean and std for each subcarrier
const mean = new Float64Array(nSc);
const std = new Float64Array(nSc);
for (let f = 0; f < nFrames; f++) {
const amp = this.frames[f].amplitudes;
for (let i = 0; i < nSc; i++) {
mean[i] += amp[i];
}
}
for (let i = 0; i < nSc; i++) mean[i] /= nFrames;
for (let f = 0; f < nFrames; f++) {
const amp = this.frames[f].amplitudes;
for (let i = 0; i < nSc; i++) {
const d = amp[i] - mean[i];
std[i] += d * d;
}
}
for (let i = 0; i < nSc; i++) {
std[i] = Math.sqrt(std[i] / (nFrames - 1));
}
// Filter out null/static subcarriers (std below noise floor)
const activeIndices = [];
for (let i = 0; i < nSc; i++) {
if (std[i] > VAR_FLOOR) {
activeIndices.push(i);
}
}
const n = activeIndices.length;
if (n < 2) return { matrix: null, n: 0, activeIndices };
// Compute Pearson correlation for active pairs
const matrix = new Float64Array(n * n);
for (let ai = 0; ai < n; ai++) {
matrix[ai * n + ai] = 1.0; // self-correlation
const si = activeIndices[ai];
for (let aj = ai + 1; aj < n; aj++) {
const sj = activeIndices[aj];
let cov = 0;
for (let f = 0; f < nFrames; f++) {
const amp = this.frames[f].amplitudes;
cov += (amp[si] - mean[si]) * (amp[sj] - mean[sj]);
}
cov /= (nFrames - 1);
const denom = std[si] * std[sj];
const r = denom > 1e-10 ? cov / denom : 0;
matrix[ai * n + aj] = r;
matrix[aj * n + ai] = r;
}
}
return { matrix, n, activeIndices };
}
}
// ---------------------------------------------------------------------------
// Weighted undirected graph (adjacency list)
// ---------------------------------------------------------------------------
class WeightedGraph {
constructor(n) {
this.n = n;
// adj[i] = Map<j, weight>
this.adj = new Array(n);
for (let i = 0; i < n; i++) this.adj[i] = new Map();
this.edgeCount = 0;
}
addEdge(u, v, w) {
if (u === v) return;
if (!this.adj[u].has(v)) this.edgeCount++;
this.adj[u].set(v, w);
this.adj[v].set(u, w);
}
/** Build graph from correlation matrix, keeping edges above threshold */
static fromCorrelation(matrix, n, threshold) {
const g = new WeightedGraph(n);
for (let i = 0; i < n; i++) {
for (let j = i + 1; j < n; j++) {
const r = Math.abs(matrix[i * n + j]);
if (r > threshold) {
g.addEdge(i, j, r);
}
}
}
return g;
}
/**
* Find connected components via BFS.
* Returns array of arrays: each inner array = vertex indices in component.
*/
connectedComponents() {
const visited = new Uint8Array(this.n);
const components = [];
for (let start = 0; start < this.n; start++) {
if (visited[start]) continue;
const comp = [];
const queue = [start];
visited[start] = 1;
while (queue.length > 0) {
const u = queue.shift();
comp.push(u);
for (const [v] of this.adj[u]) {
if (!visited[v]) {
visited[v] = 1;
queue.push(v);
}
}
}
components.push(comp);
}
return components;
}
/**
* Extract a subgraph containing only the specified vertices.
* Returns a new WeightedGraph with vertices relabeled 0..vertices.length-1,
* plus a mapping array from new index to original index.
*/
subgraph(vertices) {
const newIdx = new Map();
vertices.forEach((v, i) => newIdx.set(v, i));
const sub = new WeightedGraph(vertices.length);
for (const u of vertices) {
for (const [v, w] of this.adj[u]) {
if (newIdx.has(v) && u < v) {
sub.addEdge(newIdx.get(u), newIdx.get(v), w);
}
}
}
return { graph: sub, mapping: vertices };
}
}
// ---------------------------------------------------------------------------
// Stoer-Wagner minimum cut algorithm
//
// Finds the global minimum s-t cut of an undirected weighted graph.
// Complexity: O(V * E) using adjacency list with priority tracking.
//
// Reference: Stoer & Wagner (1997), "A Simple Min-Cut Algorithm", JACM.
// ---------------------------------------------------------------------------
/**
* Run one "minimum cut phase" of Stoer-Wagner.
*
* Starting from an arbitrary vertex, greedily add the most tightly connected
* vertex to the growing set A until all vertices are absorbed.
*
* @param {number} n - Number of active vertices
* @param {Map<number, Map<number, number>>} adj - Adjacency: adj[u].get(v) = weight
* @param {number[]} activeVertices - List of active vertex IDs
* @returns {{ s: number, t: number, cutOfPhase: number }}
*/
function minimumCutPhase(n, adj, activeVertices) {
// key[v] = sum of edge weights from v to vertices already in A
const key = new Float64Array(n);
const inA = new Uint8Array(n);
const active = new Uint8Array(n);
for (const v of activeVertices) active[v] = 1;
let s = -1, t = -1;
for (let iter = 0; iter < activeVertices.length; iter++) {
// Find vertex not in A with maximum key value
let best = -1, bestKey = -Infinity;
for (const v of activeVertices) {
if (!inA[v] && key[v] > bestKey) {
bestKey = key[v];
best = v;
}
}
// On first iteration when all keys are 0, just pick the first active vertex
if (best === -1) {
for (const v of activeVertices) {
if (!inA[v]) { best = v; break; }
}
}
s = t;
t = best;
inA[best] = 1;
// Update keys: for each neighbor of best, increase key
if (adj[best]) {
for (const [neighbor, weight] of adj[best]) {
if (active[neighbor] && !inA[neighbor]) {
key[neighbor] += weight;
}
}
}
}
// Cut of the phase = sum of edges from t to all other active vertices
let cutOfPhase = 0;
if (adj[t]) {
for (const [neighbor, weight] of adj[t]) {
if (active[neighbor] && neighbor !== t) {
cutOfPhase += weight;
}
}
}
return { s, t, cutOfPhase };
}
/**
* Stoer-Wagner global minimum cut.
*
* @param {WeightedGraph} graph
* @returns {{ minCutValue: number, partition: [number[], number[]] }}
* partition[0] = vertices on one side, partition[1] = vertices on the other side
*/
function stoerWagner(graph) {
const n = graph.n;
if (n <= 1) return { minCutValue: Infinity, partition: [Array.from({length: n}, (_, i) => i), []] };
// Build mutable adjacency (Map-based for efficient merge)
const adj = new Array(n);
for (let i = 0; i < n; i++) adj[i] = new Map(graph.adj[i]);
// Track which original vertices each super-vertex contains
const groups = new Array(n);
for (let i = 0; i < n; i++) groups[i] = [i];
let activeVertices = Array.from({length: n}, (_, i) => i);
let bestCut = Infinity;
let bestPartitionSide = null; // group of vertices on the "t" side of the best cut
while (activeVertices.length > 1) {
const { s, t, cutOfPhase } = minimumCutPhase(n, adj, activeVertices);
if (s === -1 || t === -1) break;
if (cutOfPhase < bestCut) {
bestCut = cutOfPhase;
bestPartitionSide = [...groups[t]];
}
// Merge t into s: move all edges from t to s
if (adj[t]) {
for (const [neighbor, weight] of adj[t]) {
if (neighbor === s) continue;
const existing = adj[s].get(neighbor) || 0;
adj[s].set(neighbor, existing + weight);
// Update neighbor's adjacency
adj[neighbor].delete(t);
adj[neighbor].set(s, existing + weight);
}
}
adj[s].delete(t);
// Merge group membership
groups[s] = groups[s].concat(groups[t]);
groups[t] = [];
// Remove t from active vertices
activeVertices = activeVertices.filter(v => v !== t);
}
// Build full partition
if (!bestPartitionSide || bestPartitionSide.length === 0) {
return { minCutValue: Infinity, partition: [Array.from({length: n}, (_, i) => i), []] };
}
const sideSet = new Set(bestPartitionSide);
const sideA = [], sideB = [];
for (let i = 0; i < n; i++) {
if (sideSet.has(i)) sideA.push(i);
else sideB.push(i);
}
return { minCutValue: bestCut, partition: [sideA, sideB] };
}
// ---------------------------------------------------------------------------
// Recursive min-cut person separator
//
// Recursively applies Stoer-Wagner to split the correlation graph into
// independent clusters. Each cluster = one person's Fresnel zone group.
// ---------------------------------------------------------------------------
/**
* @param {WeightedGraph} graph
* @param {number} cutThreshold - min-cut below this = real person boundary
* @param {number} maxPersons - stop splitting after this many partitions
* @returns {number[][]} - array of vertex groups (each = one person's subcarriers)
*/
function separatePersons(graph, cutThreshold, maxPersons) {
// Start with connected components (disconnected groups are trivially separate)
const components = graph.connectedComponents();
const personGroups = [];
for (const comp of components) {
if (comp.length < 2) {
// Single vertex — not enough for a person
continue;
}
_splitComponent(graph, comp, cutThreshold, maxPersons, personGroups);
}
return personGroups;
}
function _splitComponent(graph, vertices, cutThreshold, maxPersons, result) {
if (vertices.length < 2 || result.length >= maxPersons) {
if (vertices.length >= 2) result.push(vertices);
return;
}
// Extract subgraph
const { graph: sub, mapping } = graph.subgraph(vertices);
// Run Stoer-Wagner on the subgraph
const { minCutValue, partition } = stoerWagner(sub);
// If the min-cut is above threshold, this is one coherent group (one person)
if (minCutValue >= cutThreshold || partition[0].length === 0 || partition[1].length === 0) {
result.push(vertices);
return;
}
// Map partition indices back to original vertex IDs
const groupA = partition[0].map(i => mapping[i]);
const groupB = partition[1].map(i => mapping[i]);
// Recurse on each side
_splitComponent(graph, groupA, cutThreshold, maxPersons, result);
_splitComponent(graph, groupB, cutThreshold, maxPersons, result);
}
// ---------------------------------------------------------------------------
// CSI frame parsing (from JSONL recording or UDP)
// ---------------------------------------------------------------------------
/** Parse IQ hex string into amplitude array */
function parseIqHex(iqHex, nSubcarriers) {
const bytes = Buffer.from(iqHex, 'hex');
const amplitudes = new Float64Array(nSubcarriers);
// IQ data: pairs of signed int8 (I, Q) for each subcarrier
// First 2 bytes are header/padding, then I/Q pairs
for (let sc = 0; sc < nSubcarriers; sc++) {
const offset = 2 + sc * 2; // skip 2-byte header
if (offset + 1 >= bytes.length) break;
// Read as signed int8
let I = bytes[offset];
let Q = bytes[offset + 1];
if (I > 127) I -= 256;
if (Q > 127) Q -= 256;
amplitudes[sc] = Math.sqrt(I * I + Q * Q);
}
return amplitudes;
}
/** Parse binary UDP CSI packet (ADR-018 format) */
function parseUdpPacket(buf) {
if (buf.length < HEADER_SIZE) return null;
const magic = buf.readUInt32LE(0);
if (magic !== CSI_MAGIC) return null;
const nodeId = buf.readUInt8(4);
const nAntennas = buf.readUInt8(5) || 1;
const nSubcarriers = buf.readUInt16LE(6);
const freqMhz = buf.readUInt32LE(8);
const rssi = buf.readInt8(16);
const iqLen = nSubcarriers * nAntennas * 2;
if (buf.length < HEADER_SIZE + iqLen) return null;
const amplitudes = new Float64Array(nSubcarriers);
for (let sc = 0; sc < nSubcarriers; sc++) {
const offset = HEADER_SIZE + sc * 2;
const I = buf.readInt8(offset);
const Q = buf.readInt8(offset + 1);
amplitudes[sc] = Math.sqrt(I * I + Q * Q);
}
return { nodeId, nSubcarriers, freqMhz, rssi, amplitudes, timestamp: Date.now() / 1000 };
}
// ---------------------------------------------------------------------------
// Analysis engine
// ---------------------------------------------------------------------------
class PersonCounter {
constructor(opts) {
this.windowMs = opts.windowMs;
this.corrThreshold = opts.corrThreshold;
this.cutThreshold = opts.cutThreshold;
this.maxPersons = opts.maxPersons;
// Per-node sliding windows
this.windows = new Map(); // nodeId -> SubcarrierWindow
// Latest result
this.lastResult = null;
this.analysisCount = 0;
}
ingestFrame(nodeId, timestamp, amplitudes) {
if (!this.windows.has(nodeId)) {
this.windows.set(nodeId, new SubcarrierWindow(this.windowMs));
}
this.windows.get(nodeId).push(timestamp * 1000, amplitudes);
}
/**
* Run the min-cut analysis on accumulated data.
* Merges subcarrier data from all nodes into a single correlation graph.
*
* @returns {{ personCount, groups, graphStats, perNode }}
*/
analyze() {
this.analysisCount++;
const perNode = {};
const allGroups = [];
let totalPersons = 0;
for (const [nodeId, window] of this.windows) {
const corr = window.correlationMatrix();
if (!corr || !corr.matrix || corr.n < 2) {
perNode[nodeId] = { personCount: 0, activeSubcarriers: corr ? corr.n : 0, groups: [], edges: 0 };
continue;
}
// Build correlation graph
const graph = WeightedGraph.fromCorrelation(corr.matrix, corr.n, this.corrThreshold);
// Separate persons via recursive min-cut
const groups = separatePersons(graph, this.cutThreshold, this.maxPersons);
// Map group indices back to original subcarrier indices
const mappedGroups = groups.map(g => g.map(i => corr.activeIndices[i]));
const nodeResult = {
personCount: groups.length,
activeSubcarriers: corr.n,
totalSubcarriers: window.nSubcarriers,
groups: mappedGroups,
edges: graph.edgeCount,
frames: window.length,
};
perNode[nodeId] = nodeResult;
totalPersons = Math.max(totalPersons, groups.length);
allGroups.push(...mappedGroups);
}
this.lastResult = {
personCount: totalPersons,
groups: allGroups,
perNode,
timestamp: Date.now() / 1000,
analysisIndex: this.analysisCount,
};
return this.lastResult;
}
}
// ---------------------------------------------------------------------------
// ASCII output
// ---------------------------------------------------------------------------
function formatResult(result) {
const lines = [];
const ts = new Date(result.timestamp * 1000).toISOString().slice(11, 19);
lines.push(`\x1b[1m[${ts}] Persons: ${result.personCount}\x1b[0m (analysis #${result.analysisIndex})`);
for (const [nodeId, nodeResult] of Object.entries(result.perNode)) {
const { personCount, activeSubcarriers, totalSubcarriers, groups, edges, frames } = nodeResult;
lines.push(` Node ${nodeId}: ${personCount} person(s) | ${activeSubcarriers}/${totalSubcarriers} active subcarriers | ${edges} edges | ${frames} frames`);
for (let i = 0; i < groups.length; i++) {
const g = groups[i];
const scList = g.length <= 12 ? g.join(',') : g.slice(0, 10).join(',') + `...+${g.length - 10}`;
lines.push(` Person ${i + 1}: subcarriers [${scList}] (${g.length} sc)`);
}
}
return lines.join('\n');
}
function formatJson(result) {
return JSON.stringify(result);
}
// ---------------------------------------------------------------------------
// UDP forwarding (override person count in feature vector)
// ---------------------------------------------------------------------------
let forwardSocket = null;
function forwardWithCorrectedCount(buf, personCount) {
if (!FORWARD_PORT || !forwardSocket) return;
// If it's a vitals packet (magic 0xC5110002), override byte 13 (nPersons)
const magic = buf.readUInt32LE(0);
if (magic === 0xC5110002 && buf.length >= 14) {
const copy = Buffer.from(buf);
copy.writeUInt8(Math.min(personCount, 255), 13);
forwardSocket.send(copy, FORWARD_PORT, '127.0.0.1');
} else {
// Forward as-is
forwardSocket.send(buf, FORWARD_PORT, '127.0.0.1');
}
}
// ---------------------------------------------------------------------------
// Main: live UDP mode
// ---------------------------------------------------------------------------
function startLive() {
const counter = new PersonCounter({
windowMs: WINDOW_MS,
corrThreshold: CORR_THRESHOLD,
cutThreshold: CUT_THRESHOLD,
maxPersons: MAX_PERSONS,
});
const server = dgram.createSocket('udp4');
if (FORWARD_PORT) {
forwardSocket = dgram.createSocket('udp4');
}
server.on('message', (buf, rinfo) => {
const frame = parseUdpPacket(buf);
if (frame) {
counter.ingestFrame(frame.nodeId, frame.timestamp, frame.amplitudes);
}
// Forward all packets with corrected person count
if (counter.lastResult) {
forwardWithCorrectedCount(buf, counter.lastResult.personCount);
}
});
// Periodic analysis
setInterval(() => {
const result = counter.analyze();
if (JSON_OUTPUT) {
console.log(formatJson(result));
} else {
process.stdout.write('\x1b[2J\x1b[H'); // clear screen
console.log('ADR-075 Min-Cut Person Counter (live UDP)');
console.log('─'.repeat(60));
console.log(formatResult(result));
console.log('─'.repeat(60));
console.log(`Thresholds: corr=${CORR_THRESHOLD} cut=${CUT_THRESHOLD} var-floor=${VAR_FLOOR}`);
}
}, INTERVAL_MS);
server.bind(PORT, () => {
if (!JSON_OUTPUT) {
console.log(`Listening on UDP port ${PORT} (analysis every ${INTERVAL_MS}ms, window ${WINDOW_MS}ms)`);
if (FORWARD_PORT) console.log(`Forwarding corrected packets to UDP port ${FORWARD_PORT}`);
}
});
}
// ---------------------------------------------------------------------------
// Main: replay mode (from .csi.jsonl recording)
// ---------------------------------------------------------------------------
async function startReplay(filePath) {
const counter = new PersonCounter({
windowMs: WINDOW_MS,
corrThreshold: CORR_THRESHOLD,
cutThreshold: CUT_THRESHOLD,
maxPersons: MAX_PERSONS,
});
if (!fs.existsSync(filePath)) {
console.error(`File not found: ${filePath}`);
process.exit(1);
}
const rl = readline.createInterface({
input: fs.createReadStream(filePath),
crlfDelay: Infinity,
});
let frameCount = 0;
let lastAnalysisTs = 0;
let analysisResults = [];
for await (const line of rl) {
if (!line.trim()) continue;
let record;
try {
record = JSON.parse(line);
} catch {
continue;
}
if (record.type !== 'raw_csi' || !record.iq_hex) continue;
const amplitudes = parseIqHex(record.iq_hex, record.subcarriers || 64);
counter.ingestFrame(record.node_id, record.timestamp, amplitudes);
frameCount++;
// Run analysis every INTERVAL_MS worth of frames
const tsMs = record.timestamp * 1000;
if (lastAnalysisTs === 0) lastAnalysisTs = tsMs;
if (tsMs - lastAnalysisTs >= INTERVAL_MS) {
const result = counter.analyze();
analysisResults.push(result);
if (JSON_OUTPUT) {
console.log(formatJson(result));
} else {
console.log(formatResult(result));
console.log();
}
lastAnalysisTs = tsMs;
}
}
// Final analysis
const result = counter.analyze();
analysisResults.push(result);
if (!JSON_OUTPUT) {
console.log('─'.repeat(60));
console.log('FINAL ANALYSIS');
console.log('─'.repeat(60));
console.log(formatResult(result));
console.log();
console.log(`Processed ${frameCount} frames, ${analysisResults.length} analysis windows`);
// Summary statistics
const counts = analysisResults.map(r => r.personCount);
const avg = counts.reduce((a, b) => a + b, 0) / counts.length;
const max = Math.max(...counts);
const min = Math.min(...counts);
console.log(`Person count: min=${min} max=${max} avg=${avg.toFixed(1)}`);
console.log(`Thresholds: corr=${CORR_THRESHOLD} cut=${CUT_THRESHOLD} var-floor=${VAR_FLOOR}`);
} else {
console.log(formatJson(result));
}
}
// ---------------------------------------------------------------------------
// Entry point
// ---------------------------------------------------------------------------
if (args.replay) {
startReplay(args.replay);
} else {
startLive();
}

View File

@ -0,0 +1,475 @@
#!/usr/bin/env node
/**
* SNN-CSI Processor Spiking Neural Network for WiFi CSI Sensing
*
* Receives live CSI frames via UDP (ADR-018 binary format), feeds subcarrier
* amplitude deltas through a 128-64-8 SNN with STDP online learning.
* Output neurons map to: presence, motion, breathing, HR, phase_var, persons, fall, RSSI.
*
* Usage:
* node scripts/snn-csi-processor.js [options]
*
* Options:
* --port <n> UDP listen port (default: 5006)
* --max-rate <n> Max spike rate in Hz (default: 200)
* --learning-rate <n> STDP a_plus/a_minus (default: 0.005)
* --hidden <n> Hidden layer neurons (default: 64)
* --no-learn Disable STDP (freeze weights)
* --send-vectors Forward spike vectors to Cognitum Seed
* --seed-host <host> Cognitum Seed host (default: localhost)
* --seed-port <n> Cognitum Seed port (default: 5007)
* --quiet Suppress visualization, print only JSON
*
* Requires: @ruvector/spiking-neural (vendored or npm)
*
* ADR-074: Spiking Neural Network for CSI Sensing
*/
'use strict';
const dgram = require('dgram');
const path = require('path');
// ---------------------------------------------------------------------------
// Resolve spiking-neural: try npm, then vendor
// ---------------------------------------------------------------------------
let snn_lib;
try {
snn_lib = require('@ruvector/spiking-neural');
} catch {
try {
snn_lib = require(path.resolve(
__dirname, '..', 'vendor', 'ruvector', 'npm', 'packages', 'spiking-neural', 'src', 'index.js'
));
} catch {
// If src/index.js doesn't exist locally, fall back to the CLI which re-exports
snn_lib = require(path.resolve(
__dirname, '..', 'vendor', 'ruvector', 'npm', 'packages', 'spiking-neural', 'bin', 'cli.js'
));
}
}
const { createFeedforwardSNN, rateEncoding, SIMDOps, version: snnVersion } = snn_lib;
// ---------------------------------------------------------------------------
// CLI argument parsing
// ---------------------------------------------------------------------------
function parseArgs() {
const args = process.argv.slice(2);
const opts = {
port: 5006,
maxRate: 200,
learningRate: 0.005,
hidden: 64,
learn: true,
sendVectors: false,
seedHost: 'localhost',
seedPort: 5007,
quiet: false,
};
for (let i = 0; i < args.length; i++) {
switch (args[i]) {
case '--port': opts.port = parseInt(args[++i], 10); break;
case '--max-rate': opts.maxRate = parseInt(args[++i], 10); break;
case '--learning-rate': opts.learningRate = parseFloat(args[++i]); break;
case '--hidden': opts.hidden = parseInt(args[++i], 10); break;
case '--no-learn': opts.learn = false; break;
case '--send-vectors': opts.sendVectors = true; break;
case '--seed-host': opts.seedHost = args[++i]; break;
case '--seed-port': opts.seedPort = parseInt(args[++i], 10); break;
case '--quiet': opts.quiet = true; break;
case '--help': case '-h':
console.log(`SNN-CSI Processor (spiking-neural v${snnVersion || '?'})`);
console.log('Usage: node scripts/snn-csi-processor.js [options]');
console.log(' --port <n> UDP listen port (default: 5006)');
console.log(' --max-rate <n> Max spike rate Hz (default: 200)');
console.log(' --learning-rate <n> STDP rate (default: 0.005)');
console.log(' --hidden <n> Hidden neurons (default: 64)');
console.log(' --no-learn Freeze STDP weights');
console.log(' --send-vectors Forward to Cognitum Seed');
console.log(' --seed-host <host> Seed host (default: localhost)');
console.log(' --seed-port <n> Seed port (default: 5007)');
console.log(' --quiet JSON-only output');
process.exit(0);
}
}
return opts;
}
// ---------------------------------------------------------------------------
// ADR-018 binary frame parser
// ---------------------------------------------------------------------------
const HEADER_SIZE = 20;
function parseFrame(buf) {
if (buf.length < HEADER_SIZE) return null;
const magic = buf.readUInt32LE(0);
// ADR-018 magic: 0xC5110001 (raw CSI), 0xC5110002 (vitals), 0xC5110003 (features)
if (magic !== 0xC5110001 && magic !== 0xC5110002 && magic !== 0xC5110003) return null;
const version = buf.readUInt8(2);
const flags = buf.readUInt8(3);
const timestamp = buf.readUInt32LE(4);
const frequency = buf.readUInt32LE(8);
const rssi = buf.readInt8(12);
const noiseFloor = buf.readInt8(13);
const numSubcarriers = buf.readUInt16LE(14);
const nodeId = buf.readUInt16LE(16);
const seqNum = buf.readUInt16LE(18);
const expectedPayload = numSubcarriers * 4; // 2 bytes I + 2 bytes Q per subcarrier
if (buf.length < HEADER_SIZE + expectedPayload) {
// Fallback: try 2 bytes per subcarrier (amplitude only)
if (buf.length >= HEADER_SIZE + numSubcarriers * 2) {
const amplitudes = new Float32Array(numSubcarriers);
for (let i = 0; i < numSubcarriers; i++) {
amplitudes[i] = buf.readInt16LE(HEADER_SIZE + i * 2);
}
return { timestamp, frequency, rssi, noiseFloor, numSubcarriers, nodeId, seqNum, amplitudes };
}
return null;
}
// Parse I/Q and compute amplitudes
const amplitudes = new Float32Array(numSubcarriers);
for (let i = 0; i < numSubcarriers; i++) {
const offset = HEADER_SIZE + i * 4;
const real = buf.readInt16LE(offset);
const imag = buf.readInt16LE(offset + 2);
amplitudes[i] = Math.sqrt(real * real + imag * imag);
}
return { timestamp, frequency, rssi, noiseFloor, numSubcarriers, nodeId, seqNum, amplitudes };
}
// ---------------------------------------------------------------------------
// SNN setup
// ---------------------------------------------------------------------------
const INPUT_NEURONS = 128;
const OUTPUT_NEURONS = 8;
const OUTPUT_LABELS = [
'presence', 'motion', 'breathing', 'heart_rate',
'phase_var', 'persons', 'fall', 'rssi'
];
function createCSISnn(opts) {
const snn = createFeedforwardSNN([INPUT_NEURONS, opts.hidden, OUTPUT_NEURONS], {
dt: 1.0,
tau: 20.0,
v_rest: -70.0,
v_reset: -75.0,
v_thresh: -50.0,
resistance: 10.0,
a_plus: opts.learningRate,
a_minus: opts.learningRate * 0.6, // Slight asymmetry: LTP > LTD for stability
w_min: 0.0,
w_max: 1.0,
init_weight: 0.3,
init_std: 0.05,
lateral_inhibition: true,
inhibition_strength: 15.0,
});
return snn;
}
// ---------------------------------------------------------------------------
// Amplitude delta tracking + normalization
// ---------------------------------------------------------------------------
class DeltaTracker {
constructor(size) {
this.size = size;
this.prev = null;
this.maxDelta = 1.0; // Adaptive normalization ceiling
this.ewmaMaxDelta = 1.0;
}
/**
* Compute normalized amplitude deltas from a new frame.
* Returns Float32Array of length INPUT_NEURONS (zero-padded if fewer subcarriers).
*/
update(amplitudes) {
const n = Math.min(amplitudes.length, this.size);
const deltas = new Float32Array(this.size);
if (this.prev === null) {
this.prev = new Float32Array(amplitudes);
return deltas; // First frame: all zeros (no delta yet)
}
let frameMax = 0;
for (let i = 0; i < n; i++) {
const d = Math.abs(amplitudes[i] - this.prev[i]);
deltas[i] = d;
if (d > frameMax) frameMax = d;
}
// Update adaptive normalization with EWMA
if (frameMax > 0) {
this.ewmaMaxDelta = 0.95 * this.ewmaMaxDelta + 0.05 * frameMax;
this.maxDelta = Math.max(this.ewmaMaxDelta, 1.0);
}
// Normalize to [0, 1]
for (let i = 0; i < this.size; i++) {
deltas[i] = Math.min(deltas[i] / this.maxDelta, 1.0);
}
// Store current amplitudes for next delta
this.prev = new Float32Array(amplitudes);
return deltas;
}
}
// ---------------------------------------------------------------------------
// Spike rate smoother (exponentially-weighted moving average on output)
// ---------------------------------------------------------------------------
class OutputSmoother {
constructor(size, alpha) {
this.size = size;
this.alpha = alpha; // Smoothing factor (0.1 = slow, 0.5 = fast)
this.smoothed = new Float32Array(size);
}
update(raw) {
for (let i = 0; i < this.size; i++) {
this.smoothed[i] = this.alpha * raw[i] + (1 - this.alpha) * this.smoothed[i];
}
return this.smoothed;
}
}
// ---------------------------------------------------------------------------
// ASCII visualization
// ---------------------------------------------------------------------------
const BAR_CHARS = ' .:;+=xX#@';
function renderBar(value, maxWidth) {
const clamped = Math.min(Math.max(value, 0), 1);
const filled = Math.round(clamped * maxWidth);
const charIdx = Math.min(Math.floor(clamped * (BAR_CHARS.length - 1)), BAR_CHARS.length - 1);
return BAR_CHARS[charIdx].repeat(filled).padEnd(maxWidth);
}
function renderVisualization(outputSmoothed, stats, frameCount, opts) {
const lines = [];
lines.push('');
lines.push(`--- SNN-CSI Processor (frame #${frameCount}) ---`);
lines.push(` Network: ${INPUT_NEURONS}-${opts.hidden}-${OUTPUT_NEURONS} | STDP: ${opts.learn ? 'ON' : 'OFF'} | Spikes: ${stats.totalSpikes}`);
lines.push('');
lines.push(' Output Activity:');
// Find max for relative scaling
const maxVal = Math.max(...outputSmoothed, 0.001);
for (let i = 0; i < OUTPUT_NEURONS; i++) {
const norm = outputSmoothed[i] / maxVal;
const bar = renderBar(norm, 30);
const raw = outputSmoothed[i].toFixed(2).padStart(6);
lines.push(` ${OUTPUT_LABELS[i].padEnd(12)} |${bar}| ${raw}`);
}
lines.push('');
// Hidden layer activity heatmap (single row)
const hiddenActivity = stats.hiddenSpikes || [];
let heatmap = ' Hidden: ';
for (let i = 0; i < Math.min(opts.hidden, 64); i++) {
const val = hiddenActivity[i] || 0;
const charIdx = Math.min(Math.floor(val * (BAR_CHARS.length - 1)), BAR_CHARS.length - 1);
heatmap += BAR_CHARS[Math.max(charIdx, 0)];
}
lines.push(heatmap);
// Weight stats
if (stats.weightMean !== undefined) {
lines.push(` Weights: mean=${stats.weightMean.toFixed(3)} min=${stats.weightMin.toFixed(3)} max=${stats.weightMax.toFixed(3)}`);
}
lines.push('');
// Clear screen and print (ANSI escape)
process.stdout.write('\x1b[2J\x1b[H');
process.stdout.write(lines.join('\n'));
}
// ---------------------------------------------------------------------------
// Main processing loop
// ---------------------------------------------------------------------------
function main() {
const opts = parseArgs();
console.log(`SNN-CSI Processor`);
console.log(` spiking-neural version: ${snnVersion || 'unknown'}`);
console.log(` Network: ${INPUT_NEURONS} -> ${opts.hidden} -> ${OUTPUT_NEURONS}`);
console.log(` Synapses: ${INPUT_NEURONS * opts.hidden + opts.hidden * OUTPUT_NEURONS}`);
console.log(` STDP: ${opts.learn ? `ON (lr=${opts.learningRate})` : 'OFF (frozen)'}`);
console.log(` Lateral inhibition: ON (strength=15.0)`);
console.log(` Listening on UDP port ${opts.port}...`);
console.log('');
const snn = createCSISnn(opts);
const deltaTracker = new DeltaTracker(INPUT_NEURONS);
const smoother = new OutputSmoother(OUTPUT_NEURONS, 0.3);
let frameCount = 0;
let totalSpikes = 0;
const SIM_STEPS_PER_FRAME = 5; // Run 5ms of SNN simulation per CSI frame
// Optional: Cognitum Seed forwarding socket
let seedSocket = null;
if (opts.sendVectors) {
seedSocket = dgram.createSocket('udp4');
console.log(` Forwarding spike vectors to ${opts.seedHost}:${opts.seedPort}`);
}
// UDP listener
const server = dgram.createSocket('udp4');
server.on('message', (msg, rinfo) => {
const frame = parseFrame(msg);
if (!frame) return;
frameCount++;
// Compute amplitude deltas
const deltas = deltaTracker.update(frame.amplitudes);
// Run SNN for multiple simulation steps per frame
let frameSpikes = 0;
const outputAccum = new Float32Array(OUTPUT_NEURONS);
for (let t = 0; t < SIM_STEPS_PER_FRAME; t++) {
// Rate-encode deltas as Poisson spikes
const inputSpikes = rateEncoding(deltas, 1.0, opts.maxRate);
// Step SNN (STDP learning happens inside if weights are not frozen)
frameSpikes += snn.step(inputSpikes);
// Accumulate output
const output = snn.getOutput();
for (let i = 0; i < OUTPUT_NEURONS; i++) {
outputAccum[i] += output[i];
}
}
totalSpikes += frameSpikes;
// Normalize accumulated output by simulation steps
for (let i = 0; i < OUTPUT_NEURONS; i++) {
outputAccum[i] /= SIM_STEPS_PER_FRAME;
}
// Smooth output
const smoothed = smoother.update(outputAccum);
// Get network stats
const netStats = snn.getStats();
const stats = {
totalSpikes: frameSpikes,
hiddenSpikes: [],
weightMean: 0,
weightMin: 0,
weightMax: 0,
};
// Extract hidden layer spike info if available
if (netStats.layers && netStats.layers.length > 1) {
const hiddenLayer = netStats.layers[1];
if (hiddenLayer.neurons) {
// Build a rough activity vector from spike counts
// The API gives aggregate counts, not per-neuron; approximate with output
stats.hiddenSpikes = new Array(opts.hidden).fill(0);
stats.hiddenSpikes[0] = hiddenLayer.neurons.spike_count > 0 ? 1 : 0;
}
if (netStats.layers[0] && netStats.layers[0].synapses) {
stats.weightMean = netStats.layers[0].synapses.mean;
stats.weightMin = netStats.layers[0].synapses.min;
stats.weightMax = netStats.layers[0].synapses.max;
}
}
// Visualization or JSON output
if (opts.quiet) {
const result = {
frame: frameCount,
timestamp: frame.timestamp,
nodeId: frame.nodeId,
channel: Math.round((frame.frequency - 2407) / 5),
subcarriers: frame.numSubcarriers,
rssi: frame.rssi,
spikes: frameSpikes,
output: {},
};
for (let i = 0; i < OUTPUT_NEURONS; i++) {
result.output[OUTPUT_LABELS[i]] = parseFloat(smoothed[i].toFixed(3));
}
console.log(JSON.stringify(result));
} else {
renderVisualization(smoothed, stats, frameCount, opts);
}
// Forward spike vector to Cognitum Seed
if (seedSocket) {
const vectorBuf = Buffer.alloc(4 + OUTPUT_NEURONS * 4); // 4-byte header + float32 array
vectorBuf.writeUInt16LE(0x534E, 0); // 'SN' magic
vectorBuf.writeUInt8(OUTPUT_NEURONS, 2);
vectorBuf.writeUInt8(frame.nodeId & 0xFF, 3);
for (let i = 0; i < OUTPUT_NEURONS; i++) {
vectorBuf.writeFloatLE(smoothed[i], 4 + i * 4);
}
seedSocket.send(vectorBuf, opts.seedPort, opts.seedHost);
}
});
server.on('error', (err) => {
console.error(`UDP error: ${err.message}`);
server.close();
process.exit(1);
});
server.bind(opts.port, () => {
console.log(`Listening for CSI frames on UDP port ${opts.port}`);
});
// Periodic weight decay (prevent drift) — every 1 second
if (opts.learn) {
setInterval(() => {
// Weight decay is applied implicitly by the SNN's w_min/w_max clamping
// and the balanced LTP/LTD rates. No additional decay needed for now.
// Future: iterate weights and multiply by 0.999 if drift is observed.
}, 1000);
}
// Periodic stats dump (every 10 seconds)
setInterval(() => {
if (opts.quiet) return;
const stats = snn.getStats();
const uptimeSec = Math.floor(process.uptime());
const fps = frameCount > 0 ? (frameCount / uptimeSec).toFixed(1) : '0.0';
process.stderr.write(
`[${uptimeSec}s] frames=${frameCount} fps=${fps} totalSpikes=${totalSpikes} ` +
`mem=${Math.round(process.memoryUsage().heapUsed / 1024)}KB\n`
);
}, 10000);
// Graceful shutdown
process.on('SIGINT', () => {
console.log('\n\nShutting down SNN-CSI Processor...');
const stats = snn.getStats();
console.log(` Total frames processed: ${frameCount}`);
console.log(` Total spikes: ${totalSpikes}`);
if (stats.layers && stats.layers[0] && stats.layers[0].synapses) {
const w = stats.layers[0].synapses;
console.log(` Final weights: mean=${w.mean.toFixed(3)} min=${w.min.toFixed(3)} max=${w.max.toFixed(3)}`);
}
server.close();
if (seedSocket) seedSocket.close();
process.exit(0);
});
}
main();