docs(bf-12h75): document --ble matcher recipe in hardware-free-runtime.md

Add a "BLE identity matcher (--ble)" subsection to
notes/hardware-free-runtime.md capturing the working --ble opt-in recipe
that scripts/run-sim-ble-match.sh exercises, so the parent capstone
(bf-2m534 / scripts/run-sim-identity.sh) reuses rather than re-derives.
Closes the bf-1yr1w acceptance gap: the --ble opt-in was documented only
for the FIXTURE (notes/ble-identity-fixture.md), not the matcher script.

Records: the script + the sim --ble invocation it runs; env params; the
key gotcha (SIM_WALKERS=1, NOT run-sim-local's 2 -- with two walkers the
nearest CSI blob is the second walker's, ~1.73m off, killing matchConf
below the 0.60 gate); the /api/ble/matches observation surface
(identityMatcher.GetAllMatches); and PASS evidence captured at HEAD
(blob_id=7, person_name=Alice, confidence=0.714, is_ble_only=false).
Links ble-identity-fixture.md for the fixture half rather than duplicating.

Also commits scripts/run-sim-ble-match.sh (child-2 artifact, was untracked)
so the recipe is reproducible at HEAD.

Verified at HEAD 7757149: bash scripts/run-sim-ble-match.sh -> PASS
(exit 0, blob_id=7 conf=0.714); go vet ./mothership/... clean.

Co-Authored-By: Claude <noreply@anthropic.com>
Bead-Id: bf-12h75
This commit is contained in:
jedarden 2026-07-08 14:10:50 -04:00
parent 7757149440
commit c42989e608
2 changed files with 339 additions and 1 deletions

View file

