spaxel/scripts/run-sim-local.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

179 lines
7.9 KiB
Bash
Executable file

#!/usr/bin/env bash
#
# run-sim-local.sh — canonical, hardware-free reproduce of the spaxel runtime path.
#
# Builds the mothership + the canonical CSI simulator from source, starts the
# mothership on an ephemeral port + data dir, streams simulated CSI, and asserts
# that the fusion/tracker pipeline emits >=1 tracked blob via GET /api/blobs.
#
# This is the single deterministic artifact that captures the working end-to-end
# path (sim connect -> CSI ingest -> phase/fusion -> blob) so that later beads
# (e.g. the blob-identity runtime checks under bf-f841) and reruns don't have to
# re-derive the command sequence or the tuning. See notes/hardware-free-runtime.md
# for the full reasoning, gotchas, and the chain this resolves (bf-3zll/bf-3hji/bf-51fq).
#
# No hardware, no Docker, no manual IP, no manual token. The sim auto-provisions a
# per-node HMAC token from the mothership /api/provision endpoint (with the
# migration-window fallback), exactly like real provisioned firmware.
#
# Usage: ./scripts/run-sim-local.sh
# Env: SIM_NODES, SIM_WALKERS, SIM_RATE, SIM_DURATION, SIM_SEED, SIM_PORT,
# SIM_SPACE, SIM_BLE (override defaults; see below)
# Exit: 0 if >=1 blob is observed via /api/blobs, 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}"
# --- Working defaults (verified reproducible 2026-07-08, post bf-243os) ----------
# nodes=4/walkers=2/rate=30/duration=25/seed=42 -> peak_blob_count==walkers,
# first blob ~1s, 0 rejects, ~360 FPS. rate=30 (not the upstream default 20) is
# REQUIRED for reproducibility: at 20 Hz fusion only peaks while a walker crosses
# the DeltaRMS threshold, so a run can land peak=2 *or* 0 nondeterministically.
# At 30 Hz every observed run hits peak==walkers. duration=25 keeps the sim
# --verify late-window sample inside the active-walk phase. See
# notes/hardware-free-runtime.md for the rationale.
SIM_NODES="${SIM_NODES:-4}"
SIM_WALKERS="${SIM_WALKERS:-2}"
SIM_RATE="${SIM_RATE:-30}"
SIM_DURATION="${SIM_DURATION:-25}"
SIM_SEED="${SIM_SEED:-42}"
SIM_SPACE="${SIM_SPACE:-5x5x2.5}"
# SIM_BLE=1 opts in to synthetic BLE advertisements (--ble). Default off: this
# script's gate is the CSI->blob path, NOT identity. The --ble + fixture + matcher
# gate lives in scripts/run-sim-ble-match.sh (bf-1yr1w, notes/hardware-free-runtime.md
# "BLE identity matcher (--ble)"). Opting in here only exercises BLE ingestion.
SIM_BLE="${SIM_BLE:-0}"
log() { printf '[run-sim] %s\n' "$*"; }
die() { printf '[run-sim] 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 (idempotent: reuse if present) -------------
# IMPORTANT: the canonical sim lives at mothership/cmd/sim (it has --verify). The
# repo-root cmd/sim/ is a separate, OLDER module that LACKS --verify — never use it.
log "building canonical binaries from source (reuse if present)..."
need_build=0
[ -x "$MS" ] || need_build=1
[ -x "$SIM" ] || need_build=1
# Even if the binary exists, reject a stale/standalone build that lacks --verify.
if [ -x "$SIM" ] && ! "$SIM" --help 2>&1 | grep -q -- '--verify'; then
log "existing $SIM lacks --verify (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-runsim-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 "params: nodes=$SIM_NODES walkers=$SIM_WALKERS rate=$SIM_RATE duration=${SIM_DURATION}s seed=$SIM_SEED space=$SIM_SPACE port=$PORT ble=$SIM_BLE"
# --- Start mothership on an ephemeral data dir ---------------------------------
# SPAXEL_MDNS_ENABLED=false: loopback reproduce, no multicast needed (the sim
# connects directly via --mothership). SPAXEL_LOG_LEVEL=warn keeps the log quiet
# enough to scan while still surfacing real problems.
SPAXEL_BIND_ADDR="127.0.0.1:$PORT" \
SPAXEL_DATA_DIR="$DATA_DIR" \
SPAXEL_LOG_LEVEL=warn \
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"
# --- Start sim with --verify (canonical acceptance cross-check) ----------------
# The sim streams for $SIM_DURATION, then its verifyBlobs polls /api/blobs
# (2s settle + 12x500ms) and asserts blob_count == walkers ±1. SIM_BLE=1 appends
# --ble (synthetic BLE advertisements); default off keeps the canonical path green.
sim_args=(--mothership "ws://localhost:$PORT/ws/node"
--nodes "$SIM_NODES" --walkers "$SIM_WALKERS" --rate "$SIM_RATE"
--space "$SIM_SPACE"
--duration "$SIM_DURATION" --seed "$SIM_SEED" --verify)
[ "$SIM_BLE" = "1" ] && sim_args+=(--ble)
"$SIM" "${sim_args[@]}" > "$DATA_DIR/sim.log" 2>&1 &
SIM_PID=$!
log "sim started (pid $SIM_PID, --verify enabled, ble=$SIM_BLE)"
# --- Independent /api/blobs poller over the streaming window (authoritative) ---
# A node is online when went_offline_at is the Go zero timestamp (0001-01-01T00:00:00Z)
# or absent — /api/nodes exposes no `status` field (matches the e2e harness, bf-vuzie).
peak=0
first_blob_t=""
sample_count=0
for t in $(seq 1 "$SIM_DURATION"); do
sleep 1
nodes=$(curl -s --max-time 2 "http://localhost:$PORT/api/nodes" 2>/dev/null \
| jq '[.[]|select(.went_offline_at=="0001-01-01T00:00:00Z" or .went_offline_at==null)]|length' 2>/dev/null || echo 0)
n=$(curl -s --max-time 2 "http://localhost:$PORT/api/blobs" 2>/dev/null | jq 'length' 2>/dev/null || echo 0)
sample_count=$((sample_count+1))
[ "$n" -gt "$peak" ] && peak=$n
if [ "$n" -gt 0 ] && [ -z "$first_blob_t" ]; then first_blob_t="${t}s"; fi
printf ' t=%2ds nodes_online=%s blobs=%s peak=%s\n' "$t" "$nodes" "$n" "$peak"
done
# --- Capture the sim --verify verdict ------------------------------------------
wait "$SIM_PID"; SIM_EXIT=$?
verify_line=$(grep -E '\[SIM\] (PASS|FAIL|Verification)' "$DATA_DIR/sim.log" | tail -3)
reject_count=$(grep -ciE 'reject|invalid_token|\b401\b|\b403\b' "$DATA_DIR/sim.log" 2>/dev/null)
fps=$(grep -oE 'Average FPS: [0-9.]+' "$DATA_DIR/sim.log" | tail -1)
echo ""
log "---- RESULT ----"
log "peak_blob_count=$peak"
log "first_blob_at=${first_blob_t:-never}"
log "samples=$sample_count nodes=$SIM_NODES walkers=$SIM_WALKERS rate=$SIM_RATE duration=${SIM_DURATION}s seed=$SIM_SEED"
log "sim_exit=$SIM_EXIT (0 = --verify PASSED)"
log "sim_verify: ${verify_line//$'\n'/ | }"
log "reject_in_sim_log=${reject_count:-0}"
log "fps=${fps:-n/a}"
# --- Acceptance gate: >=1 blob observed via /api/blobs over the run window ------
# This is the task's actual acceptance. The peak poller is a real gate (it can
# legitimately fail with 0 on an under-tuned rate), not a rubber stamp. sim
# --verify is a reported secondary cross-check (timing-sensitive late-window sample).
if [ "$peak" -le 0 ]; then
log "FAIL: peak_blob_count=0 via /api/blobs — no tracked blob observed"
exit 1
fi
if [ "$SIM_EXIT" -eq 0 ]; then
log "PASS: >=1 blob via /api/blobs (peak=$peak) AND sim --verify passed"
else
log "PASS: >=1 blob via /api/blobs (peak=$peak); sim --verify=$SIM_EXIT (late-window sample; /api/blobs is authoritative)"
fi
exit 0