lumbda/factory/bend-dispatcher.sh
russell@unturf.com 1665893321
factory + quantum + sweep-doctrine: AGPLv3 share-back from foxhop ecdsa
29 new files publish factory infra (V2 autoscaler with live VRAM
sampling + EWMA peak tracking, HUGE solo-dispatch, two-tier DLQ/rDLQ
classifier + retry), general quantum circuit primitives (Cuccaro
ripple-carry adder, Clifford gate library, Clifford tableau simulator,
mod-arith family, dialog GCD reversible inverse, Karatsuba multiplier,
Solinas fast reduction), and a TCRAUDT reducer harness. Originally
developed in ~/git/www.foxhop.net/ecdsa/ for secp256k1 attack-surface
research; published upstream as obligated by AGPLv3.

Parametrization contract at factory/CONTRACT.md. Consumers export
LUMBDA_REPO_DIR + LUMBDA_QUEUE_DIR + LUMBDA_BACKEND_CMD + LUMBDA_EMITTER_CMD
then exec factory scripts. No fork-and-modify; single source of truth
upstream.

Integration tests gate 7 V2 defect classes that wedged a live factory
on 2026-06-12 (skewed-demand starve, zero-floor reservation,
multi-tier greedy, +-25%% damping, cold-start ramp, DLQ surge halve,
post-damp CPU ceiling) + 28 DLQ classifier cases (auto-retry vs
escalate partition) + bash -n syntax lint across every script.

GPU backend stays in consumer trees; rationale in
factory/GPU-BACKEND-NOTE.md. Bend wire protocol + gpu-worker.lsp
already upstream at examples/cuda-fanout/.

