spaxel/scripts/run-sim-ble-match.sh
jedarden 58faa9ac4a docs(bf-1yr1w): record --ble opt-in (SIM_BLE=1) in run-sim-local + re-verify matcher PASS
bf-1yr1w criterion 3: the --ble opt-in must be recorded in the canonical
runtime script (not a fork). scripts/run-sim-local.sh now carries an
opt-in env passthrough:

  SIM_BLE=1 ./scripts/run-sim-local.sh   # appends --ble to the sim invocation
  ./scripts/run-sim-local.sh             # default: --ble OFF, CSI->blob path unchanged

Default-off preserves the script's existing gate (CSI->blob). The --ble +
fixture + matcher gate remains scripts/run-sim-ble-match.sh; only the
opt-in itself is added here. A matching "--ble opt-in (SIM_BLE=1)"
subsection is added to notes/hardware-free-runtime.md so the recipe is
recorded there, not only in the fixture note.

Also chmod +x scripts/run-sim-ble-match.sh: it was committed non-executable
(mode 100644), so its documented invocation (./scripts/run-sim-ble-match.sh)
failed with EACCES. Now 100755 like the other run-sim-*.sh scripts.

Re-verified PASS (fresh run, 2026-07-08): bash scripts/run-sim-ble-match.sh
-> exit 0, GetMatch() returns "Alice" for blob_id=7 (confidence=0.714,
is_ble_only=false) via /api/ble/matches; 0 rejects, no panic. This is the
evidence for criteria 1+2: the bf-21v71 wire fix (GetMatch() copied onto
the served slice) fires for the sim fixture and resolves the seeded person
onto a live CSI blob.

Hygiene (change is shell+md+mode only, zero Go source): go vet ./... clean;
go test ./internal/... ./cmd/... all ok. The containerized integration
packages (test/acceptance, tests/e2e) are unaffected and tests/e2e remains
deliberately RED per notes/hardware-free-runtime.md (gated on bf-4q5w /
bf-5jeo), not a regression of this shell+md change.

Co-Authored-By: Claude <noreply@anthropic.com>
Bead-Id: bf-1yr1w
2026-07-08 14:28:58 -04:00

263 lines
13 KiB
Bash
Executable file

#!/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