@ -1,7 +1,7 @@
# Hardware-Free Runtime Reproduce
**Canonical script:** `scripts/run-sim-local.sh`
**Verified:** 2026-07-08 — `5bbe52c` (post bf-243os sim→blob path fix) and re-verified at HEAD `f859f3e` for bf-40hc acceptance.
**Verified:** 2026-07-08 — `5bbe52c` (post bf-243os sim→blob path fix), re-verified at HEAD `f859f3e` for bf-40hc acceptance, and final-clean-rebuild re-verified at HEAD `c927f67` (the bf-40hc doc commit itself).
**Resolves:** the ambiguity that caused bf-40hc's repeated failures; closes the chain bf-3zll → bf-3hji → bf-51fq → bf-5cr3, and **satisfies bf-40hc** (hardware-free runtime path that creates a blob).
This is the single deterministic artifact that captures the working end-to-end
@ -176,6 +176,81 @@ deterministically proves.
---
## BLE identity matcher (--ble) — bf-1yr1w
`run-sim-local.sh` proves the CSI→blob path. **`scripts/run-sim-ble-match.sh`** layers
the `--ble` opt-in on top and asserts the identity **matcher** (`ble.IdentityMatcher`)
resolves a seeded person onto a *live CSI blob* — the second link of the bf-2m534 identity
chain. The first link (register a person + bind the sim's advertised device) is the
**fixture**, documented in **[`notes/ble-identity-fixture.md`](ble-identity-fixture.md)** —
link to it, do not duplicate it. The matcher script reuses that exact REST sequence
internally.
### The recipe
```bash
./scripts/run-sim-ble-match.sh
# builds mothership + canonical sim (mothership/cmd/sim — has --ble; never repo-root cmd/sim),
# starts a fresh-data-dir mothership, applies the fixture (register person "Alice" +
# bind sim walker-0 addr AA:BB:CC:DD:EE:00), then runs:
# sim --ble --nodes 4 --walkers 1 --rate 30 --space 5x5x2.5 --duration 25 --seed 42
# polls GET /api/ble/matches and exits 0 once GetMatch() returns the seeded person
# attached to a REAL blob (blob_id != -1).
```
### Params (why these, not run-sim-local's)
| Env | Value | Note |
|-----|-------|------|
| `SIM_WALKERS` | **`1`** | NOT the `2` from `run-sim-local.sh` — see gotcha below |
| `SIM_RATE` | `30` | same as gotcha #2; deterministic CSI blobs the matcher needs |
| `SIM_DURATION` | `25` | same as run-sim-local |
| `SIM_SEED` | `42` | pins walker paths; walker 0 always advertises `AA:BB:CC:DD:EE:00` |
| `SIM_NODES` | `4` | multi-node RSSI → triangulation clears MinMatchConfidence |
| `SIM_SPACE` | `5x5x2.5` | default room geometry |
| `SIM_PORT` | `8088` | loopback; avoids 8080 |
| `SIM_WALKER_MAC` | `AA:BB:CC:DD:EE:00` | sim walker-0 convention (see fixture note) |
| `SIM_PERSON_NAME` / `SIM_PERSON_COLOR` | `Alice` / `#4488ff` | the seeded identity |
### Gotcha — `walkers=1`, not `2`
The fixture registers **exactly one** person + device, so the sim must emit exactly one
walker (Alice's). With `walkers=2` (run-sim-local's blob-stress default) the CSI blob
nearest Alice's triangulated point was the **second** walker's (cornered away from her),
leaving a ~1.73 m gap so `f_distance` killed `matchConf` (0.143 < gate 0.60) diagnosis
bf-6crd7 / bf-6d2ii. With one walker the blob is Alice's own, co-located at ~0.6 m →
`matchConf ≈ 0.71`. This is **not** a threshold loosening: `MaxBLEBlobDistance=2.0` and
`MinMatchConfidence=0.6` are unchanged.
### Observation surface
`GET /api/ble/matches``identityMatcher.GetAllMatches()` (`cmd/mothership/main.go:~3070`).
It returns **both** real blob matches (`blob_id >= 0`) and BLE-only placeholder tracks
(`blob_id == -1`: triangulated device with no nearby blob). A PASS record is a real blob
match — `blob_id != -1` AND `person_name == "Alice"` AND `confidence >= 0.6`:
```json
{"blob_id":7,"person_name":"Alice","device_addr":"AA:BB:CC:DD:EE:00",
"confidence":0.714,"is_ble_only":false}
```
### PASS evidence (HEAD `7757149`, 2026-07-08)
```
[match] peak_blobs (via /api/blobs): 2
[match] alice_blob_match: blob_id=7 conf=0.7139527093753523 device=AA:BB:CC:DD:EE:00 (at 6s)
[match] final /api/ble/matches summary:
[{"blob_id":7,"person_name":"Alice","device_addr":"AA:BB:CC:DD:EE:00",
"confidence":0.7139527093753523,"is_ble_only":false}]
[match] PASS: GetMatch() returns "Alice" for blob_id=7 (conf=0.714) via /api/ble/matches
```
Closes bf-1yr1w criterion 3: the `--ble` matcher recipe is now recorded here, not only in
the fixture note. The matcher → `/api/blobs` identity capstone is **bf-2m534**
(`scripts/run-sim-identity.sh`), which depends on this PASS — keep it green.
---
## For later beads (bf-f841 and beyond)
- **Reuse, don't re-derive.** Start from `scripts/run-sim-local.sh`. If you need a

View file

@ -0,0 +1,263 @@
#!/usr/bin/env bash
#
# run-sim-ble-match.sh — the BLE identity MATCHER check (bf-1yr1w).
#
# This is the SECOND link of the bf-2m534 identity chain. The first link
# (bf-14wx5, scripts/run-sim-ble-fixture.sh) established the fixture: a registered
# person + the sim's advertised device bound to that person, verified only via
# GET /api/ble/devices (the device<->person binding). That binding is NECESSARY but
# not SUFFICIENT for identity on a blob — the matcher (ble.IdentityMatcher) still
# has to (a) triangulate the device from live RSSI, (b) land it within 2m of a CSI
# blob, and (c) clear MinMatchConfidence (0.6). This script drives exactly that and
# asserts the matcher's GetMatch() returns the seeded person for at least one blob.
#
# Observation surface: GET /api/ble/matches returns identityMatcher.GetAllMatches()
# (cmd/mothership/main.go:~3070) — both real blob matches (blob_id >= 0) and BLE-only
# placeholder tracks (blob_id == -1). A record with person_name == "Alice" and
# blob_id != -1 is direct evidence GetMatch(blobID) returns the seeded person.
#
# It also wires the two preconditions the chain named:
# 1. The fixture (reuse run-sim-ble-fixture.sh's REST sequence): register person
# "Alice" + bind the sim's advertised AA:BB:CC:DD:EE:00 (walker 0).
# 2. The rssi_dbm key in sim BLE ads (mothership/cmd/sim/main.go:sendBLEMessages):
# emitting "rssi" left RSSIdBm at zero, collapsing triangulation. The sim is
# built from the canonical mothership/cmd/sim source which carries this fix.
#
# Scope note: this asserts the MATCHER produces a match. It deliberately does NOT
# gate on /api/blobs identity fields — that is the THIRD link / parent capstone
# (bf-2m534, scripts/run-sim-identity.sh), which depends on this bead's PASS.
#
# No hardware, no Docker, no manual IP, no manual token. Reuses the build + health
# path proven by run-sim-local.sh / run-sim-ble-fixture.sh.
#
# Usage: ./scripts/run-sim-ble-match.sh
# Env: SIM_NODES, SIM_WALKERS, SIM_RATE, SIM_DURATION, SIM_SEED, SIM_SPACE,
# SIM_PORT, SIM_WALKER_MAC, SIM_PERSON_NAME, SIM_PERSON_COLOR,
# SPAXEL_MOTHERSHIP_BIN, SPAXEL_SIM_BIN
# Exit: 0 if GetMatch() returns the seeded person for >=1 blob (real blob match,
# not BLE-only); 1 otherwise.
#
# Requires: go, curl, jq.
set -uo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
MS="${SPAXEL_MOTHERSHIP_BIN:-/tmp/spaxel-mothership}"
SIM="${SPAXEL_SIM_BIN:-/tmp/spaxel-sim}"
PORT="${SIM_PORT:-8088}"
# walkers=1: this fixture registers exactly ONE person (Alice) + ONE device
# (AA:BB:CC:DD:EE:00), so the sim emits exactly ONE walker — Alice's. The earlier
# default of 2 (inherited from run-sim-local.sh's blob-emission stress params) is a
# multi-person confound this single-person identity test does not model: with two
# walkers the CSI blob nearest Alice's triangulated point was the SECOND walker's
# (cornered away from Alice), leaving a ~1.73m gap so f_distance killed matchConf
# (0.143 < gate 0.60) — diagnosis bf-6crd7/bf-6d2ii. With one walker the blob is
# Alice's own, co-located at ~0.6m gap → matchConf ≈ 0.714 ≥ 0.60. NOT a threshold
# loosening: MaxBLEBlobDistance=2.0 and MinMatchConfidence=0.6 are unchanged. The
# rate (30) stays — REQUIRED for deterministic CSI blobs (hardware-free-runtime.md
# gotcha #2); the matcher needs a live blob near the device to match.
SIM_NODES="${SIM_NODES:-4}"
SIM_WALKERS="${SIM_WALKERS:-1}"
SIM_RATE="${SIM_RATE:-30}"
SIM_DURATION="${SIM_DURATION:-25}"
SIM_SEED="${SIM_SEED:-42}"
SIM_SPACE="${SIM_SPACE:-5x5x2.5}"
# Walker 0 -> BLE addr AA:BB:CC:DD:EE:00 (cmd/sim/main.go: AA:BB:CC:DD:EE:%02X, IDs from 0).
WALKER_MAC="${SIM_WALKER_MAC:-AA:BB:CC:DD:EE:00}"
PERSON_NAME="${SIM_PERSON_NAME:-Alice}"
PERSON_COLOR="${SIM_PERSON_COLOR:-#4488ff}"
log() { printf '[match] %s\n' "$*"; }
die() { printf '[match] ERROR: %s\n' "$*" >&2; exit 1; }
for dep in go curl jq; do
command -v "$dep" >/dev/null 2>&1 || die "missing required dependency: $dep"
done
# --- Build both binaries from source (canonical sim has --ble; force if stale) -----
log "building canonical binaries from source (reuse if present)..."
need_build=0
[ -x "$MS" ] || need_build=1
[ -x "$SIM" ] || need_build=1
if [ -x "$SIM" ] && ! "$SIM" --help 2>&1 | grep -q -- '--ble'; then
log "existing $SIM lacks --ble (stale/standalone build); rebuilding..."
rm -f "$SIM"; need_build=1
fi
if [ "$need_build" -eq 1 ]; then
( cd "$ROOT/mothership" || exit 1
go build -o "$MS" ./cmd/mothership || die "building mothership"
go build -o "$SIM" ./cmd/sim || die "building sim"
) || exit 1
fi
DATA_DIR=$(mktemp -d -t spaxel-match-data-XXXXXX)
MS_PID=""
SIM_PID=""
cleanup() {
[ -n "$SIM_PID" ] && kill "$SIM_PID" 2>/dev/null || true
[ -n "$MS_PID" ] && kill -INT "$MS_PID" 2>/dev/null || true
sleep 1
[ -n "$MS_PID" ] && kill -9 "$MS_PID" 2>/dev/null || true
wait 2>/dev/null || true
rm -rf "$DATA_DIR"
}
trap cleanup EXIT INT TERM
log "mothership: $MS"
log "sim: $SIM"
log "fixture: $WALKER_MAC -> person \"$PERSON_NAME\" ($PERSON_COLOR)"
log "params: nodes=$SIM_NODES walkers=$SIM_WALKERS rate=$SIM_RATE duration=${SIM_DURATION}s seed=$SIM_SEED space=$SIM_SPACE port=$PORT"
# --- Start mothership on an ephemeral data dir (identical env to run-sim-local) ----
# SPAXEL_LOG_LEVEL=info surfaces matcher/fleet INFO lines for evidence capture.
SPAXEL_BIND_ADDR="127.0.0.1:$PORT" \
SPAXEL_DATA_DIR="$DATA_DIR" \
SPAXEL_LOG_LEVEL=info \
SPAXEL_MDNS_ENABLED=false \
TZ=UTC \
"$MS" > "$DATA_DIR/mothership.log" 2>&1 &
MS_PID=$!
ok=""
for _ in $(seq 1 50); do
if resp=$(curl -s --max-time 2 "http://localhost:$PORT/healthz" 2>/dev/null) && \
[ "$(printf '%s' "$resp" | jq -r '.status // empty' 2>/dev/null)" = "ok" ]; then
ok=1; break
fi
sleep 0.3
done
[ -n "$ok" ] || { log "mothership never became healthy"; tail -30 "$DATA_DIR/mothership.log"; exit 1; }
log "mothership healthy (port $PORT)"
# --- Apply the child-1 fixture BEFORE starting the sim ----------------------------
# Register the person + bind the sim's advertised device addr, exactly per
# notes/ble-identity-fixture.md. Done while the mothership is up but the sim is not
# yet streaming, so the device is bound standalone (PreregisterDevice sets
# last_seen_at=now). The binding then survives the live --ble advertisements.
seed_fixture() {
curl -s -X POST "http://localhost:$PORT/api/ble/devices/preregister" \
-H 'Content-Type: application/json' \
-d "{\"mac\":\"$WALKER_MAC\",\"label\":\"$PERSON_NAME\"}" >/dev/null
person_id=$(curl -s -X POST "http://localhost:$PORT/api/people" \
-H 'Content-Type: application/json' \
-d "{\"name\":\"$PERSON_NAME\",\"color\":\"$PERSON_COLOR\"}" \
| jq -r '.id // empty' 2>/dev/null)
[ -n "$person_id" ] || die "could not create person \"$PERSON_NAME\" via /api/people"
curl -s -X PUT "http://localhost:$PORT/api/ble/devices/$WALKER_MAC" \
-H 'Content-Type: application/json' \
-d "{\"person_id\":\"$person_id\",\"label\":\"$PERSON_NAME\"}" >/dev/null
SEED_PERSON_ID="$person_id"
# Sanity: confirm the binding took (reuses the child-1 gate).
rec=$(curl -s --max-time 2 "http://localhost:$PORT/api/ble/devices?registered=true" \
| jq -c --arg mac "$WALKER_MAC" '[.devices[]|select(.mac==$mac)][0]' 2>/dev/null)
pid=$(printf '%s' "$rec" | jq -r '.person_id // empty' 2>/dev/null)
[ -n "$pid" ] || { log "fixture FAILED: $WALKER_MAC not bound (child-1 regression)"; exit 1; }
}
log "applying child-1 fixture (register \"$PERSON_NAME\" + bind $WALKER_MAC)..."
SEED_PERSON_ID=""
seed_fixture
log "fixture bound: $WALKER_MAC -> \"$PERSON_NAME\" (id=$SEED_PERSON_ID)"
# --- Start the sim with --ble (canonical blob-emission params from run-sim-local) --
"$SIM" \
--mothership "ws://localhost:$PORT/ws/node" \
--nodes "$SIM_NODES" --walkers "$SIM_WALKERS" --rate "$SIM_RATE" \
--space "$SIM_SPACE" \
--duration "$SIM_DURATION" --seed "$SIM_SEED" --ble \
> "$DATA_DIR/sim.log" 2>&1 &
SIM_PID=$!
log "sim --ble started (pid $SIM_PID)"
# --- Poll /api/ble/matches for a REAL blob match for the seeded person -----------
# GET /api/ble/matches -> identityMatcher.GetAllMatches(). A real blob match has
# blob_id != -1 (BLE-only placeholder tracks use blob_id == -1). We want a match
# whose person_name == "Alice" attached to a live CSI blob — that is GetMatch()
# returning the seeded person. We also record peak blob count and whether a BLE-only
# track appeared (diagnostic: BLE-only-with-no-blob-match narrows a miss to the
# distance/confidence gate, not the triangulation step).
peak_blobs=0
match_blob_id=""
match_conf=""
match_device=""
got_bleonly=0
first_match_t=""
for t in $(seq 1 "$SIM_DURATION"); do
sleep 1
nblobs=$(curl -s --max-time 2 "http://localhost:$PORT/api/blobs" 2>/dev/null | jq 'length' 2>/dev/null || echo 0)
[ "$nblobs" -gt "$peak_blobs" ] && peak_blobs=$nblobs
matches_json=$(curl -s --max-time 2 "http://localhost:$PORT/api/ble/matches" 2>/dev/null)
# Real blob match for the seeded person: blob_id != -1 AND person_name == Alice.
rec=$(printf '%s' "$matches_json" \
| jq -c --arg name "$PERSON_NAME" \
'[.[] | select((.blob_id // -1) != -1 and .person_name == $name)][0]' 2>/dev/null)
if [ "$rec" != "null" ] && [ -n "$rec" ]; then
match_blob_id=$(printf '%s' "$rec" | jq -r '.blob_id')
match_conf=$(printf '%s' "$rec" | jq -r '.confidence')
match_device=$(printf '%s' "$rec" | jq -r '.device_addr')
first_match_t="${t}s"
break
fi
# Diagnostic: did a BLE-only track (triangulated device, no nearby blob) appear?
bo=$(printf '%s' "$matches_json" \
| jq -c --arg name "$PERSON_NAME" \
'[.[] | select((.blob_id // -1) == -1 and .person_name == $name)][0]' 2>/dev/null)
[ "$bo" != "null" ] && [ -n "$bo" ] && got_bleonly=1
printf ' t=%2ds blobs=%s peak_blobs=%s alice_blob_match=no%s\n' \
"$t" "$nblobs" "$peak_blobs" "$([ "$got_bleonly" = 1 ] && echo ' (alice BLE-only track seen)')"
done
# Stop the sim so the run is bounded, then tally diagnostics.
kill "$SIM_PID" 2>/dev/null || true
wait "$SIM_PID" 2>/dev/null || true
SIM_PID=""
reject_count=$(grep -ciE 'reject|invalid_token|\b401\b|\b403\b' "$DATA_DIR/sim.log" 2>/dev/null)
# Final matcher state snapshot (may still carry a persistent match after sim stop).
final_matches=$(curl -s --max-time 2 "http://localhost:$PORT/api/ble/matches" 2>/dev/null)
final_summary=$(printf '%s' "$final_matches" \
| jq -c '[.[] | {blob_id, person_name, device_addr, confidence, is_ble_only}]' 2>/dev/null)
echo ""
log "---- RESULT ----"
log "seeded person: \"$PERSON_NAME\" (id=$SEED_PERSON_ID) device: $WALKER_MAC"
log "peak_blobs (via /api/blobs): $peak_blobs"
log "alice_blob_match: ${match_blob_id:+blob_id=$match_blob_id conf=$match_conf device=$match_device (at $first_match_t)}"
log "alice_blob_match: ${match_blob_id:-NONE}"
log "alice_bleonly_track_seen: $([ "$got_bleonly" = 1 ] && echo yes || echo no)"
log "reject_in_sim_log=${reject_count:-0}"
echo ""
log "final /api/ble/matches summary:"
printf '%s\n' "${final_summary:-[]}" | head -40
echo ""
log "mothership matcher-related log lines (INFO/WARN):"
grep -iE 'ble:|identity|triangulat|match' "$DATA_DIR/mothership.log" 2>/dev/null | tail -20 || log " (none)"
# --- Acceptance gate: GetMatch() returns the seeded person for >=1 real blob -------
if [ -n "$match_blob_id" ]; then
log "PASS: GetMatch() returns \"$PERSON_NAME\" for blob_id=$match_blob_id (conf=$match_conf) via /api/ble/matches"
log " — matcher triangulated $match_device within range of a CSI blob and cleared MinMatchConfidence."
exit 0
fi
# Explicit failure with evidence (acceptance criterion 4): never leave a miss silent.
log "FAIL: no real blob match for \"$PERSON_NAME\" via /api/ble/matches over the ${SIM_DURATION}s window."
log " peak_blobs=$peak_blobs (so CSI blobs WERE present), alice_bleonly_track=$got_bleonly"
if [ "$peak_blobs" -le 0 ]; then
log " -> NO CSI blob at all: the prerequisite blob path (run-sim-local) regressed. Re-run at SIM_RATE=30 first."
elif [ "$got_bleonly" = 1 ]; then
log " -> A BLE-only track appeared (device WAS triangulated + has a person) but never attached to a blob:"
log " the matcher's distance (<2m, X/Z plane) or confidence (>=0.6) gate rejected the blob assignment."
log " Likely cause: the sim RSSI model (-50-20log10(d)) differs from the matcher's rssiToDistance"
log " (ref -65 dBm @ 1m, n=2.5), so triangulation lands the device off the walker -> off the CSI blob."
else
log " -> No triangulated device for \"$PERSON_NAME\" appeared at all: triangulation produced <0.1 confidence"
log " (too few nodes / node positions unset / RSSI not landing). Check sim rssi_dbm + node positions."
fi
exit 1