mirror of
https://github.com/ruvnet/RuView
synced 2026-06-09 10:13:17 +00:00
2783f40bd1
* research(R9): RSSI fingerprint K-NN — 2.18x lift (MODERATE); surfaces counting-vs-localization asymmetry Hypothesis: if temporal proximity correlates with RSSI-feature proximity in the existing single-session data, RSSI fingerprinting is viable. If K-NN of each query is random in time, RSSI sequences are too noisy for fingerprint localization. Test: 1077 samples, 20-dim RSSI proxy (band-mean across 56 subcarriers), cosine-NN with K=5, measure fraction of K-NN within plus/minus 60s of each query timestamp. Compare to random baseline. Result (honest): 5-NN within +/-60s 0.169 Random baseline 0.077 Lift over random 2.18x (verdict: MODERATE) Per-query stdev 0.183 Below the >=3x STRONG-fingerprint threshold but well above 1x random. Real signal, but weaker than R8 counting result on the same data. Important asymmetry surfaced (publishable distinction): Task RSSI vs CSI retention Verdict ------- ----- ----- Counting 94.82% (R8) RSSI works well Localization ~2x random (R9) RSSI struggles in this regime This is consistent with R5's band-spread observation: the count signal integrates across the band, but localization may require per-subcarrier shape that the band-mean discards. Three actionable explanations for the MODERATE result: 1. 20-frame windows (~2s) too short for stable fingerprint while operator moves — longer windows might lift to 3-4x. 2. Within-room fingerprint space too narrow — multi-room data would show categorical lift jump (5-10x). 3. Band-mean discards the per-subcarrier shape needed for localization. Once multi-room data lands (#645), this test should be re-run; if hypothesis (2) is right, the lift will jump categorically. Files: * examples/research-sota/r9_rssi_fingerprint_knn.py * examples/research-sota/r9_rssi_fingerprint_results.json * docs/research/sota-2026-05-22/R9-rssi-fingerprint-knn.md * docs/research/sota-2026-05-22/PROGRESS.md updated * feat(tools/ruview-mcp): M2 — wire real inference via cog health subcommand ruview_pose_infer and ruview_count_infer now run the cog binary's `health` subcommand (ADR-100 contract) which performs real Candle forward-pass inference on a synthetic CSI window and emits a structured health.ok JSON event containing backend, confidence (pose) or count/confidence/p95_range (count). The MCP tools parse this event and return typed inference results. This satisfies the ADR-104 acceptance gate: "ruview_pose_infer returns a finite output for a synthetic CSI window" when the cog binary is installed. On machines without the binary, both tools still fail-open with {ok:false, warn:true} and actionable install hints. Also updates PROGRESS.md with cross-links: R7 (Stoer-Wagner) and R8 (RSSI-only 94.82% retained) marked done with cron-originated findings distilled into the research vectors section. Co-Authored-By: claude-flow <ruv@ruv.net>
144 lines
5.5 KiB
Python
144 lines
5.5 KiB
Python
#!/usr/bin/env python3
|
||
"""R9 — RSSI fingerprint topology: does temporal proximity = feature proximity?
|
||
|
||
See docs/research/sota-2026-05-22/R9-rssi-fingerprint-knn.md.
|
||
|
||
Hypothesis: if RSSI sequences from temporally-adjacent windows are
|
||
nearest-neighbours in feature space, RSSI-fingerprint localisation is
|
||
viable. If the K-NN of every query is random in time, RSSI sequences
|
||
don't carry stable enough fingerprints — fall back to multi-modal cues
|
||
(BSSID lists, signal-of-opportunity).
|
||
|
||
Test:
|
||
1. Build the same 20-dim RSSI proxy from the 1,077 paired windows
|
||
(band-mean across 56 subcarriers per frame).
|
||
2. For each sample i, find K-NN in cosine-similarity space.
|
||
3. Measure: what fraction of the K-NN come from windows within
|
||
±60 seconds of the query's timestamp?
|
||
4. Compare to a random baseline (what would the fraction be if K-NN
|
||
were chosen at random?).
|
||
|
||
If the temporal-K-NN fraction is ≫ random, RSSI fingerprints have stable
|
||
spatial structure → R9 viable.
|
||
|
||
Usage:
|
||
python examples/research-sota/r9_rssi_fingerprint_knn.py \
|
||
--paired data/paired/wiflow-p7-1779210883.paired.jsonl
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import json
|
||
from datetime import datetime, timezone
|
||
from pathlib import Path
|
||
|
||
import numpy as np
|
||
|
||
N_SUB, N_FRAMES = 56, 20
|
||
|
||
|
||
def load_rssi_proxy(path: Path) -> tuple[np.ndarray, np.ndarray]:
|
||
"""Return (X_rssi, ts_seconds). X_rssi is [N, 20], ts is [N] float seconds."""
|
||
csis, ts = [], []
|
||
with path.open(encoding="utf-8") as f:
|
||
for line in f:
|
||
if not line.strip():
|
||
continue
|
||
d = json.loads(line)
|
||
shape = d.get("csi_shape", [N_SUB, N_FRAMES])
|
||
if shape != [N_SUB, N_FRAMES]:
|
||
continue
|
||
csi = np.asarray(d["csi"], dtype=np.float32).reshape(N_SUB, N_FRAMES)
|
||
csis.append(csi.mean(axis=0)) # band-mean → [20]
|
||
t_iso = d.get("ts_start", "1970-01-01T00:00:00Z")
|
||
ts.append(datetime.fromisoformat(t_iso.replace("Z", "+00:00")).timestamp())
|
||
return np.stack(csis), np.asarray(ts, dtype=np.float64)
|
||
|
||
|
||
def main():
|
||
parser = argparse.ArgumentParser()
|
||
parser.add_argument("--paired", required=True)
|
||
parser.add_argument("--out", default="examples/research-sota/r9_rssi_fingerprint_results.json")
|
||
parser.add_argument("--k", type=int, default=5)
|
||
parser.add_argument("--temporal-window-s", type=float, default=60.0)
|
||
args = parser.parse_args()
|
||
|
||
print(f"Loading RSSI-proxy from {args.paired}")
|
||
X, ts = load_rssi_proxy(Path(args.paired))
|
||
print(f" N samples: {X.shape[0]}, feature dim: {X.shape[1]}")
|
||
print(f" time range: {datetime.fromtimestamp(ts.min(), tz=timezone.utc):%H:%M:%S} - "
|
||
f"{datetime.fromtimestamp(ts.max(), tz=timezone.utc):%H:%M:%S} "
|
||
f"({(ts.max() - ts.min()) / 60:.1f} min total)")
|
||
|
||
# Z-score normalise across all samples — what a real device does via AGC
|
||
mu = X.mean(axis=0, keepdims=True)
|
||
sd = X.std(axis=0, keepdims=True) + 1e-6
|
||
Xn = (X - mu) / sd
|
||
|
||
# All-pairs cosine similarity
|
||
print(f"\nComputing all-pairs cosine similarity ({X.shape[0]}×{X.shape[0]} = "
|
||
f"{X.shape[0]**2:,} pairs)...")
|
||
norms = np.linalg.norm(Xn, axis=1, keepdims=True) + 1e-9
|
||
Xnorm = Xn / norms
|
||
sim = Xnorm @ Xnorm.T
|
||
np.fill_diagonal(sim, -np.inf) # exclude self-match
|
||
|
||
N = X.shape[0]
|
||
K = args.k
|
||
W = args.temporal_window_s
|
||
|
||
# For each query, find top-K nearest neighbours and measure how many are
|
||
# within the temporal window
|
||
print(f"\nMeasuring temporal-locality of top-{K} cosine-NN with window ±{W:.0f}s...")
|
||
knn_idx = np.argsort(-sim, axis=1)[:, :K] # [N, K]
|
||
knn_ts = ts[knn_idx] # [N, K]
|
||
delta_t = np.abs(knn_ts - ts[:, None]) # [N, K]
|
||
within = (delta_t <= W).astype(np.float32) # [N, K]
|
||
per_query_within_frac = within.mean(axis=1) # [N] — fraction of K-NN within window
|
||
overall_within_frac = within.mean() # scalar
|
||
|
||
# Random baseline: for each query, what fraction of all OTHER samples
|
||
# fall within ±W of its timestamp?
|
||
rand_within = np.zeros(N, dtype=np.float32)
|
||
for i in range(N):
|
||
delta = np.abs(ts - ts[i])
|
||
delta[i] = np.inf
|
||
rand_within[i] = (delta <= W).mean()
|
||
rand_baseline = float(rand_within.mean())
|
||
|
||
# Headline numbers
|
||
lift = overall_within_frac / max(rand_baseline, 1e-9)
|
||
|
||
print(f"\n=== R9 RSSI-fingerprint K-NN results ===")
|
||
print(f" K-NN within ±{W:.0f}s: {overall_within_frac:.3f}")
|
||
print(f" Random baseline: {rand_baseline:.3f}")
|
||
print(f" Lift over random: {lift:.2f}×")
|
||
print(f" Per-query stdev: {per_query_within_frac.std():.3f}")
|
||
|
||
if lift >= 3.0:
|
||
verdict = "STRONG: RSSI sequences carry stable spatial fingerprints"
|
||
elif lift >= 1.5:
|
||
verdict = "MODERATE: RSSI fingerprints work but with significant noise"
|
||
else:
|
||
verdict = "WEAK: RSSI-only fingerprint localisation is unreliable on this data"
|
||
print(f"\n Verdict: {verdict}")
|
||
|
||
out = {
|
||
"n_samples": int(N),
|
||
"k": K,
|
||
"temporal_window_s": W,
|
||
"knn_within_window_fraction": float(overall_within_frac),
|
||
"random_baseline": rand_baseline,
|
||
"lift": float(lift),
|
||
"per_query_within_fraction_stdev": float(per_query_within_frac.std()),
|
||
"verdict": verdict,
|
||
}
|
||
Path(args.out).parent.mkdir(parents=True, exist_ok=True)
|
||
Path(args.out).write_text(json.dumps(out, indent=2))
|
||
print(f"\nWrote {args.out}")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|