diff --git a/docs/adr/ADR-373-host-device-access-layer.md b/docs/adr/ADR-373-host-device-access-layer.md index c75bccc59..df8aa6ad8 100644 --- a/docs/adr/ADR-373-host-device-access-layer.md +++ b/docs/adr/ADR-373-host-device-access-layer.md @@ -175,6 +175,20 @@ scrubbed child env) and aborted on the firmware's U+2192 log character, so `bootLog.captured` was false and an earlier abort would have hidden the CSI evidence. It now writes raw bytes; a regression test forces cp1252. +Live RTL8721Dx re-run (MEASURED, board 4 on COM10, node 3, alongside the C6): +`devices` classified COM10 as `realtek`; a 20 s capture decoded 7,404/7,404 +packets (6,959 RAC1 frames at 348 Hz, RSSI −38.9 dBm, 200 RHB1 heartbeats); +`monitor --baud 1500000` saw 62 CSI log lines and did not reset the board +(sequence kept rising, 80,856 → 88,257). `--analyze --node-id 3` ran 12,549 +live frames at a measured 313.7 Hz through the WASM kernel (integrity +verified). The board also exposed a loss-accounting bug carried over from the +original code: one stray sequence value (+52,835, then straight back) was +counted as 52,834 lost frames, giving 97% "loss". The tracker now treats a +single out-of-window value as a stray, needs two agreeing frames to accept a +counter reset, and lets a late packet fill its own gap. Against ground truth +(unique sequence numbers over the span, 15 s): 4,783/4,783 present, true loss +0, 9 strays — the tracker reported loss 0 and 9 strays. + ## Consequences - One command per modality works across CLI, MCP and SDK, with actionable diff --git a/harness/ruview/.harness/manifest.json b/harness/ruview/.harness/manifest.json index b0fc82d76..6d788c4b7 100644 --- a/harness/ruview/.harness/manifest.json +++ b/harness/ruview/.harness/manifest.json @@ -44,7 +44,7 @@ "skills/train-pose.md": "8cb132e468392497377828f7974c744e4c877134ed9a87f960f77fa1221540ba", "skills/verify.md": "2d38d240e9810a7827e2ebd3717dc0f85c646cc92e46c3812fe77c5b9eb40b76", "src/brain.js": "0f16a75aea943acdacc430ff11d5df7ecdec9cca2ab497795ff6f33eaebdfab6", - "src/devices/esp32.js": "54e234f41d88a5032f473791e78cfc2fd2a34776af19c9ac4943c3a1e9be381e", + "src/devices/esp32.js": "59c445b9d36188405d075953941a0abf261ea3ee8c0523ffe71ac1d00fcccbff", "src/devices/lidar.js": "d753d88375358ced5174376753c276b5ca621f38ae48eac58dbb307b9004b629", "src/devices/mmwave.js": "09d828e3b3e140926194ab83bf27e7c63cbda1cf438385c82c0e89b9d8e4b526", "src/devices/registry.js": "26725e43adaa908b41462f492c5f6bf63e9193f3ee076e3d64ebaa012b66f6be", @@ -70,7 +70,7 @@ "src/tools.js": "cd72ac87e85e0bfbef7af11c98d03c4cb63a5bdd5da760460e9df8de9468bc34", "src/training.js": "b8451c48db69c7d077d75f07500996213beaa52705116f64afafe084e68e3956" }, - "filesDigest": "df2350f6a9989615e1978dfc57377edca97e794001ed23a3348ded984441d033", + "filesDigest": "b8c8a261e647535ccfafc54783fc25d7de961eab584b3365c9457f9cf9cfe46f", "brainDigest": "c0fb7b079ded157059b91601361429944697dae3cc42abc00dfe1a680986b0f4", "gateFingerprint": "6e53c784eee38310188948fc75fb49e6b4ebc04e247d01b903fa8c8a92d67bdd", "developmentPins": { diff --git a/harness/ruview/.harness/manifest.sha256 b/harness/ruview/.harness/manifest.sha256 index 1d25fe1db..f724e6c8c 100644 --- a/harness/ruview/.harness/manifest.sha256 +++ b/harness/ruview/.harness/manifest.sha256 @@ -1 +1 @@ -3dc0ee81f9b5ded15ed1016e7cc4e5ac31d4cb45064af154242d441e237ebde9 manifest.json +36990fd5c552e61b410ac8771d6a55769e8fcc9f695a1710b107a57e409a27de manifest.json diff --git a/harness/ruview/src/devices/esp32.js b/harness/ruview/src/devices/esp32.js index 8f6995111..8b8e8583b 100644 --- a/harness/ruview/src/devices/esp32.js +++ b/harness/ruview/src/devices/esp32.js @@ -160,6 +160,37 @@ export function parsePacket(buf, opts = {}) { const shapeKey = (pkt) => `${pkt.antennas}x${pkt.subcarriers}`; +/** Jumps larger than this are a counter reset or a stray frame, not loss. */ +export const SEQ_WINDOW = 1024; + +/** + * Loss accounting that survives UDP reordering and stray sequence values. + * A late packet fills the hole it left instead of being re-counted. A single + * out-of-window value is a stray (observed live on an RTL8721Dx: sporadic + * seq 0 and +52,835 frames between in-order ones) and leaves the baseline + * alone; two consecutive frames agreeing on a new range are a counter reset. + */ +function trackSequence(n, seq) { + if (n.lastSeq === null) { n.lastSeq = seq; return; } + const d = seq - n.lastSeq; + if (d >= 1 && d <= SEQ_WINDOW) { + n.seqGaps += d - 1; + n.lastSeq = seq; + n.pendingSeq = null; + } else if (d <= 0 && d > -SEQ_WINDOW) { + n.reordered += 1; // late or duplicate; a late packet fills one counted gap + if (d < 0 && n.seqGaps > 0) n.seqGaps -= 1; + } else if (n.pendingSeq != null && seq - n.pendingSeq >= 1 && seq - n.pendingSeq <= SEQ_WINDOW) { + n.strays -= 1; // the previous out-of-window frame started a new range + n.resyncs += 1; + n.lastSeq = seq; + n.pendingSeq = null; + } else { + n.strays += 1; + n.pendingSeq = seq; + } +} + /** Aggregate parsed packets into a per-node summary. */ export class NodeStats { constructor() { @@ -191,14 +222,13 @@ export class NodeStats { const key = `${pkt.source}:${pkt.nodeId ?? -1}`; let n = this.nodes.get(key); if (!n) { - n = { source: pkt.source, nodeId: pkt.nodeId ?? -1, packets: {}, rssiSum: 0, rssiCount: 0, lastSeq: null, seqGaps: 0, shapes: new Map(), vitals: null }; + n = { source: pkt.source, nodeId: pkt.nodeId ?? -1, packets: {}, rssiSum: 0, rssiCount: 0, lastSeq: null, pendingSeq: null, seqGaps: 0, reordered: 0, strays: 0, resyncs: 0, shapes: new Map(), vitals: null }; this.nodes.set(key, n); } n.packets[pkt.kind] = (n.packets[pkt.kind] || 0) + 1; if (pkt.kind === 'csi') { n.rssiSum += pkt.rssi; n.rssiCount += 1; - if (n.lastSeq !== null && pkt.seq > n.lastSeq + 1) n.seqGaps += pkt.seq - n.lastSeq - 1; - n.lastSeq = pkt.seq; + trackSequence(n, pkt.seq); const shape = shapeKey(pkt); const sh = n.shapes.get(shape) || { count: 0, ampSum: 0, last: null }; sh.count += 1; sh.ampSum += pkt.meanAmplitude; sh.last = pkt; @@ -231,6 +261,9 @@ export class NodeStats { packets: n.packets, csiRateHz: Number((csi / seconds).toFixed(2)), csiLossFraction: received ? Number((n.seqGaps / received).toFixed(4)) : null, + ...(n.reordered ? { seqReordered: n.reordered } : {}), + ...(n.strays ? { seqStrays: n.strays } : {}), + ...(n.resyncs ? { seqResyncs: n.resyncs } : {}), rssiMean: n.rssiCount ? Number((n.rssiSum / n.rssiCount).toFixed(1)) : null, csi: shapes.length ? describe(shapes[0]) : null, ...(shapes.length > 1 ? { csiShapes: shapes.map(describe) } : {}), diff --git a/harness/ruview/test/csi-nodes.test.mjs b/harness/ruview/test/csi-nodes.test.mjs index 3771fd27f..619d04878 100644 --- a/harness/ruview/test/csi-nodes.test.mjs +++ b/harness/ruview/test/csi-nodes.test.mjs @@ -114,6 +114,30 @@ test('mixed ESP32 + Realtek fleet: per-source nodes, shapes, loss, heartbeats', assert.equal(r.heartbeatOnlySenders, undefined, 'the sender also delivered CSI'); }); +test('loss survives reordering and stray sequence values (live RTL8721Dx pattern)', async () => { + // B+0..B+19 (B = 50,000, live counters are ~80k) with: B+5 missing, B+8 arriving after B+9, B+12 duplicated, a + // stray +52,835 frame after B+15, and a stray 0 after B+17 that hides a + // real loss (B+18) — both stray patterns were seen live on COM10. Then a genuine + // counter reset: 1, 2, 3. + const B = 50000; + const seqs = []; + for (let i = 0; i < 20; i++) { + if (i === 5 || i === 8 || i === 18) continue; + seqs.push(B + i); + if (i === 9) seqs.push(B + 8); + if (i === 12) seqs.push(B + 12); + if (i === 15) seqs.push(B + 15 + 52835); + if (i === 17) seqs.push(0); + } + seqs.push(1, 2, 3); + const r = await capture({ max_packets: seqs.length }, seqs.map((seq) => rac1({ seq }))); + const n = r.nodes[0]; + assert.equal(n.packets.csi, seqs.length); + assert.deepEqual([n.seqReordered, n.seqStrays, n.seqResyncs], [2, 2, 1]); + // B+5 and B+18 are truly lost: 2 gaps over csi + gaps slots. + assert.equal(n.csiLossFraction, Number((2 / (seqs.length + 2)).toFixed(4))); +}); + test('heartbeat-only and undecodable streams fail instead of claiming MEASURED', async () => { const hb = await capture({ max_packets: 3 }, [rhb1(), rhb1(), rhb1()]); assert.deepEqual([hb.ok, hb.reason, hb.evidence, hb.heartbeats], [false, 'heartbeat_only', null, 3]);