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
13 KiB
Hardware-Free Runtime Reproduce
Canonical script: scripts/run-sim-local.sh
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 runtime path — sim connects → CSI ingested → phase/fusion → tracked blob — with zero hardware, zero Docker, zero manual IP, and zero manual token. Later beads (e.g. the blob-identity runtime checks under bf-f841) and reruns should start from here instead of re-deriving the command sequence or the tuning.
TL;DR
./scripts/run-sim-local.sh
# expects, within ~30s:
# [run-sim] mothership healthy
# t= 1s nodes_online=4 blobs=2 peak=2
# ...
# [run-sim] peak_blob_count=2
# [run-sim] PASS: >=1 blob via /api/blobs (peak=2) AND sim --verify passed
Exit 0 iff ≥1 tracked blob is observed via GET /api/blobs over the run window.
What the script does (the exact reproduce sequence)
- Build mothership from source —
cd mothership && go build -o /tmp/spaxel-mothership ./cmd/mothership - Build the canonical sim from source —
cd mothership && go build -o /tmp/spaxel-sim ./cmd/sim - Start the mothership on an ephemeral port + data dir:
SPAXEL_BIND_ADDR=127.0.0.1:8088 \ SPAXEL_DATA_DIR=$(mktemp -d) \ SPAXEL_LOG_LEVEL=warn \ SPAXEL_MDNS_ENABLED=false \ TZ=UTC \ /tmp/spaxel-mothership & - Wait for health — poll
GET /healthzuntil{"status":"ok"}. - Run the sim with
--verify:/tmp/spaxel-sim \ --mothership ws://localhost:8088/ws/node \ --nodes 4 --walkers 2 --rate 30 --space 5x5x2.5 \ --duration 25 --seed 42 --verify - Observe — poll
GET /api/blobsonce per second for the whole streaming window, keep the peak, gate onpeak ≥ 1. (sim --verifyis a reported secondary cross-check that assertsblob_count == walkers ±1.)
Each step is in scripts/run-sim-local.sh with a trap cleanup that kills both
processes and removes the temp data dir.
The two gotchas (read these before touching the path)
1. There are TWO cmd/sim packages — only one has --verify
| Path | Module | Has --verify? |
Use it? |
|---|---|---|---|
mothership/cmd/sim/ |
part of the mothership module | YES (verify.go) |
YES — canonical |
cmd/sim/ |
separate, older module (own go.mod) |
no | NO |
scripts/run-sim-local.sh builds only from mothership/cmd/sim and
additionally guards against a stale binary by checking --help for --verify,
rebuilding if missing. Do not build/run cmd/sim for runtime blob checks.
2. rate=30, not the upstream default 20
At the upstream default --rate 20, fusion produces a peak only while a walker
moves enough to cross the DeltaRMS threshold, so a run can legitimately land
peak=2 or peak=0 nondeterministically (see the comment in
mothership/cmd/sim/verify.go). Bumping --rate to 30 reliably clears the
threshold: every observed run hits peak == walkers with the first blob at ~1s.
This is a rate/tuning fact, not a broken pipeline — if a bead reports 0 blobs,
first re-run at rate=30 before suspecting a defect.
--duration 25 (not 40) keeps the sim's late-window --verify sample inside the
active-walk phase; at long durations walkers drift into low-signal positions and
verify can spuriously report 0 even though the /api/blobs window still saw blobs.
Token path (no manual token needed)
The canonical sim authenticates exactly like real provisioned firmware:
--tokendefaults to empty → the sim callsPOST /api/provisionto mint a real per-node HMAC token (HMAC-SHA256(install_secret, node_mac)), sent as theX-Spaxel-Tokenheader on the WebSocket dial, then bridged intohello.Tokenfor validation (bf-1o7qi).- Fallback: if provisioning fails, the sim generates a synthetic token. Either way the connection is not left tokenless-by-design.
- Defense-in-depth: the mothership also tolerates tokenless nodes for
SPAXEL_MIGRATION_WINDOW_HOURS(default 24h) after startup, so even a token glitch during a reproduce run does not cause areject(bf-2hdbg). The script countsreject/401/403lines in the sim log and reports the count; expect 0.
If you ever see rejects, check SPAXEL_MIGRATION_WINDOW_HOURS (the e2e harness
sets it to 0 to force strict validation) and that the binary is built from
mothership/cmd/sim.
Environment (mothership)
| Variable | Value in the script | Why |
|---|---|---|
SPAXEL_BIND_ADDR |
127.0.0.1:8088 |
Loopback; avoid clashing with anything on 8080 |
SPAXEL_DATA_DIR |
ephemeral mktemp -d |
Clean state every run; removed on exit |
SPAXEL_LOG_LEVEL |
warn |
Quiet enough to scan, loud enough on real problems |
SPAXEL_MDNS_ENABLED |
false |
Loopback reproduce needs no multicast; sim dials directly |
TZ |
UTC |
Deterministic diurnal/timestamp behavior |
Parameters (sim)
| Flag | Default in script | Notes |
|---|---|---|
--nodes |
4 |
Enough for stable multi-link fusion |
--walkers |
2 |
peak == walkers at rate=30 |
--rate |
30 |
See gotcha #2 — 20 is nondeterministic |
--duration |
25 |
Keeps --verify's late sample in the active-walk phase |
--seed |
42 |
Reproducible walker paths |
--space |
5x5x2.5 |
Default room geometry |
--verify |
(set) | Canonical acceptance: blob_count == walkers ±1 + within-2m check |
All overridable via env: SIM_NODES, SIM_WALKERS, SIM_RATE, SIM_DURATION,
SIM_SEED, SIM_SPACE, SIM_PORT. Binary paths via SPAXEL_MOTHERSHIP_BIN /
SPAXEL_SIM_BIN.
Expected result (reference run, HEAD 5bbe52c)
peak_blob_count=2
first_blob_at=1s
samples=25 nodes=4 walkers=2 rate=30 duration=25s seed=42
sim_exit=0 (0 = --verify PASSED)
sim_verify: [SIM] PASS: 2 blobs detected for 2 walkers
reject_in_sim_log=0
fps=Average FPS: ~360
Acceptance — bf-40hc (independently re-verified 2026-07-08, HEAD f859f3e)
./scripts/run-sim-local.sh is the bf-40hc deliverable. Re-running it at f859f3e
produced exit 0 with this evidence, mapping each acceptance criterion:
| bf-40hc acceptance criterion | Verified evidence (2026-07-08, HEAD f859f3e) |
|---|---|
| Documented command starts the mothership without a runtime panic/crash | [run-sim] mothership healthy after the /healthz poll; clean startup, no panic — mothership served the full 25 s run and shut down via the trap |
| At least one blob object observable at runtime (API / WebSocket / tracker log) | Independent /api/blobs poller: peak_blob_count=2, first_blob_at=1s (nodes_online=4 throughout); sim --verify secondary cross-check: [SIM] PASS: 2 blobs detected for 2 walkers |
| Reproduce command written into the bead body or a referenced note | This file + scripts/run-sim-local.sh; exact flags: --nodes 4 --walkers 2 --rate 30 --space 5x5x2.5 --duration 25 --seed 42 --verify |
Reference run captured: peak=2 first_blob=1s sim_exit=0 verify=PASS rejects=0 fps≈363.
Out of scope — do not confuse with bf-40hc: the tests/e2e tests
TestDetectionEvents, TestFullE2EIntegration, and TestConcurrentNodes are
deliberately RED by design (see their inline NOTE comments) — they are gated on
the upstream fusion SetNodePosition wiring (bf-4q5w) and are the strictness
contract of the bf-5jeo verification capstone. TestConcurrentNodes additionally
connects raw tokenless nodes against the harness's SPAXEL_MIGRATION_WINDOW_HOURS=0
strict mode, so its rejects are a harness artifact, not a pipeline defect. They must
NOT be weakened to turn green, and they are not a bf-40hc regression: bf-40hc asserts
only that a hardware-free runtime path emits a tracked blob, which run-sim-local.sh
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 —
link to it, do not duplicate it. The matcher script reuses that exact REST sequence
internally.
The recipe
./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:
{"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 runtime assertion, run the script (or import its param block) rather than hand-rolling acurl /api/blobs. - Determinism contract:
--seedpins walker paths;--rate 30pins blob emission. The only residual nondeterminism is OS scheduling jitter, which the peak-over-window poller absorbs. - Adding identity checks (bf-f841): the sim emits synthetic BLE advertisements
with
--ble; pair--blewith the BLE-registry REST flow. The token/CSI/blob path this script exercises is the prerequisite — keep it green. - Predecessor:
blob_observation.sh(repo root) is the bf-51fq-specific harness with the same logic and detailed inline comments; this script is the bead-agnostic canonical form that supersedes it for general use.
Keeping this honest
If a clean run of scripts/run-sim-local.sh ever fails to reach peak ≥ 1:
- Confirm HEAD includes the sim→blob fix (the
bf-243oscommit andb9f362cLinkID-split fix) —grepfusion.gofor stray debug flags. - Rebuild both binaries (
rm /tmp/spaxel-sim /tmp/spaxel-mothershipand rerun). - Try
SIM_RATE=35to rule out a jitter-induced threshold miss. - Only then treat it as a real defect and update this note + the script defaults.