make factory-lint                bash -n on every factory/*.sh
make test-integration            V2 reducer + DLQ classifier + syntax gate
make sweep-doctrine              TCRAUDT reducer gate (serial)
make sweep-doctrine-parallel     xargs -P fan-out

Verified on neoblanka: factory-lint 12 scripts PASS; test-integration
14 V2 cases + 28 DLQ classifier cases + 12 syntax cases all PASS.
2026-06-14 10:37:35 -04:00

192 lines
8.6 KiB
Bash
Executable file

#!/usr/bin/env bash
# bend-dispatcher.sh — singleton dispatcher for a circuit-emission pipeline.
#
# Polls $LUMBDA_QUEUE_DIR/*.ready, dispatches each via $LUMBDA_BACKEND_CMD
# (or a custom bend-dispatch.py wrapper named in BEND_DISPATCH), logs result
# to $RESULTS_TSV, removes .bin to free disk, touches .done.
#
# Singleton: refuses to start if another instance holds
# $LUMBDA_QUEUE_DIR/dispatcher.lock.
#
# Exits when no .ready remains AND no .emitting marker AND $LUMBDA_QUEUE_DIR/STOP
# exists, OR after MAX_IDLE_LOOPS (default 60 = 5 min) of empty polling.
#
# Usage: bend-dispatcher.sh [QUEUE_DIR [RESULTS_TSV [BATCHES]]]
# QUEUE_DIR default $LUMBDA_QUEUE_DIR
# RESULTS_TSV default $QUEUE_DIR/results.tsv
# BATCHES default 141
#
# Self-identifies as "bend-dispatcher" in cmdline for precise pkill targeting.
#
# Results TSV row format:
# tag n_ops qubits avg_tof score status identity wall_s
# config_root ops_hash trace_hash score_hash candidate_merkle_root
# Old readers ignore unknown trailing columns. Pre-fingerprint rows have
# these columns empty when migrated.
#
# Env vars consumed: see $LUMBDA_FACTORY_DIR/CONTRACT.md (LUMBDA_QUEUE_DIR,
# LUMBDA_BACKEND_CMD, LUMBDA_DOMAIN_DIR, BEND_DISPATCH, HASHES_PY).
set -u
QUEUE_DIR="${1:-${LUMBDA_QUEUE_DIR:-/tmp/lumbda-queue}}"
RESULTS_TSV="${2:-$QUEUE_DIR/results.tsv}"
BATCHES="${3:-141}"
MAX_IDLE_LOOPS="${MAX_IDLE_LOOPS:-60}"
# BEND_DISPATCH = python wrapper that talks to backend over a portal.
# When unset, consumer should set it via env (foxhop sets to
# $LUMBDA_DOMAIN_DIR/runs/lumbda-sweep-008/bend-dispatch.py).
# HASHES_PY = optional fingerprint helper; falls through to empty columns
# when missing.
LUMBDA_DOMAIN_DIR="${LUMBDA_DOMAIN_DIR:-}"
BEND_DISPATCH="${BEND_DISPATCH:-${LUMBDA_DOMAIN_DIR:+$LUMBDA_DOMAIN_DIR/runs/lumbda-sweep-008/bend-dispatch.py}}"
HASHES_PY="${HASHES_PY:-${LUMBDA_DOMAIN_DIR:+$LUMBDA_DOMAIN_DIR/scripts/ecdsa-hashes.py}}"
mkdir -p "$QUEUE_DIR"
LOCK="$QUEUE_DIR/dispatcher.lock"
echo $$ > "$QUEUE_DIR/dispatcher.pid"
trap 'rm -f "$QUEUE_DIR/dispatcher.pid"' EXIT
exec -a bend-dispatcher bash -c '
QUEUE_DIR="'"$QUEUE_DIR"'"
RESULTS_TSV="'"$RESULTS_TSV"'"
BATCHES="'"$BATCHES"'"
MAX_IDLE_LOOPS="'"$MAX_IDLE_LOOPS"'"
BEND_DISPATCH="'"$BEND_DISPATCH"'"
HASHES_PY="'"$HASHES_PY"'"
LOCK="'"$LOCK"'"
# Singleton via flock
exec 9>"$LOCK"
if ! flock -n 9; then
echo "[$(date +%H:%M:%S)] bend-dispatcher: another instance holds $LOCK, refusing to start"
exit 1
fi
echo "[$(date +%H:%M:%S)] bend-dispatcher start queue=$QUEUE_DIR"
trap "echo \"[\$(date +%H:%M:%S)] bend-dispatcher exit\"; rm -f $LOCK" EXIT
# Header includes fingerprint columns. A fresh results.tsv gets the full
# 13-column header. A pre-fingerprint file with only the original 8 columns
# stays untouched (so readers keyed off the legacy header keep working) but
# every new row gets fingerprints appended after legacy fields.
if [ ! -f "$RESULTS_TSV" ]; then
printf "tag\tn_ops\tqubits\tavg_tof\tscore\tstatus\tidentity\twall_s\tconfig_root\tops_hash\ttrace_hash\tscore_hash\tcandidate_merkle_root\n" > "$RESULTS_TSV"
fi
idle=0
while true; do
ready=$(ls "$QUEUE_DIR"/*.ready 2>/dev/null | sort | head -1)
if [ -z "$ready" ]; then
# check stop conditions
if [ -f "$QUEUE_DIR/STOP" ]; then
n_emitting=$(ls "$QUEUE_DIR"/*.emitting 2>/dev/null | wc -l)
[ "$n_emitting" = "0" ] && { echo "[$(date +%H:%M:%S)] STOP + no emits"; break; }
fi
idle=$((idle + 1))
if [ "$idle" -ge "$MAX_IDLE_LOOPS" ]; then
# nothing in flight, no ready, no new emits coming
n_emitting=$(ls "$QUEUE_DIR"/*.emitting 2>/dev/null | wc -l)
pending=$(ls "$QUEUE_DIR"/*.lsp 2>/dev/null | wc -l)
n_done=$(ls "$QUEUE_DIR"/*.done 2>/dev/null | wc -l)
n_ready_total=$((pending - n_done))
if [ "$n_emitting" = "0" ] && [ "$n_ready_total" -le "0" ]; then
echo "[$(date +%H:%M:%S)] idle drained"
break
fi
idle=0
fi
sleep 5
continue
fi
idle=0
tag=$(basename "$ready" .ready)
bin="$QUEUE_DIR/${tag}.bin"
if [ ! -f "$bin" ]; then
echo "[$(date +%H:%M:%S)] SKIP $tag bin missing"
rm -f "$ready"
touch "$QUEUE_DIR/${tag}.done"
continue
fi
echo "[$(date +%H:%M:%S)] dispatch $tag ($(du -h $bin | cut -f1))"
t0=$(date +%s)
out=$(python3 "$BEND_DISPATCH" "$bin" "$BATCHES" 2>&1)
t1=$(date +%s)
wall=$((t1 - t0))
# Dead-letter detection: dispatch returned non-parseable output (backend
# crashed or empty response). Move bin + ready to DLQ for retry by
# watchdog after backend comes back up. We dont mark .done here so the
# cell stays out of completed state — only DLQ retries fully drain it.
if ! echo "$out" | grep -q "^score = "; then
mkdir -p "$QUEUE_DIR/dlq"
retries=0
if [ -f "$QUEUE_DIR/dlq/${tag}.retries" ]; then
retries=$(cat "$QUEUE_DIR/dlq/${tag}.retries")
fi
retries=$((retries + 1))
echo "[$(date +%H:%M:%S)] DLQ $tag (retry=$retries, wall=${wall}s, backend likely down)"
echo "$out" | tail -10 > "$QUEUE_DIR/dlq/${tag}.reason"
echo "$retries" > "$QUEUE_DIR/dlq/${tag}.retries"
if [ "$retries" -lt 5 ]; then
mv "$bin" "$QUEUE_DIR/dlq/${tag}.bin"
rm -f "$ready"
# Note: do NOT touch .done — cell still pending. Watchdog/reaper
# moves bin back + re-touches .ready after backend stays healthy 60s+.
else
echo "[$(date +%H:%M:%S)] DLQ $tag exhausted retries — marking .done.fail"
rm -f "$bin" "$ready"
touch "$QUEUE_DIR/${tag}.done.fail"
touch "$QUEUE_DIR/${tag}.done"
fi
continue
fi
# Keep last 5 lines: parsed= + score + trace_hash= + raw_portal_resp= +
# trailing newline. Portal-resp blob lets us recompute fingerprints
# offline if dispatcher TSV ever needs re-hashing.
echo "$out" | tail -5 > "$QUEUE_DIR/${tag}.dispatch.log"
nops=$(echo "$out" | grep -oP "n_ops.: \K[0-9]+" | head -1)
qubits=$(echo "$out" | grep -oP "qubits.: \K[0-9]+" | head -1)
tof=$(echo "$out" | grep -oP "avg_tof.: \K[0-9.]+" | head -1)
score=$(echo "$out" | grep -oP "score = .* = \K\S+")
status=$(echo "$out" | grep -oP "status.: .\K[A-Z]+" | head -1)
ident=$(echo "$out" | grep -oP "identity.: .\K[a-z]+" | head -1)
# ── Fingerprint columns ─────────────────────────────────────────────
# cell .lsp drives config_root. Cells get staged into queue dir as
# $QUEUE_DIR/${tag}.lsp by the feeder. When originating .lsp is absent
# (e.g. probe path), config_root falls through to all-zero digest and
# downstream readers can detect absence.
cell="$QUEUE_DIR/${tag}.lsp"
portal_resp=$(echo "$out" | grep -oP "^raw_portal_resp=\K.*" | tail -1)
# Pipe portal response into HASHES_PY so every root and the
# candidate_merkle_root land in a single JSON line. When HASHES_PY is
# unset / missing, fall through with empty columns so legacy callers
# still get the score row.
score_json=$(printf "{\"tag\":\"%s\",\"n_ops\":\"%s\",\"qubits\":\"%s\",\"avg_tof\":\"%s\",\"score\":\"%s\",\"status\":\"%s\",\"identity\":\"%s\",\"wall_s\":\"%s\"}" \
"$tag" "${nops:-}" "${qubits:-}" "${tof:-}" "${score:-}" "${status:-}" "${ident:-}" "$wall")
hash_json="{}"
if [ -n "$HASHES_PY" ] && [ -f "$HASHES_PY" ]; then
hash_json=$(printf "%s" "$portal_resp" | \
python3 "$HASHES_PY" all \
--cell "$cell" \
--bin "$bin" \
--portal-stdin \
--score-json "$score_json" 2>/dev/null || echo "{}")
fi
config_root=$(echo "$hash_json" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get(\"config_root\",\"\"))" 2>/dev/null)
ops_hash=$(echo "$hash_json" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get(\"ops_hash\",\"\"))" 2>/dev/null)
trace_hash=$(echo "$hash_json" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get(\"trace_hash\",\"\"))" 2>/dev/null)
score_hash=$(echo "$hash_json" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get(\"score_hash\",\"\"))" 2>/dev/null)
merkle=$(echo "$hash_json" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get(\"candidate_merkle_root\",\"\"))" 2>/dev/null)
printf "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n" \
"$tag" "${nops:-}" "${qubits:-}" "${tof:-}" "${score:-}" "${status:-}" "${ident:-}" "$wall" \
"${config_root:-}" "${ops_hash:-}" "${trace_hash:-}" "${score_hash:-}" "${merkle:-}" \
>> "$RESULTS_TSV"
echo " -> score=$score status=$status identity=$ident wall=${wall}s"
echo " merkle=${merkle:0:16}.. trace=${trace_hash:0:16}.. ops=${ops_hash:0:16}.. config=${config_root:0:16}.."
rm -f "$bin" "$ready"
touch "$QUEUE_DIR/${tag}.done"
done
'