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.
This commit is contained in:
parent
1db0932fd5
commit
1665893321
30 changed files with 19014 additions and 0 deletions
35
Makefile
35
Makefile
|
|
@ -224,6 +224,40 @@ endif
|
|||
gpu-worker-test: gpu-worker-bin
|
||||
$(MAKE) -C $(GPU_WORKER_DIR) test
|
||||
|
||||
# ─── Factory — bend at scale ──────────────────────────────────────
|
||||
#
|
||||
# Bounded-parallel emit + dispatch + autoscaling for lumbda research
|
||||
# loops driving GPU bend backends. See factory/README.md & factory/CONTRACT.md.
|
||||
# Originally built at foxhop for secp256k1 attack-surface research; shared
|
||||
# upstream under AGPLv3.
|
||||
#
|
||||
# make factory-lint bash -n syntax check on factory/*.sh
|
||||
# make test-integration integration tests (autoscaler V2 reducer + DLQ classifier)
|
||||
# make sweep-doctrine TCRAUDT reducer gate (serial)
|
||||
# make sweep-doctrine-parallel same, xargs -P fan-out (JOBS=N)
|
||||
#
|
||||
# Consumer wrapper pattern: domain repos export LUMBDA_* env then exec
|
||||
# factory/bend-supervisor.sh. See factory/CONTRACT.md.
|
||||
|
||||
FACTORY_DIR := factory
|
||||
TESTS_INTEGRATION_DIR := tests/integration
|
||||
TESTS_SWEEP_DOCTRINE_DIR := tests/sweep-doctrine
|
||||
|
||||
factory-lint:
|
||||
@bash $(TESTS_INTEGRATION_DIR)/test-bash-script-syntax.sh
|
||||
|
||||
test-integration:
|
||||
@for t in $(TESTS_INTEGRATION_DIR)/test-*.sh; do \
|
||||
echo "── $$t ──"; \
|
||||
bash "$$t" || { rc=$$?; [ $$rc -eq 77 ] && echo "SKIP (factory script absent)" || exit $$rc; }; \
|
||||
done
|
||||
|
||||
sweep-doctrine:
|
||||
@bash $(TESTS_SWEEP_DOCTRINE_DIR)/run.sh
|
||||
|
||||
sweep-doctrine-parallel:
|
||||
@bash $(TESTS_SWEEP_DOCTRINE_DIR)/run-parallel.sh
|
||||
|
||||
# ─── Documentation ────────────────────────────────────────────────
|
||||
|
||||
DOT_FILES := $(wildcard docs/*.dot)
|
||||
|
|
@ -296,4 +330,5 @@ clean-all: clean clean-whitepaper clean-docs c-clean asm-clean
|
|||
test-all bench-all examples friction functional-test portal-rng-cross-test \
|
||||
bench-3way bench-portal bench-portal-cross bench-web bench-rpc-chain bench-proof \
|
||||
gpu-worker gpu-worker-bin gpu-worker-test \
|
||||
factory-lint test-integration sweep-doctrine sweep-doctrine-parallel \
|
||||
docs whitepaper clean clean-whitepaper clean-docs clean-all
|
||||
|
|
|
|||
16
factory/CHANGELOG.md
Normal file
16
factory/CHANGELOG.md
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
# factory CHANGELOG
|
||||
|
||||
## 2026-06-14 — initial upstream port
|
||||
|
||||
AGPLv3 share-back from foxhop ecdsa secp256k1 attack-surface research. Port covers:
|
||||
|
||||
- V2 live-state autoscaler (EWMA peak tracking, DLQ growth-rate damping, HUGE solo-dispatch, ±25 % per-poll damping, cold-recovery baseline)
|
||||
- Bounded parallel emit pool with auto-pull-master across multiple repos
|
||||
- Singleton dispatcher with live tier-config reload
|
||||
- Two-tier DLQ + rDLQ with class-based auto-retry vs escalate
|
||||
- Tier classifier (micro/small/medium/large/huge bin bands)
|
||||
- Supervisor liveness signaling
|
||||
|
||||
Env-var contract defined in `CONTRACT.md`. Consumers wrap by exporting env then exec'ing upstream scripts. Single source of truth — no fork-and-modify.
|
||||
|
||||
Provenance: foxhop scripts at `~/git/www.foxhop.net/ecdsa/scripts/bend-*` operated this factory from 2026-05 through 2026-06-14, producing measured improvements documented in foxhop's PROVENANCE.md.
|
||||
62
factory/CONTRACT.md
Normal file
62
factory/CONTRACT.md
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
# Factory env-var contract
|
||||
|
||||
Every upstream factory script reads these env vars. Consumers (foxhop ecdsa, any future lumbda-driven research repo) set them before sourcing or exec'ing factory scripts. Defaults assume a generic lumbda development setup; consumers override for their domain.
|
||||
|
||||
## Path knobs
|
||||
|
||||
| Var | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `LUMBDA_REPO_DIR` | `$HOME/git/lumbda` | Git repo to `git pull --ff-only origin master` at top of every emit-loop iter. Consumers override to their own repo (e.g. foxhop sets `$HOME/git/www.foxhop.net`). |
|
||||
| `LUMBDA_FACTORY_DIR` | `$LUMBDA_REPO_DIR/factory` | Where upstream factory scripts live. Used for inter-script `exec`/`source` resolution. |
|
||||
| `LUMBDA_QUEUE_DIR` | `/tmp/lumbda-queue` | Runtime queue + DLQ + rDLQ + supervisor.config home. One per running factory instance. |
|
||||
| `LUMBDA_DOMAIN_DIR` | unset | Optional domain root (consumer repo's working subdir). Foxhop sets `$HOME/git/www.foxhop.net/ecdsa`. Scripts that resolve `.lsp` cell paths look here. |
|
||||
|
||||
## Backend knobs
|
||||
|
||||
| Var | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `LUMBDA_BACKEND_CMD` | `bend-cuda` | Command the dispatcher exec's per cell. Receives `--portal <bin-path>` as first arg. Consumers point at their compiled backend binary. |
|
||||
| `LUMBDA_BACKEND_PROC_PATTERN` | `bend-cuda` | `pgrep -f` pattern to count running backend forks for VRAM accounting. Usually same basename as `LUMBDA_BACKEND_CMD`. |
|
||||
| `LUMBDA_EMITTER_CMD` | unset | Optional emit-side command run by `bend-emit-pool` per `.lsp` cell. If unset, pool only manages queue lifecycle (no emit). Foxhop sets a full `lumbda --fast .../emit-stream.lsp` invocation. |
|
||||
|
||||
## Remote knobs
|
||||
|
||||
| Var | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `LUMBDA_REMOTE` | unset (local) | SSH target (e.g. `user@host`) for remote factory ops. Empty = run locally. |
|
||||
| `LUMBDA_REMOTE_SSH_OPTS` | `-o BatchMode=yes -o ConnectTimeout=10` | SSH options for remote calls. |
|
||||
|
||||
## Autoscaler V2 knobs (all already env-var-gated in source)
|
||||
|
||||
| Var | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `AUTOSCALER_V2` | `1` | `0` falls back to V1 controller (legacy). |
|
||||
| `AUTOSCALER_PEAK_INITIAL` | `4096` | Initial per-worker VRAM estimate (MiB). |
|
||||
| `AUTOSCALER_CPU_CEILING` | `30` | Total worker hard cap (cores + modest oversub). |
|
||||
| `AUTOSCALER_DLQ_DAMPING_THRESHOLD` | `10` | DLQ entries-per-poll above which damping fires. |
|
||||
| `AUTO_PULL_MASTER` | `1` | Auto-pull at top of each emit iter (0 disables). |
|
||||
| `AUTO_PULL_TIMEOUT` | `30` | Seconds for git pull. |
|
||||
| `LUMBDA_AUTO_PULL_DIRS` | `$LUMBDA_REPO_DIR` | Colon-separated repo dirs to pull each iter. Consumer with both upstream lumbda + domain repo sets e.g. `$HOME/git/lumbda:$HOME/git/www.foxhop.net`. Both pull `--ff-only origin master`; either failure logged as PULL-FAIL but does not abort emit. |
|
||||
| `TIER_<NAME>_DISPATCH_CEILING` | 24/16/12/6/1 | Per-tier hard cap (micro/small/medium/large/huge). |
|
||||
| `TIER_<NAME>_MAX` | varies | Per-tier bin-size ceiling (MiB). See `lib-tier.sh`. |
|
||||
| `TIER_CONFIG_LIVE_RELOAD` | `1` | Dispatcher re-reads supervisor.config per dispatch (no restart needed). |
|
||||
|
||||
## Discipline
|
||||
|
||||
- Defaults stay generic. Consumers override via wrapper scripts that `export` then `exec`.
|
||||
- Scripts NEVER hardcode `/tmp/ecdsa-queue`, `bend-cuda`, host names, or repo paths. Always read from env.
|
||||
- New knobs land with default + table entry above. CHANGELOG entry on add.
|
||||
- `LUMBDA_FACTORY_DIR/CONTRACT.md` (this file) stays single source of truth; never duplicate var docs in script comment blocks beyond a one-line pointer.
|
||||
|
||||
## Consumer wrapper pattern
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# foxhop ecdsa/scripts/bend-supervisor — wraps upstream lumbda factory
|
||||
export LUMBDA_REPO_DIR="$HOME/git/www.foxhop.net"
|
||||
export LUMBDA_DOMAIN_DIR="$HOME/git/www.foxhop.net/ecdsa"
|
||||
export LUMBDA_QUEUE_DIR="/tmp/ecdsa-queue"
|
||||
export LUMBDA_BACKEND_CMD="$LUMBDA_DOMAIN_DIR/cuda/bend-cuda"
|
||||
export LUMBDA_EMITTER_CMD="lumbda --fast $LUMBDA_DOMAIN_DIR/lumbda/emit-stream.lsp"
|
||||
exec "$HOME/git/lumbda/factory/bend-supervisor.sh" "$@"
|
||||
```
|
||||
29
factory/GPU-BACKEND-NOTE.md
Normal file
29
factory/GPU-BACKEND-NOTE.md
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
# GPU backend — scope note
|
||||
|
||||
## What lives upstream
|
||||
|
||||
- **bend wire protocol** — `examples/cuda-fanout/` (`gpu-worker.lsp`, `bend.lsp`, `bend-macros.lsp`, `wire.lsp`). S-expression + binary (`BSHK` magic) modes. Tier-agnostic — runs under Python, C, or asm tier.
|
||||
- **GPU launcher** — `make gpu-worker LUMBDA={c,python,asm} PORT=NNNN`. Spawns gpu-worker.lsp on chosen tier.
|
||||
- **Factory** — `factory/` orchestrates a queue of cells against any backend reachable via `LUMBDA_BACKEND_CMD`. Backend-agnostic.
|
||||
|
||||
## What stays in a domain consumer
|
||||
|
||||
Specific GPU simulator backends (Clifford tableau, stabilizer, surface-code) couple deeply to a research domain's operation set. They live in domain consumers:
|
||||
|
||||
- Foxhop ecdsa Clifford tableau simulator: `~/git/www.foxhop.net/ecdsa/cuda/` — secp256k1 group operations, `BSHK` binary ops format with secp-specific opcodes.
|
||||
|
||||
These backends accept a portal-file interface and expose `LUMBDA_BACKEND_CMD`-compatible call shape. Consumers point factory at their backend; factory provides everything around it (queue, autoscaling, DLQ, tier admission).
|
||||
|
||||
## Why a generic Clifford backend stays out of this port
|
||||
|
||||
A truly upstream Clifford simulator would need:
|
||||
|
||||
1. **Generic ops format** — abstract over per-domain instruction sets (currently `main_ops.c` carries secp-specific group ops).
|
||||
2. **Domain-agnostic memory model** — current tableau layout sized for n=257 qubits + ancilla; would need width-parametric VRAM accounting.
|
||||
3. **Standalone test coverage** — current correctness gates run against secp256k1 truth tables.
|
||||
|
||||
Each is several days' work + a research-quality decision (which Clifford simulator family becomes canonical upstream). Out of scope for this AGPLv3 share-back. Filed as future work.
|
||||
|
||||
## What this means for AGPLv3 compliance
|
||||
|
||||
The factory + lumbda quantum primitives discharge the share-back. The domain-specific backend belongs to consumer repos under their own license (AGPLv3 if derivative). Foxhop's `ecdsa/cuda/` remains AGPLv3 in its tree.
|
||||
93
factory/README.md
Normal file
93
factory/README.md
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
# factory/ — bend at scale
|
||||
|
||||
Bounded-parallel emit + dispatch + autoscaling for lumbda research loops driving GPU bend backends. Originally built for foxhop ecdsa secp256k1 attack-surface work (`~/git/www.foxhop.net/ecdsa/`), shared back upstream under AGPLv3.
|
||||
|
||||
## What it does
|
||||
|
||||
Drives a queue of `.lsp` cells → emitted `.bin` payloads → dispatched to a backend (default `bend-cuda`) → results in a portal file. Survives crashes, OOM, transient CUDA errors via a two-tier DLQ. Adapts concurrency to live VRAM headroom + observed per-fork peak memory.
|
||||
|
||||
## Components
|
||||
|
||||
- `bend-supervisor.sh` — process orchestrator. Launches pool + dispatcher + autoscaler + DLQ runner.
|
||||
- `bend-emit-pool.sh` — bounded parallel emit loop. Auto-pulls `LUMBDA_REPO_DIR` per iter.
|
||||
- `bend-dispatcher.sh` — singleton dispatcher. Reads supervisor.config tier caps live, dispatches `.bin` to backend.
|
||||
- `bend-autoscaler.sh` — V2 live-state controller. Samples VRAM free + running forks + EWMA peak → plans per-tier caps.
|
||||
- `bend-supervisor-dlq-runner.sh` — DLQ classifier. Auto-retries transient classes, escalates persistent to rDLQ.
|
||||
- `lib-tier.sh` — bin-size classifier. Tier bands + HUGE solo-dispatch.
|
||||
- `lib-heartbeat.sh` — supervisor liveness signaling.
|
||||
|
||||
## V2 controller — live VRAM model
|
||||
|
||||
Rather than reserving `PEAK × allocated_slots` for every tier (V1's approach, prone to over-reservation under skew), V2 samples actual state:
|
||||
|
||||
- `nvidia-smi --query-gpu=memory.free` → live headroom (not fixed budget × fraction)
|
||||
- `pgrep -f "$LUMBDA_BACKEND_PROC_PATTERN"` + `nvidia-smi --query-compute-apps=used_memory` → fork count + observed peak
|
||||
- EWMA over observed peaks → per-worker VRAM estimate (file `autoscaler.ewma`)
|
||||
- Zero-floor on empty tiers (no `MIN=1` reservation)
|
||||
- Fall-through admission (single-tier demand → grant whole budget to that tier)
|
||||
- ±25 % per-poll rate-of-change damping
|
||||
- DLQ growth-rate damping (halve next plan if DLQ growth > threshold)
|
||||
|
||||
See `bend-autoscaler.sh` header for signal format + operator overrides.
|
||||
|
||||
## HUGE solo-dispatch
|
||||
|
||||
Cells that classify as HUGE (4-12 GB bins) get whole-card solo dispatch: when a HUGE bin is queued or in flight, all other tiers plan to 0 until HUGE completes. Damped at 25 %/poll so already-running cells finish naturally (~2 min drain).
|
||||
|
||||
Trigger: `r_huge > 0` OR `inflight_huge > 0`. Release: `inflight_huge = 0` AND no queued HUGE.
|
||||
|
||||
## Two-tier DLQ + rDLQ
|
||||
|
||||
`bend-supervisor-dlq-runner.sh` polls `$LUMBDA_QUEUE_DIR/dlq/` every 30s. Per entry, classifies + acts:
|
||||
|
||||
- **Auto-retry classes** (`missing-bin`, `bisect-pool-race`, `cuda-error-transient`, `no-portal`) → move `.bin` back to queue, `.ready` touched, `.retries++`. Cap `AUTO_RETRY_MAX=3` then escalates.
|
||||
- **Escalate classes** (`cuda-oom`, `cuda-illegal-addr`, `memory-cap-refused`, `bin-load-fail`, `tier-classify`, `emit-broken`, `unknown`) → move to `$LUMBDA_QUEUE_DIR/rdlq/` with full state preservation. Owner inspects + patches + retries.
|
||||
|
||||
rDLQ state per cell:
|
||||
```
|
||||
rdlq/<tag>.bin (preserved iff stage != emit AND salvage=yes)
|
||||
rdlq/<tag>.lsp (cell source — required for retry-reemit path)
|
||||
rdlq/<tag>.reason (original DLQ reason + classifier verdict)
|
||||
rdlq/<tag>.class (cuda-oom | bin-load-fail | ...)
|
||||
rdlq/<tag>.stage (emit | dispatch | sim)
|
||||
rdlq/<tag>.retries (cumulative)
|
||||
rdlq/<tag>.first-seen (UTC timestamp)
|
||||
```
|
||||
|
||||
## Cold-recovery baseline
|
||||
|
||||
V2 writes `$LUMBDA_QUEUE_DIR/supervisor.config.baseline` every poll iff `total >= CPU_CEILING/4`. Last known-healthy cap vector. On autoscaler crash with stale `supervisor.config`:
|
||||
|
||||
```bash
|
||||
cp "$LUMBDA_QUEUE_DIR/supervisor.config.baseline" "$LUMBDA_QUEUE_DIR/supervisor.config"
|
||||
```
|
||||
|
||||
then restart `bend-autoscaler.sh`. Baseline overwritten each healthy poll, tracks workload evolution.
|
||||
|
||||
## Configuration
|
||||
|
||||
All knobs in `CONTRACT.md`. Defaults assume a generic lumbda dev setup; domain consumers (foxhop ecdsa, etc.) set env vars then exec.
|
||||
|
||||
Example consumer wrapper:
|
||||
```bash
|
||||
#!/bin/bash
|
||||
export LUMBDA_REPO_DIR="$HOME/git/www.foxhop.net"
|
||||
export LUMBDA_DOMAIN_DIR="$HOME/git/www.foxhop.net/ecdsa"
|
||||
export LUMBDA_QUEUE_DIR="/tmp/ecdsa-queue"
|
||||
export LUMBDA_BACKEND_CMD="$LUMBDA_DOMAIN_DIR/cuda/bend-cuda"
|
||||
export LUMBDA_EMITTER_CMD="lumbda --fast $LUMBDA_DOMAIN_DIR/lumbda/emit-stream.lsp"
|
||||
exec "$HOME/git/lumbda/factory/bend-supervisor.sh" "$@"
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
Integration tests at `~/git/lumbda/tests/integration/`:
|
||||
- `test-bash-script-syntax.sh` — `bash -n` lint gate. Catches the apostrophe-in-bash-c class that took the foxhop factory down 24 min on 2026-06-12.
|
||||
- `test-autoscaler-v2.sh` — V2 reducer covering 7 defect classes (skewed-demand starve, zero-floor reservation, multi-tier greedy, ±25 % damping, cold-start ramp, DLQ surge halve, post-damp CPU ceiling).
|
||||
- `test-dlq-runner-classify.sh` — DLQ verdict classifier reducer.
|
||||
|
||||
Run: `make -C ~/git/lumbda test-integration`.
|
||||
|
||||
## License
|
||||
|
||||
AGPLv3. Originally developed at foxhop for secp256k1 attack-surface research; shared upstream as obligated by AGPLv3 share-back when the work runs as a service or against shared infrastructure.
|
||||
765
factory/bend-autoscaler.sh
Executable file
765
factory/bend-autoscaler.sh
Executable file
|
|
@ -0,0 +1,765 @@
|
|||
#!/usr/bin/env bash
|
||||
# bend-autoscaler.sh — bin-pack autopilot for a bend factory.
|
||||
#
|
||||
# Watches:
|
||||
# - .ready / .bin tier distribution on $LUMBDA_QUEUE_DIR
|
||||
# - live RAM / VRAM headroom on this host
|
||||
# - current TIER_*_DISPATCH caps (from supervisor.config)
|
||||
#
|
||||
# Computes a safe per-tier cap vector that maximizes throughput under a
|
||||
# memory budget — bin-pack: greedily fill smallest tiers first (highest
|
||||
# cap, lightest per-dispatch footprint), then ratchet larger tiers until
|
||||
# predicted RAM/VRAM saturate.
|
||||
#
|
||||
# Writes new caps to $LUMBDA_QUEUE_DIR/supervisor.config. Supervisor's
|
||||
# reload_config picks up our change within $POLL_SECONDS, kills the
|
||||
# dispatcher, restart loop respawns with new env. No SIGHUP gymnastics.
|
||||
#
|
||||
# Tuned for a 24-core / 62 GB RAM / 24 GB VRAM workstation but every
|
||||
# threshold stays parametric — substrate-grade.
|
||||
#
|
||||
# Heartbeat: $LUMBDA_QUEUE_DIR/autoscaler.heartbeat via lib-heartbeat.sh.
|
||||
# Supervisor restarts us if we go silent.
|
||||
#
|
||||
# Exit:
|
||||
# - SIGTERM/INT
|
||||
# - $LUMBDA_QUEUE_DIR/AUTOSCALER_STOP marker
|
||||
#
|
||||
# Env vars consumed: see $LUMBDA_FACTORY_DIR/CONTRACT.md for our canonical
|
||||
# table (LUMBDA_QUEUE_DIR, LUMBDA_BACKEND_PROC_PATTERN, AUTOSCALER_V2,
|
||||
# AUTOSCALER_PEAK_INITIAL, AUTOSCALER_CPU_CEILING,
|
||||
# AUTOSCALER_DLQ_DAMPING_THRESHOLD, TIER_*_DISPATCH_CEILING, ...).
|
||||
set -u
|
||||
|
||||
QUEUE_DIR="${LUMBDA_QUEUE_DIR:-${QUEUE_DIR:-/tmp/lumbda-queue}}"
|
||||
POLL_SECONDS="${AUTOSCALER_POLL:-20}"
|
||||
LOG="${AUTOSCALER_LOG:-$QUEUE_DIR/autoscaler.log}"
|
||||
CONFIG_FILE="$QUEUE_DIR/supervisor.config"
|
||||
STOP_FILE="$QUEUE_DIR/AUTOSCALER_STOP"
|
||||
|
||||
LUMBDA_FACTORY_DIR="${LUMBDA_FACTORY_DIR:-$(dirname "$(readlink -f "$0")")}"
|
||||
LIB_HEARTBEAT="${LIB_HEARTBEAT:-$LUMBDA_FACTORY_DIR/lib-heartbeat.sh}"
|
||||
LIB_TIER="${LIB_TIER:-$LUMBDA_FACTORY_DIR/lib-tier.sh}"
|
||||
# shellcheck source=/dev/null
|
||||
. "$LIB_HEARTBEAT"
|
||||
# shellcheck source=/dev/null
|
||||
. "$LIB_TIER"
|
||||
|
||||
# Backend proc pattern — pgrep -f match for counting in-flight backend forks.
|
||||
BACKEND_PROC_PATTERN="${LUMBDA_BACKEND_PROC_PATTERN:-bend-cuda}"
|
||||
|
||||
# V1 (legacy) — fixed budget × total knobs. Kept as fallback via AUTOSCALER_V2=0.
|
||||
RAM_BUDGET_FRACTION="${RAM_BUDGET_FRACTION:-0.70}"
|
||||
VRAM_BUDGET_FRACTION="${VRAM_BUDGET_FRACTION:-0.85}"
|
||||
MIN_DISPATCH_PER_TIER="${MIN_DISPATCH_PER_TIER:-1}"
|
||||
|
||||
# ── V2 controller knobs ──────────────────────────────────────────
|
||||
# V2 replaces V1's "reserve PEAK × allocated SLOT for every tier" model with
|
||||
# a live-state model: sample actual VRAM free + running backend fork peaks
|
||||
# + DLQ growth rate, plan = running + headroom_workers. Fall-through
|
||||
# admission gives single-tier demand the full budget; ±25 % per-poll rate
|
||||
# damping prevents oscillation; DLQ surge halves a plan next poll.
|
||||
AUTOSCALER_V2="${AUTOSCALER_V2:-1}" # 1 = V2 live model (default), 0 = V1 fallback
|
||||
AUTOSCALER_PEAK_INITIAL="${AUTOSCALER_PEAK_INITIAL:-4096}" # MIB per backend fork before first observation
|
||||
AUTOSCALER_PEAK_MIN="${AUTOSCALER_PEAK_MIN:-2048}" # MIB floor (never under-estimate)
|
||||
AUTOSCALER_EWMA_ALPHA="${AUTOSCALER_EWMA_ALPHA:-0.3}" # weight of new observation (0.3 = slow decay; max() biased anyway)
|
||||
AUTOSCALER_CPU_CEILING="${AUTOSCALER_CPU_CEILING:-30}" # never plan > N total workers (24 cores + modest oversub)
|
||||
AUTOSCALER_RAMP_FROM_ZERO="${AUTOSCALER_RAMP_FROM_ZERO:-4}" # max plan when cur=0 (cold-start step)
|
||||
AUTOSCALER_DLQ_DAMPING_THRESHOLD="${AUTOSCALER_DLQ_DAMPING_THRESHOLD:-10}" # DLQ entries/poll above which next plan halves
|
||||
AUTOSCALER_VRAM_SAFETY_RESERVE_MIB="${AUTOSCALER_VRAM_SAFETY_RESERVE_MIB:-2048}" # never plan into last N MIB of free VRAM
|
||||
# RAM model — V2 originally used VRAM EWMA peak for RAM headroom too. Real
|
||||
# backend RSS runs 1.5-2× VRAM (lib-tier.sh:48-49 calibration). Separate
|
||||
# EWMA for RAM peak avoids over-estimating headroom + edging into swap.
|
||||
AUTOSCALER_RAM_PEAK_INITIAL="${AUTOSCALER_RAM_PEAK_INITIAL:-6400}" # MIB per fork RSS guess pre-observation
|
||||
AUTOSCALER_RAM_PEAK_MIN="${AUTOSCALER_RAM_PEAK_MIN:-4096}" # MIB floor on RAM/worker estimate
|
||||
AUTOSCALER_RAM_SAFETY_RESERVE_MIB="${AUTOSCALER_RAM_SAFETY_RESERVE_MIB:-8192}" # leave N MIB unallocated for OS + pool emits
|
||||
# Swap-pressure signal — fires when MemAvailable falls below this fraction
|
||||
# of MemTotal OR when SwapUsed grows over poll period. Triggers a halving
|
||||
# of plan capacity AND writes pool advisory file so emit pool can slow down.
|
||||
AUTOSCALER_RAM_PRESSURE_FRACTION="${AUTOSCALER_RAM_PRESSURE_FRACTION:-0.15}" # < 15% free MemAvailable = pressure
|
||||
AUTOSCALER_SWAP_PRESSURE_DELTA_MIB="${AUTOSCALER_SWAP_PRESSURE_DELTA_MIB:-256}" # SwapUsed grew > N MIB in last poll = active swap
|
||||
# Per-tier hard ceilings — V2 never plans above these regardless of headroom.
|
||||
# Same shape as lib-tier.sh defaults but explicit so V2 reads them even when
|
||||
# autoscaler runs against an unchanged supervisor.config.
|
||||
TIER_MICRO_DISPATCH_CEILING="${TIER_MICRO_DISPATCH_CEILING:-24}"
|
||||
TIER_SMALL_DISPATCH_CEILING="${TIER_SMALL_DISPATCH_CEILING:-16}"
|
||||
TIER_MEDIUM_DISPATCH_CEILING="${TIER_MEDIUM_DISPATCH_CEILING:-12}"
|
||||
TIER_LARGE_DISPATCH_CEILING="${TIER_LARGE_DISPATCH_CEILING:-6}"
|
||||
# HUGE — solo-dispatch tier. Cap = 1 enforced + V2 drains all other tiers
|
||||
# before admitting. Largest candidate bins (6-12 GB) land here.
|
||||
TIER_HUGE_DISPATCH_CEILING="${TIER_HUGE_DISPATCH_CEILING:-1}"
|
||||
|
||||
log() {
|
||||
local ts msg
|
||||
ts=$(date +%H:%M:%S)
|
||||
msg="[$ts] $*"
|
||||
echo "$msg" | tee -a "$LOG"
|
||||
}
|
||||
|
||||
# Read host capacity. Returns lines:
|
||||
# total_ram_mib XXXX
|
||||
# total_vram_mib YYYY
|
||||
read_capacity() {
|
||||
local total_ram_kb total_ram_mib total_vram_mib
|
||||
total_ram_kb=$(awk '/^MemTotal:/ {print $2}' /proc/meminfo 2>/dev/null || echo 0)
|
||||
total_ram_mib=$((total_ram_kb / 1024))
|
||||
if command -v nvidia-smi >/dev/null 2>&1; then
|
||||
total_vram_mib=$(nvidia-smi --query-gpu=memory.total --format=csv,noheader,nounits 2>/dev/null | head -1 | tr -dc 0-9)
|
||||
fi
|
||||
[ -z "$total_vram_mib" ] && total_vram_mib=0
|
||||
printf 'total_ram_mib %d\ntotal_vram_mib %d\n' "$total_ram_mib" "$total_vram_mib"
|
||||
}
|
||||
|
||||
# Count .ready + .bin files per tier in queue. Single ls + stat per call.
|
||||
# Emits lines: ready_<tier> N (incl. huge — solo-dispatch tier).
|
||||
count_ready_by_tier() {
|
||||
declare -A counts
|
||||
counts[micro]=0; counts[small]=0; counts[medium]=0; counts[large]=0; counts[huge]=0
|
||||
local f tag sz tier
|
||||
for f in "$QUEUE_DIR"/*.ready "$QUEUE_DIR"/*.bin; do
|
||||
[ -f "$f" ] || continue
|
||||
case "$f" in
|
||||
*.ready) tag=$(basename "$f" .ready) ;;
|
||||
*.bin) tag=$(basename "$f" .bin) ;;
|
||||
esac
|
||||
# De-dup: prefer .bin (covers .ready + .inflight states)
|
||||
local bin="$QUEUE_DIR/${tag}.bin"
|
||||
[ -f "$bin" ] || continue
|
||||
# Skip cells already done/inflight (in-flight handled by tier_inflight)
|
||||
[ -f "$QUEUE_DIR/${tag}.done" ] && continue
|
||||
sz=$(stat -c %s "$bin" 2>/dev/null || echo 0)
|
||||
tier=$(tier_of_size "$sz")
|
||||
case "$tier" in
|
||||
micro|small|medium|large|huge) counts[$tier]=$((counts[$tier] + 1)) ;;
|
||||
esac
|
||||
done
|
||||
for t in micro small medium large huge; do
|
||||
printf 'ready_%s %d\n' "$t" "${counts[$t]}"
|
||||
done
|
||||
}
|
||||
|
||||
# Bin-pack: pick per-tier dispatch counts that maximize sum(count_t × value_t)
|
||||
# under RAM + VRAM budgets. value_t is throughput proxy (assume 1 per dispatch
|
||||
# slot; weighting by tier's expected wall-time would refine, but uniform-1 is
|
||||
# a good first cut — our autopilot's job is admission, not perfect throughput).
|
||||
#
|
||||
# Greedy: start with all tiers at min, scan small→large. For each tier,
|
||||
# allocate as many slots as fit AND as demand (ready count) wants AND
|
||||
# tier's hard ceiling from lib-tier defaults. Stop when budget exhausted.
|
||||
#
|
||||
# Outputs four lines:
|
||||
# plan_micro N
|
||||
# plan_small N
|
||||
# plan_medium N
|
||||
# plan_large N
|
||||
plan_caps() {
|
||||
local ram_budget="$1" vram_budget="$2"
|
||||
local ready_micro="$3" ready_small="$4" ready_medium="$5" ready_large="$6"
|
||||
local ram_used=0 vram_used=0
|
||||
declare -A plan demand cap_max cpu vram
|
||||
plan[micro]=$MIN_DISPATCH_PER_TIER
|
||||
plan[small]=$MIN_DISPATCH_PER_TIER
|
||||
plan[medium]=$MIN_DISPATCH_PER_TIER
|
||||
plan[large]=$MIN_DISPATCH_PER_TIER
|
||||
demand[micro]=$ready_micro
|
||||
demand[small]=$ready_small
|
||||
demand[medium]=$ready_medium
|
||||
demand[large]=$ready_large
|
||||
# cap_max = lib-tier HARD CEILINGS (not the live caps autoscaler just
|
||||
# wrote). Reading from tier_dispatch would chicken-and-egg: autoscaler
|
||||
# writes cap=1, next iter reads cap_max=1, can never grow back up
|
||||
# even when budget freed. Hardcode ceilings here so a planner always
|
||||
# considers a full feasible range.
|
||||
cap_max[micro]=24
|
||||
cap_max[small]=16
|
||||
cap_max[medium]=12
|
||||
cap_max[large]=6
|
||||
cpu[micro]=$(tier_cpu_mib micro)
|
||||
cpu[small]=$(tier_cpu_mib small)
|
||||
cpu[medium]=$(tier_cpu_mib medium)
|
||||
cpu[large]=$(tier_cpu_mib large)
|
||||
vram[micro]=$(tier_vram_mib micro)
|
||||
vram[small]=$(tier_vram_mib small)
|
||||
vram[medium]=$(tier_vram_mib medium)
|
||||
vram[large]=$(tier_vram_mib large)
|
||||
|
||||
# Pre-charge minimum-per-tier floor.
|
||||
local t
|
||||
for t in micro small medium large; do
|
||||
ram_used=$((ram_used + plan[$t] * cpu[$t]))
|
||||
vram_used=$((vram_used + plan[$t] * vram[$t]))
|
||||
done
|
||||
|
||||
# Greedy fill LARGEST tier first. Smaller tiers drain fast (10-30 s
|
||||
# per cell) so under-allocating them is cheap; large tiers (200+ s
|
||||
# per cell) starve catastrophically — at L=1 a 40-cell large queue
|
||||
# takes ~2 hours to drain. Fill large→medium→small→micro so big
|
||||
# demand gets priority on VRAM budget; small tiers backfill a
|
||||
# remainder + still clear quickly.
|
||||
for t in large medium small micro; do
|
||||
# Skip tiers with no demand.
|
||||
[ "${demand[$t]}" -eq 0 ] && continue
|
||||
local want=$((demand[$t] < cap_max[$t] ? demand[$t] : cap_max[$t]))
|
||||
while [ "${plan[$t]}" -lt "$want" ]; do
|
||||
local next_ram=$((ram_used + cpu[$t]))
|
||||
local next_vram=$((vram_used + vram[$t]))
|
||||
if [ "$next_ram" -gt "$ram_budget" ] || [ "$next_vram" -gt "$vram_budget" ]; then
|
||||
break
|
||||
fi
|
||||
plan[$t]=$((plan[$t] + 1))
|
||||
ram_used=$next_ram
|
||||
vram_used=$next_vram
|
||||
done
|
||||
done
|
||||
|
||||
for t in micro small medium large; do
|
||||
printf 'plan_%s %d\n' "$t" "${plan[$t]}"
|
||||
done
|
||||
printf 'ram_used_mib %d ram_budget_mib %d\n' "$ram_used" "$ram_budget"
|
||||
printf 'vram_used_mib %d vram_budget_mib %d\n' "$vram_used" "$vram_budget"
|
||||
}
|
||||
|
||||
# Read current TIER_*_DISPATCH values from supervisor.config (if present).
|
||||
# Emits: cur_<tier> N
|
||||
read_current_caps() {
|
||||
local cur_micro="${TIER_MICRO_DISPATCH:-0}"
|
||||
local cur_small="${TIER_SMALL_DISPATCH:-0}"
|
||||
local cur_medium="${TIER_MEDIUM_DISPATCH:-0}"
|
||||
local cur_large="${TIER_LARGE_DISPATCH:-0}"
|
||||
local cur_huge="${TIER_HUGE_DISPATCH:-0}"
|
||||
if [ -f "$CONFIG_FILE" ]; then
|
||||
# shellcheck source=/dev/null
|
||||
local v
|
||||
v=$(grep -E '^TIER_MICRO_DISPATCH=' "$CONFIG_FILE" | tail -1 | cut -d= -f2)
|
||||
[ -n "$v" ] && cur_micro="$v"
|
||||
v=$(grep -E '^TIER_SMALL_DISPATCH=' "$CONFIG_FILE" | tail -1 | cut -d= -f2)
|
||||
[ -n "$v" ] && cur_small="$v"
|
||||
v=$(grep -E '^TIER_MEDIUM_DISPATCH=' "$CONFIG_FILE" | tail -1 | cut -d= -f2)
|
||||
[ -n "$v" ] && cur_medium="$v"
|
||||
v=$(grep -E '^TIER_LARGE_DISPATCH=' "$CONFIG_FILE" | tail -1 | cut -d= -f2)
|
||||
[ -n "$v" ] && cur_large="$v"
|
||||
v=$(grep -E '^TIER_HUGE_DISPATCH=' "$CONFIG_FILE" | tail -1 | cut -d= -f2)
|
||||
[ -n "$v" ] && cur_huge="$v"
|
||||
fi
|
||||
printf 'cur_micro %d\ncur_small %d\ncur_medium %d\ncur_large %d\ncur_huge %d\n' \
|
||||
"$cur_micro" "$cur_small" "$cur_medium" "$cur_large" "$cur_huge"
|
||||
}
|
||||
|
||||
# Atomically rewrite supervisor.config preserving non-TIER lines + replacing
|
||||
# TIER_<NAME>_DISPATCH = plan_<name>. Writes through a tempfile so a partial
|
||||
# write can't poison a supervisor's source.
|
||||
write_caps() {
|
||||
local micro="$1" small="$2" medium="$3" large="$4" huge="$5"
|
||||
local tmp
|
||||
tmp="$(mktemp "${CONFIG_FILE}.XXXXXX")"
|
||||
if [ -f "$CONFIG_FILE" ]; then
|
||||
grep -vE '^TIER_(MICRO|SMALL|MEDIUM|LARGE|HUGE)_DISPATCH=' "$CONFIG_FILE" > "$tmp"
|
||||
fi
|
||||
printf 'TIER_MICRO_DISPATCH=%d\nTIER_SMALL_DISPATCH=%d\nTIER_MEDIUM_DISPATCH=%d\nTIER_LARGE_DISPATCH=%d\nTIER_HUGE_DISPATCH=%d\n' \
|
||||
"$micro" "$small" "$medium" "$large" "$huge" >> "$tmp"
|
||||
mv "$tmp" "$CONFIG_FILE"
|
||||
}
|
||||
|
||||
# ── V2 controller ────────────────────────────────────────────────
|
||||
# All V2 helpers namespaced v2_* so V1 code paths stay readable.
|
||||
|
||||
# v2_sample_live — query current VRAM + backend forks + peak per fork +
|
||||
# count of in-flight HUGE bins + RAM pressure indicators.
|
||||
# Emits:
|
||||
# vram_free_mib N
|
||||
# ram_free_mib N (MemAvailable in MIB)
|
||||
# ram_total_mib N (MemTotal in MIB — for pressure fraction calc)
|
||||
# swap_used_mib N (SwapTotal - SwapFree in MIB)
|
||||
# running_forks N
|
||||
# observed_peak_mib N (max VRAM use among running backend forks)
|
||||
# observed_ram_peak_mib N (max RSS among running backend forks — separate
|
||||
# from VRAM since RSS = 1.5-2x VRAM typically)
|
||||
# observed_ram_sum_mib N (sum RSS — used to bound headroom_ram + log)
|
||||
# inflight_huge N (count of HUGE bins claimed by a dispatcher worker
|
||||
# but not yet .done)
|
||||
v2_sample_live() {
|
||||
local vram_free=0 ram_free_kb=0 ram_free=0 ram_total_kb=0 ram_total=0
|
||||
local swap_total_kb=0 swap_free_kb=0 swap_used=0
|
||||
local running=0 peak=0 ram_peak=0 ram_sum=0 inflight_huge=0
|
||||
if command -v nvidia-smi >/dev/null 2>&1; then
|
||||
vram_free=$(nvidia-smi --query-gpu=memory.free --format=csv,noheader,nounits 2>/dev/null | head -1 | tr -dc 0-9)
|
||||
[ -z "$vram_free" ] && vram_free=0
|
||||
fi
|
||||
ram_free_kb=$(awk '/^MemAvailable:/ {print $2}' /proc/meminfo 2>/dev/null || echo 0)
|
||||
ram_total_kb=$(awk '/^MemTotal:/ {print $2}' /proc/meminfo 2>/dev/null || echo 0)
|
||||
swap_total_kb=$(awk '/^SwapTotal:/ {print $2}' /proc/meminfo 2>/dev/null || echo 0)
|
||||
swap_free_kb=$(awk '/^SwapFree:/ {print $2}' /proc/meminfo 2>/dev/null || echo 0)
|
||||
ram_free=$((ram_free_kb / 1024))
|
||||
ram_total=$((ram_total_kb / 1024))
|
||||
swap_used=$(( (swap_total_kb - swap_free_kb) / 1024 ))
|
||||
running=$(pgrep -f "$BACKEND_PROC_PATTERN" 2>/dev/null | wc -l)
|
||||
if [ "$running" -gt 0 ]; then
|
||||
# VRAM peak via nvidia-smi
|
||||
if command -v nvidia-smi >/dev/null 2>&1; then
|
||||
peak=$(nvidia-smi --query-compute-apps=used_memory --format=csv,noheader,nounits 2>/dev/null \
|
||||
| tr -dc '0-9\n' | grep -v '^$' | sort -n | tail -1)
|
||||
[ -z "$peak" ] && peak=0
|
||||
fi
|
||||
# RAM peak + sum via ps RSS — independent of VRAM since backend RSS
|
||||
# = ops table + ancilla buffers + CUDA driver context, scales with bin
|
||||
# size differently than GPU side does.
|
||||
local rss_list
|
||||
rss_list=$(pgrep -f "$BACKEND_PROC_PATTERN" 2>/dev/null | xargs -r -I{} ps -o rss= -p {} 2>/dev/null | tr -d ' ')
|
||||
if [ -n "$rss_list" ]; then
|
||||
ram_peak=$(echo "$rss_list" | sort -n | tail -1)
|
||||
ram_peak=$((ram_peak / 1024)) # kB → MIB
|
||||
ram_sum=$(echo "$rss_list" | awk '{s+=$1} END {print s}')
|
||||
ram_sum=$((ram_sum / 1024))
|
||||
fi
|
||||
fi
|
||||
# Count in-flight HUGE bins.
|
||||
shopt -s nullglob
|
||||
local f tag bin sz
|
||||
for f in "$QUEUE_DIR"/*.inflight-*; do
|
||||
[ ! -f "$f" ] && continue
|
||||
tag=$(basename "$f" | sed 's/\.inflight-[0-9]*$//')
|
||||
bin="$QUEUE_DIR/${tag}.bin"
|
||||
[ ! -f "$bin" ] && continue
|
||||
sz=$(stat -c %s "$bin" 2>/dev/null || echo 0)
|
||||
if [ "$sz" -gt "${TIER_LARGE_MAX:-4294967296}" ]; then
|
||||
inflight_huge=$((inflight_huge + 1))
|
||||
fi
|
||||
done
|
||||
shopt -u nullglob
|
||||
printf 'vram_free_mib %d\nram_free_mib %d\nram_total_mib %d\nswap_used_mib %d\nrunning_forks %d\nobserved_peak_mib %d\nobserved_ram_peak_mib %d\nobserved_ram_sum_mib %d\ninflight_huge %d\n' \
|
||||
"$vram_free" "$ram_free" "$ram_total" "$swap_used" "$running" "$peak" "$ram_peak" "$ram_sum" "$inflight_huge"
|
||||
}
|
||||
|
||||
# v2_update_ewma — read prior peak from autoscaler.ewma, fold in new sample.
|
||||
# Bias up (max) for safety so under-estimate is impossible. Floor at
|
||||
# AUTOSCALER_PEAK_MIN to avoid runaway shrink when zero forks observed.
|
||||
# Prints new peak.
|
||||
v2_update_ewma() {
|
||||
local observed_peak="$1"
|
||||
local ewma_file="$QUEUE_DIR/autoscaler.ewma"
|
||||
local old_peak="$AUTOSCALER_PEAK_INITIAL"
|
||||
if [ -f "$ewma_file" ]; then
|
||||
local v
|
||||
v=$(grep -E '^peak_per_worker_mib=' "$ewma_file" | tail -1 | cut -d= -f2)
|
||||
[ -n "$v" ] && old_peak="$v"
|
||||
fi
|
||||
local new_peak
|
||||
if [ "$observed_peak" -gt 0 ]; then
|
||||
new_peak=$(awk -v old="$old_peak" -v obs="$observed_peak" -v a="$AUTOSCALER_EWMA_ALPHA" \
|
||||
'BEGIN { smoothed = old*(1-a) + obs*a;
|
||||
if (obs > smoothed) smoothed = obs;
|
||||
printf "%d", smoothed }')
|
||||
else
|
||||
new_peak="$old_peak"
|
||||
fi
|
||||
[ "$new_peak" -lt "$AUTOSCALER_PEAK_MIN" ] && new_peak="$AUTOSCALER_PEAK_MIN"
|
||||
{
|
||||
echo "peak_per_worker_mib=$new_peak"
|
||||
echo "last_updated=$(date -u +%FT%TZ)"
|
||||
echo "last_observed_mib=$observed_peak"
|
||||
} > "$ewma_file"
|
||||
echo "$new_peak"
|
||||
}
|
||||
|
||||
# v2_update_ram_ewma — same shape as v2_update_ewma but for RAM peak.
|
||||
# Separate file (autoscaler.ram-ewma) so VRAM + RAM tracks stay independent.
|
||||
v2_update_ram_ewma() {
|
||||
local observed_peak="$1"
|
||||
local ewma_file="$QUEUE_DIR/autoscaler.ram-ewma"
|
||||
local old_peak="$AUTOSCALER_RAM_PEAK_INITIAL"
|
||||
if [ -f "$ewma_file" ]; then
|
||||
local v
|
||||
v=$(grep -E '^ram_peak_per_worker_mib=' "$ewma_file" | tail -1 | cut -d= -f2)
|
||||
[ -n "$v" ] && old_peak="$v"
|
||||
fi
|
||||
local new_peak
|
||||
if [ "$observed_peak" -gt 0 ]; then
|
||||
new_peak=$(awk -v old="$old_peak" -v obs="$observed_peak" -v a="$AUTOSCALER_EWMA_ALPHA" \
|
||||
'BEGIN { smoothed = old*(1-a) + obs*a;
|
||||
if (obs > smoothed) smoothed = obs;
|
||||
printf "%d", smoothed }')
|
||||
else
|
||||
new_peak="$old_peak"
|
||||
fi
|
||||
[ "$new_peak" -lt "$AUTOSCALER_RAM_PEAK_MIN" ] && new_peak="$AUTOSCALER_RAM_PEAK_MIN"
|
||||
{
|
||||
echo "ram_peak_per_worker_mib=$new_peak"
|
||||
echo "last_updated=$(date -u +%FT%TZ)"
|
||||
echo "last_observed_mib=$observed_peak"
|
||||
} > "$ewma_file"
|
||||
echo "$new_peak"
|
||||
}
|
||||
|
||||
# v2_swap_pressure — read prior swap_used from autoscaler.swap-state,
|
||||
# diff against current. Returns 1 if SwapUsed grew > threshold MIB in
|
||||
# last poll, else 0. State file rewritten each call.
|
||||
v2_swap_pressure() {
|
||||
local cur_swap_used="$1"
|
||||
local state="$QUEUE_DIR/autoscaler.swap-state"
|
||||
local prev_swap=0
|
||||
if [ -f "$state" ]; then
|
||||
prev_swap=$(grep -E '^swap_used_mib=' "$state" | tail -1 | cut -d= -f2)
|
||||
[ -z "$prev_swap" ] && prev_swap=0
|
||||
fi
|
||||
local delta=$((cur_swap_used - prev_swap))
|
||||
echo "swap_used_mib=$cur_swap_used" > "$state"
|
||||
if [ "$delta" -gt "$AUTOSCALER_SWAP_PRESSURE_DELTA_MIB" ]; then
|
||||
echo 1
|
||||
else
|
||||
echo 0
|
||||
fi
|
||||
}
|
||||
|
||||
# v2_write_pool_advisory — emit advisory file the pool reads each iter.
|
||||
# Format: KEY=VALUE lines. Pool can act on:
|
||||
# slow_emit=1|0 — when 1, pool should reduce concurrent emits
|
||||
# reason=ram_pressure|swap_active|none
|
||||
v2_write_pool_advisory() {
|
||||
local slow="$1" reason="$2"
|
||||
{
|
||||
echo "# bend-autoscaler advisory — last update $(date -u +%FT%TZ)"
|
||||
echo "slow_emit=$slow"
|
||||
echo "reason=$reason"
|
||||
} > "$QUEUE_DIR/emit.advisory"
|
||||
}
|
||||
|
||||
# v2_dlq_growth — count DLQ reason files mtime'd in last $1 seconds.
|
||||
# Used as oversubscription signal: spike in DLQ → halve next plan.
|
||||
v2_dlq_growth() {
|
||||
local poll="$1"
|
||||
local mins=$((poll / 60 + 1))
|
||||
find "$QUEUE_DIR/dlq" -maxdepth 1 -type f -name '*.reason' -mmin -"$mins" 2>/dev/null | wc -l
|
||||
}
|
||||
|
||||
# v2_damp_change — limit per-tier rate of change to ±25 % per poll.
|
||||
# Cold start (cur=0) ramps up to AUTOSCALER_RAMP_FROM_ZERO in one step.
|
||||
v2_damp_change() {
|
||||
local cur="$1" plan="$2"
|
||||
if [ "$cur" = "0" ]; then
|
||||
if [ "$plan" -gt "$AUTOSCALER_RAMP_FROM_ZERO" ]; then
|
||||
echo "$AUTOSCALER_RAMP_FROM_ZERO"
|
||||
else
|
||||
echo "$plan"
|
||||
fi
|
||||
return
|
||||
fi
|
||||
local max_step=$((cur / 4))
|
||||
[ "$max_step" -lt 1 ] && max_step=1
|
||||
if [ "$plan" -gt "$cur" ]; then
|
||||
local up=$((cur + max_step))
|
||||
[ "$plan" -gt "$up" ] && plan="$up"
|
||||
elif [ "$plan" -lt "$cur" ]; then
|
||||
local dn=$((cur - max_step))
|
||||
[ "$plan" -lt "$dn" ] && plan="$dn"
|
||||
fi
|
||||
echo "$plan"
|
||||
}
|
||||
|
||||
# v2_plan_caps — live-state planner.
|
||||
# Args (positional):
|
||||
# 1 vram_free_mib 2 ram_free_mib 3..7 ready_micro/small/medium/large/huge
|
||||
# 8..12 cur_micro/small/medium/large/huge 13 peak_per_worker_mib
|
||||
# 14 running_forks 15 dlq_growth 16 inflight_huge
|
||||
# 17 ram_peak_per_worker_mib 18 ram_total_mib 19 swap_pressure
|
||||
# Emits plan_<tier> N + diagnostics (headroom_workers, total_budget, dlq_damped,
|
||||
# huge_active, ram_damped — 1 when RAM pressure halved plan).
|
||||
v2_plan_caps() {
|
||||
local vram_free="$1" ram_free="$2"
|
||||
local r_micro="$3" r_small="$4" r_medium="$5" r_large="$6" r_huge="$7"
|
||||
local c_micro="$8" c_small="$9" c_medium="${10}" c_large="${11}" c_huge="${12}"
|
||||
local peak="${13}" running="${14}" dlq_growth="${15}" inflight_huge="${16:-0}"
|
||||
local ram_peak="${17:-$peak}" ram_total="${18:-0}" swap_pressure="${19:-0}"
|
||||
|
||||
# Headroom workers from VRAM. Safety reserve protects against bursty
|
||||
# forks landing all at once + saturating GPU mid-poll-period.
|
||||
local vram_after_reserve=$((vram_free - AUTOSCALER_VRAM_SAFETY_RESERVE_MIB))
|
||||
[ "$vram_after_reserve" -lt 0 ] && vram_after_reserve=0
|
||||
local headroom_vram=$((vram_after_reserve / peak))
|
||||
# RAM bound — backend RSS = 1.5-2× VRAM typically (lib-tier.sh:48-49).
|
||||
# Use SEPARATE ram_peak EWMA + ram_safety_reserve so headroom_ram stays
|
||||
# honest. Old code shared VRAM peak which OVER-estimated RAM headroom
|
||||
# + edged into swap on heavy sweep loads.
|
||||
local ram_after_reserve=$((ram_free - AUTOSCALER_RAM_SAFETY_RESERVE_MIB))
|
||||
[ "$ram_after_reserve" -lt 0 ] && ram_after_reserve=0
|
||||
local headroom_ram=$((ram_after_reserve / ram_peak))
|
||||
local headroom=$headroom_vram
|
||||
[ "$headroom_ram" -lt "$headroom" ] && headroom=$headroom_ram
|
||||
|
||||
# Total budget = workers we COULD have, given current load + headroom.
|
||||
local total_budget=$((running + headroom))
|
||||
# Cap at CPU ceiling (24 cores + modest oversub).
|
||||
[ "$total_budget" -gt "$AUTOSCALER_CPU_CEILING" ] && total_budget=$AUTOSCALER_CPU_CEILING
|
||||
|
||||
# RAM-pressure damping — fires when MemAvailable < AUTOSCALER_RAM_PRESSURE_FRACTION
|
||||
# of total OR active swap (SwapUsed grew > delta MIB last poll). Either
|
||||
# signal means we sit at swap edge; halving total_budget reduces new
|
||||
# admissions so running workers can drain + RAM recovers before swap thrash.
|
||||
local ram_damped=0
|
||||
local ram_pressure_threshold=$((ram_total * 15 / 100)) # 15% default; matches RAM_PRESSURE_FRACTION
|
||||
if [ "$ram_total" -gt 0 ] && [ "$ram_free" -lt "$ram_pressure_threshold" ]; then
|
||||
total_budget=$((total_budget / 2))
|
||||
ram_damped=1
|
||||
fi
|
||||
if [ "$swap_pressure" -eq 1 ]; then
|
||||
total_budget=$((total_budget / 2))
|
||||
ram_damped=1
|
||||
fi
|
||||
|
||||
# DLQ damping — surge of DLQ entries means we already over-subscribed
|
||||
# OR we have a bad-cell batch. Either way, halving caps next poll
|
||||
# protects against amplification while operator triages.
|
||||
local dlq_damped=0
|
||||
if [ "$dlq_growth" -gt "$AUTOSCALER_DLQ_DAMPING_THRESHOLD" ]; then
|
||||
total_budget=$((total_budget / 2))
|
||||
dlq_damped=1
|
||||
fi
|
||||
|
||||
# HUGE solo-dispatch branch — must run before normal planning. Triggers:
|
||||
# (a) r_huge>0 : huge bin queued, waiting for card to drain.
|
||||
# (b) inflight_huge>0 : a huge already claimed by a dispatcher, still
|
||||
# executing. Other tiers must stay starved until
|
||||
# dispatcher releases this slot.
|
||||
# cur_huge alone is NOT a trigger — stale supervisor.config can hold
|
||||
# TIER_HUGE_DISPATCH=1 indefinitely with no bin actually running.
|
||||
local plan_micro=0 plan_small=0 plan_medium=0 plan_large=0 plan_huge=0
|
||||
local huge_active=0
|
||||
if [ "$r_huge" -gt 0 ] || [ "$inflight_huge" -gt 0 ]; then
|
||||
huge_active=1
|
||||
# Card-empty? Admit huge. "card-empty" signal is running=0
|
||||
# OR inflight_huge>0 (huge already running counts as "card claimed
|
||||
# by huge, keep slot open for it to continue").
|
||||
if [ "$running" -eq 0 ] || [ "$inflight_huge" -gt 0 ]; then
|
||||
plan_huge="$TIER_HUGE_DISPATCH_CEILING"
|
||||
fi
|
||||
# plan_others stays at 0 regardless — drain mode. Damping at the
|
||||
# tail rate-limits descent so already-running cells finish
|
||||
# naturally without abrupt kill.
|
||||
# Emit + return — skip normal multi-tier planning.
|
||||
plan_micro=$(v2_damp_change "$c_micro" "$plan_micro")
|
||||
plan_small=$(v2_damp_change "$c_small" "$plan_small")
|
||||
plan_medium=$(v2_damp_change "$c_medium" "$plan_medium")
|
||||
plan_large=$(v2_damp_change "$c_large" "$plan_large")
|
||||
plan_huge=$(v2_damp_change "$c_huge" "$plan_huge")
|
||||
printf 'plan_micro %d\nplan_small %d\nplan_medium %d\nplan_large %d\nplan_huge %d\n' \
|
||||
"$plan_micro" "$plan_small" "$plan_medium" "$plan_large" "$plan_huge"
|
||||
printf 'headroom_workers %d running_forks %d total_budget %d peak_per_worker_mib %d ram_peak_mib %d dlq_damped %d ram_damped %d huge_active %d\n' \
|
||||
"$headroom" "$running" "$total_budget" "$peak" "$ram_peak" "$dlq_damped" "$ram_damped" "$huge_active"
|
||||
return
|
||||
fi
|
||||
|
||||
# Tier demand sentinels — V2 zero-floor (V1 forced MIN=1 even on empty).
|
||||
local nonzero=0
|
||||
[ "$r_micro" -gt 0 ] && nonzero=$((nonzero + 1))
|
||||
[ "$r_small" -gt 0 ] && nonzero=$((nonzero + 1))
|
||||
[ "$r_medium" -gt 0 ] && nonzero=$((nonzero + 1))
|
||||
[ "$r_large" -gt 0 ] && nonzero=$((nonzero + 1))
|
||||
|
||||
if [ "$nonzero" -le 1 ]; then
|
||||
# Single-tier (or no-demand) — grant full budget to one tier
|
||||
# with work. Capped at that tier's ceiling.
|
||||
if [ "$r_large" -gt 0 ]; then
|
||||
plan_large=$((total_budget < TIER_LARGE_DISPATCH_CEILING ? total_budget : TIER_LARGE_DISPATCH_CEILING))
|
||||
elif [ "$r_medium" -gt 0 ]; then
|
||||
plan_medium=$((total_budget < TIER_MEDIUM_DISPATCH_CEILING ? total_budget : TIER_MEDIUM_DISPATCH_CEILING))
|
||||
elif [ "$r_small" -gt 0 ]; then
|
||||
plan_small=$((total_budget < TIER_SMALL_DISPATCH_CEILING ? total_budget : TIER_SMALL_DISPATCH_CEILING))
|
||||
elif [ "$r_micro" -gt 0 ]; then
|
||||
plan_micro=$((total_budget < TIER_MICRO_DISPATCH_CEILING ? total_budget : TIER_MICRO_DISPATCH_CEILING))
|
||||
fi
|
||||
else
|
||||
# Multi-tier — MIN=1 to each tier with demand, then greedy fill
|
||||
# largest-tier-first (slow drains get priority on capacity).
|
||||
local remaining=$total_budget
|
||||
[ "$r_large" -gt 0 ] && [ "$remaining" -gt 0 ] && { plan_large=1; remaining=$((remaining - 1)); }
|
||||
[ "$r_medium" -gt 0 ] && [ "$remaining" -gt 0 ] && { plan_medium=1; remaining=$((remaining - 1)); }
|
||||
[ "$r_small" -gt 0 ] && [ "$remaining" -gt 0 ] && { plan_small=1; remaining=$((remaining - 1)); }
|
||||
[ "$r_micro" -gt 0 ] && [ "$remaining" -gt 0 ] && { plan_micro=1; remaining=$((remaining - 1)); }
|
||||
# Greedy: largest tier with demand and below ceiling consumes 1 slot per pass.
|
||||
while [ "$remaining" -gt 0 ]; do
|
||||
local advanced=0
|
||||
if [ "$plan_large" -lt "$TIER_LARGE_DISPATCH_CEILING" ] && [ "$r_large" -gt "$plan_large" ]; then
|
||||
plan_large=$((plan_large + 1)); remaining=$((remaining - 1)); advanced=1
|
||||
[ "$remaining" -eq 0 ] && break
|
||||
fi
|
||||
if [ "$plan_medium" -lt "$TIER_MEDIUM_DISPATCH_CEILING" ] && [ "$r_medium" -gt "$plan_medium" ]; then
|
||||
plan_medium=$((plan_medium + 1)); remaining=$((remaining - 1)); advanced=1
|
||||
[ "$remaining" -eq 0 ] && break
|
||||
fi
|
||||
if [ "$plan_small" -lt "$TIER_SMALL_DISPATCH_CEILING" ] && [ "$r_small" -gt "$plan_small" ]; then
|
||||
plan_small=$((plan_small + 1)); remaining=$((remaining - 1)); advanced=1
|
||||
[ "$remaining" -eq 0 ] && break
|
||||
fi
|
||||
if [ "$plan_micro" -lt "$TIER_MICRO_DISPATCH_CEILING" ] && [ "$r_micro" -gt "$plan_micro" ]; then
|
||||
plan_micro=$((plan_micro + 1)); remaining=$((remaining - 1)); advanced=1
|
||||
[ "$remaining" -eq 0 ] && break
|
||||
fi
|
||||
[ "$advanced" -eq 0 ] && break
|
||||
done
|
||||
fi
|
||||
|
||||
# Rate-of-change damping — ±25 % per poll. Prevents oscillation when
|
||||
# demand or VRAM headroom stays bouncy.
|
||||
plan_micro=$(v2_damp_change "$c_micro" "$plan_micro")
|
||||
plan_small=$(v2_damp_change "$c_small" "$plan_small")
|
||||
plan_medium=$(v2_damp_change "$c_medium" "$plan_medium")
|
||||
plan_large=$(v2_damp_change "$c_large" "$plan_large")
|
||||
|
||||
# Post-damp CPU ceiling — damping toward a HIGHER cur can keep total
|
||||
# above ceiling. Trim largest tiers first (cheapest to lose a slot at
|
||||
# slow-drain end). Repeat until within budget.
|
||||
plan_sum=$((plan_micro + plan_small + plan_medium + plan_large))
|
||||
while [ "$plan_sum" -gt "$AUTOSCALER_CPU_CEILING" ]; do
|
||||
if [ "$plan_large" -gt 0 ]; then plan_large=$((plan_large - 1))
|
||||
elif [ "$plan_medium" -gt 0 ]; then plan_medium=$((plan_medium - 1))
|
||||
elif [ "$plan_small" -gt 0 ]; then plan_small=$((plan_small - 1))
|
||||
elif [ "$plan_micro" -gt 0 ]; then plan_micro=$((plan_micro - 1))
|
||||
else break
|
||||
fi
|
||||
plan_sum=$((plan_micro + plan_small + plan_medium + plan_large))
|
||||
done
|
||||
|
||||
# plan_huge always 0 in non-huge branch (handled above + returned early).
|
||||
printf 'plan_micro %d\nplan_small %d\nplan_medium %d\nplan_large %d\nplan_huge %d\n' \
|
||||
"$plan_micro" "$plan_small" "$plan_medium" "$plan_large" 0
|
||||
printf 'headroom_workers %d running_forks %d total_budget %d peak_per_worker_mib %d ram_peak_mib %d dlq_damped %d ram_damped %d huge_active %d\n' \
|
||||
"$headroom" "$running" "$total_budget" "$peak" "$ram_peak" "$dlq_damped" "$ram_damped" "$huge_active"
|
||||
}
|
||||
|
||||
# ── exit handlers ────────────────────────────────────────────────
|
||||
EXIT_REASON=""
|
||||
on_exit() {
|
||||
local r="${EXIT_REASON:-unknown-exit}"
|
||||
heartbeat_exit_cause "$QUEUE_DIR" "autoscaler" "$r"
|
||||
echo "[$(date +%H:%M:%S)] bend-autoscaler exit reason=$r" | tee -a "$LOG"
|
||||
}
|
||||
trap on_exit EXIT
|
||||
trap 'EXIT_REASON=signal-int; exit 130' INT
|
||||
trap 'EXIT_REASON=signal-term; exit 143' TERM
|
||||
|
||||
log "bend-autoscaler start queue=$QUEUE_DIR poll=${POLL_SECONDS}s v2=$AUTOSCALER_V2"
|
||||
if [ "$AUTOSCALER_V2" = "1" ]; then
|
||||
log " V2 live model — peak_init=${AUTOSCALER_PEAK_INITIAL}MIB cpu_ceiling=$AUTOSCALER_CPU_CEILING dlq_threshold=$AUTOSCALER_DLQ_DAMPING_THRESHOLD"
|
||||
else
|
||||
log " V1 fixed-budget — RAM frac=$RAM_BUDGET_FRACTION VRAM frac=$VRAM_BUDGET_FRACTION"
|
||||
fi
|
||||
|
||||
# Cold-recovery baseline — persist KNOWN-GOOD caps so a cold restart of
|
||||
# every component (autoscaler crash + supervisor restart with stale
|
||||
# config) can fall back to a safe operating point. Written each poll
|
||||
# after a successful plan.
|
||||
BASELINE_FILE="$QUEUE_DIR/supervisor.config.baseline"
|
||||
|
||||
while true; do
|
||||
heartbeat_touch "$QUEUE_DIR" "autoscaler"
|
||||
if [ -f "$STOP_FILE" ]; then
|
||||
log "STOP marker — exiting"
|
||||
rm -f "$STOP_FILE"
|
||||
EXIT_REASON="stop-marker"
|
||||
break
|
||||
fi
|
||||
|
||||
ready_out=$(count_ready_by_tier)
|
||||
ready_micro=$(echo "$ready_out" | awk '/^ready_micro/ {print $2}')
|
||||
ready_small=$(echo "$ready_out" | awk '/^ready_small/ {print $2}')
|
||||
ready_medium=$(echo "$ready_out" | awk '/^ready_medium/ {print $2}')
|
||||
ready_large=$(echo "$ready_out" | awk '/^ready_large/ {print $2}')
|
||||
ready_huge=$(echo "$ready_out" | awk '/^ready_huge/ {print $2}')
|
||||
total_ready=$((ready_micro + ready_small + ready_medium + ready_large + ready_huge))
|
||||
|
||||
cur_out=$(read_current_caps)
|
||||
cur_micro=$(echo "$cur_out" | awk '/^cur_micro/ {print $2}')
|
||||
cur_small=$(echo "$cur_out" | awk '/^cur_small/ {print $2}')
|
||||
cur_medium=$(echo "$cur_out" | awk '/^cur_medium/ {print $2}')
|
||||
cur_large=$(echo "$cur_out" | awk '/^cur_large/ {print $2}')
|
||||
cur_huge=$(echo "$cur_out" | awk '/^cur_huge/ {print $2}')
|
||||
|
||||
if [ "$AUTOSCALER_V2" = "1" ]; then
|
||||
live_out=$(v2_sample_live)
|
||||
vram_free_mib=$(echo "$live_out" | awk '/^vram_free_mib/ {print $2}')
|
||||
ram_free_mib=$(echo "$live_out" | awk '/^ram_free_mib/ {print $2}')
|
||||
ram_total_mib=$(echo "$live_out" | awk '/^ram_total_mib/ {print $2}')
|
||||
swap_used_mib=$(echo "$live_out" | awk '/^swap_used_mib/ {print $2}')
|
||||
running_forks=$(echo "$live_out" | awk '/^running_forks/ {print $2}')
|
||||
observed_peak_mib=$(echo "$live_out" | awk '/^observed_peak_mib/ {print $2}')
|
||||
observed_ram_peak_mib=$(echo "$live_out" | awk '/^observed_ram_peak_mib/ {print $2}')
|
||||
inflight_huge=$(echo "$live_out" | awk '/^inflight_huge/ {print $2}')
|
||||
peak_per_worker_mib=$(v2_update_ewma "$observed_peak_mib")
|
||||
ram_peak_per_worker_mib=$(v2_update_ram_ewma "$observed_ram_peak_mib")
|
||||
dlq_growth=$(v2_dlq_growth "$POLL_SECONDS")
|
||||
swap_pressure=$(v2_swap_pressure "$swap_used_mib")
|
||||
|
||||
plan_out=$(v2_plan_caps \
|
||||
"$vram_free_mib" "$ram_free_mib" \
|
||||
"$ready_micro" "$ready_small" "$ready_medium" "$ready_large" "$ready_huge" \
|
||||
"$cur_micro" "$cur_small" "$cur_medium" "$cur_large" "$cur_huge" \
|
||||
"$peak_per_worker_mib" "$running_forks" "$dlq_growth" "$inflight_huge" \
|
||||
"$ram_peak_per_worker_mib" "$ram_total_mib" "$swap_pressure")
|
||||
# Write pool advisory based on planner verdict — pool reads each iter.
|
||||
# ram_damped emitted by planner; reuse as advisory trigger.
|
||||
ram_pressure_active=$(echo "$plan_out" | awk 'match($0, /ram_damped [0-9]+/) {print substr($0, RSTART+11, RLENGTH-11)}')
|
||||
if [ "${ram_pressure_active:-0}" -eq 1 ]; then
|
||||
reason="ram_pressure"
|
||||
[ "$swap_pressure" -eq 1 ] && reason="swap_active"
|
||||
v2_write_pool_advisory 1 "$reason"
|
||||
else
|
||||
v2_write_pool_advisory 0 "none"
|
||||
fi
|
||||
diag=$(echo "$plan_out" | grep -E '^headroom_workers')
|
||||
else
|
||||
cap_out=$(read_capacity)
|
||||
total_ram_mib=$(echo "$cap_out" | awk '/^total_ram_mib/ {print $2}')
|
||||
total_vram_mib=$(echo "$cap_out" | awk '/^total_vram_mib/ {print $2}')
|
||||
ram_budget=$(awk -v t="$total_ram_mib" -v f="$RAM_BUDGET_FRACTION" 'BEGIN{printf "%d", t*f}')
|
||||
vram_budget=$(awk -v t="$total_vram_mib" -v f="$VRAM_BUDGET_FRACTION" 'BEGIN{printf "%d", t*f}')
|
||||
plan_out=$(plan_caps "$ram_budget" "$vram_budget" \
|
||||
"$ready_micro" "$ready_small" "$ready_medium" "$ready_large")
|
||||
diag=""
|
||||
fi
|
||||
|
||||
plan_micro=$(echo "$plan_out" | awk '/^plan_micro/ {print $2}')
|
||||
plan_small=$(echo "$plan_out" | awk '/^plan_small/ {print $2}')
|
||||
plan_medium=$(echo "$plan_out" | awk '/^plan_medium/ {print $2}')
|
||||
plan_large=$(echo "$plan_out" | awk '/^plan_large/ {print $2}')
|
||||
# V1 doesn't emit plan_huge — treat as zero so write_caps still gets 5 args.
|
||||
plan_huge=$(echo "$plan_out" | awk '/^plan_huge/ {print $2}')
|
||||
[ -z "$plan_huge" ] && plan_huge=0
|
||||
|
||||
# Plan-change log — fires only on a transition. Operators read this to
|
||||
# see "what caused this dispatch shift".
|
||||
if [ "$cur_micro" != "$plan_micro" ] || [ "$cur_small" != "$plan_small" ] || \
|
||||
[ "$cur_medium" != "$plan_medium" ] || [ "$cur_large" != "$plan_large" ] || \
|
||||
[ "$cur_huge" != "$plan_huge" ]; then
|
||||
if [ -n "$diag" ]; then
|
||||
log "ready=$total_ready (μ$ready_micro s$ready_small m$ready_medium L$ready_large H$ready_huge) cur=(μ$cur_micro s$cur_small m$cur_medium L$cur_large H$cur_huge) -> plan=(μ$plan_micro s$plan_small m$plan_medium L$plan_large H$plan_huge) $diag"
|
||||
else
|
||||
log "ready=$total_ready (μ$ready_micro s$ready_small m$ready_medium L$ready_large H$ready_huge) cur=(μ$cur_micro s$cur_small m$cur_medium L$cur_large H$cur_huge) -> plan=(μ$plan_micro s$plan_small m$plan_medium L$plan_large H$plan_huge)"
|
||||
fi
|
||||
write_caps "$plan_micro" "$plan_small" "$plan_medium" "$plan_large" "$plan_huge"
|
||||
else
|
||||
# Stable-plan heartbeat — fires every AUTOSCALER_STABLE_HEARTBEAT_POLLS
|
||||
# polls (default 6 = 2 min at poll=20s) when plan does NOT change.
|
||||
# Without this, a long drain phase produces no log output, making
|
||||
# the autoscaler look frozen to operators reading autoscaler.log
|
||||
# while it actually polls silently.
|
||||
STABLE_POLLS=$((${STABLE_POLLS:-0} + 1))
|
||||
if [ "$STABLE_POLLS" -ge "${AUTOSCALER_STABLE_HEARTBEAT_POLLS:-6}" ]; then
|
||||
log "STABLE plan=(μ$plan_micro s$plan_small m$plan_medium L$plan_large H$plan_huge) $diag"
|
||||
STABLE_POLLS=0
|
||||
fi
|
||||
fi
|
||||
|
||||
# Persist baseline for cold-recovery — only when plan looks healthy
|
||||
# (total >= AUTOSCALER_CPU_CEILING/4 to filter throttled states).
|
||||
# HUGE counts toward total though its cap stays solo-1.
|
||||
plan_total=$((plan_micro + plan_small + plan_medium + plan_large + plan_huge))
|
||||
if [ "$plan_total" -ge $((AUTOSCALER_CPU_CEILING / 4)) ]; then
|
||||
{
|
||||
echo "# bend-autoscaler last-known-good caps — written $(date -u +%FT%TZ)"
|
||||
echo "# Source for cold recovery: supervisor falls back to this on autoscaler stall."
|
||||
printf 'TIER_MICRO_DISPATCH=%d\nTIER_SMALL_DISPATCH=%d\nTIER_MEDIUM_DISPATCH=%d\nTIER_LARGE_DISPATCH=%d\nTIER_HUGE_DISPATCH=%d\n' \
|
||||
"$plan_micro" "$plan_small" "$plan_medium" "$plan_large" "$plan_huge"
|
||||
} > "$BASELINE_FILE"
|
||||
fi
|
||||
|
||||
sleep "$POLL_SECONDS"
|
||||
done
|
||||
192
factory/bend-dispatcher.sh
Executable file
192
factory/bend-dispatcher.sh
Executable file
|
|
@ -0,0 +1,192 @@
|
|||
#!/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
|
||||
'
|
||||
502
factory/bend-emit-pool.sh
Executable file
502
factory/bend-emit-pool.sh
Executable file
|
|
@ -0,0 +1,502 @@
|
|||
#!/usr/bin/env bash
|
||||
# bend-emit-pool.sh — bounded parallel emit pool for circuit-emission cells.
|
||||
#
|
||||
# Reads work items from $LUMBDA_QUEUE_DIR/*.lsp. For each item <tag>:
|
||||
# - skips if <tag>.ready or <tag>.done exists
|
||||
# - skips if <tag>.emitting marker exists (in-flight)
|
||||
# - throttles to MAX concurrent emits
|
||||
# - launches $LUMBDA_EMITTER_CMD <tag>.lsp, expects it to write <tag>.bin
|
||||
# - touches <tag>.ready when bin size > MIN_BIN_SIZE
|
||||
# - touches <tag>.done if emit fails or bin stays too small
|
||||
#
|
||||
# Exits when no .lsp without a marker remains AND no .emitting markers active.
|
||||
# Touch $LUMBDA_QUEUE_DIR/STOP to halt new launches gracefully.
|
||||
#
|
||||
# Liveness:
|
||||
# - Writes $LUMBDA_QUEUE_DIR/pool.heartbeat (pid + timestamp) at top of every
|
||||
# loop iter via lib-heartbeat.sh::heartbeat_touch.
|
||||
# - On every exit path (STOP, drain timeout, signal trap, EXIT trap)
|
||||
# writes $LUMBDA_QUEUE_DIR/pool.exit-cause with reason + removes heartbeat.
|
||||
# - Supervisor reads pool.heartbeat to detect silent death.
|
||||
#
|
||||
# Usage: bend-emit-pool.sh [MAX [QUEUE_DIR [MIN_BIN_SIZE_BYTES]]]
|
||||
# MAX default 6
|
||||
# QUEUE_DIR default $LUMBDA_QUEUE_DIR
|
||||
# MIN_BIN_SIZE_BYTES default 268435456 (256 MB — sanity check only).
|
||||
#
|
||||
# Self-identifies as "bend-emit-pool" in cmdline for precise pkill targeting.
|
||||
#
|
||||
# Env vars consumed: see $LUMBDA_FACTORY_DIR/CONTRACT.md (LUMBDA_QUEUE_DIR,
|
||||
# LUMBDA_DOMAIN_DIR, LUMBDA_REPO_DIR, LUMBDA_EMITTER_CMD, AUTO_PULL_MASTER,
|
||||
# AUTO_PULL_TIMEOUT, DISK_MIN_FREE_GB, MEM_MIN_FREE_GB, POOL_MAX_IDLE_LOOPS,
|
||||
# SLOW_MAX).
|
||||
set -u
|
||||
|
||||
MAX="${1:-6}"
|
||||
QUEUE_DIR="${2:-${LUMBDA_QUEUE_DIR:-/tmp/lumbda-queue}}"
|
||||
MIN_BIN_SIZE="${3:-268435456}"
|
||||
|
||||
LUMBDA_DOMAIN_DIR="${LUMBDA_DOMAIN_DIR:-}"
|
||||
LUMBDA_REPO_DIR="${LUMBDA_REPO_DIR:-$HOME/git/lumbda}"
|
||||
LUMBDA_FACTORY_DIR="${LUMBDA_FACTORY_DIR:-$(dirname "$(readlink -f "$0")")}"
|
||||
LUMBDA_EMITTER_CMD="${LUMBDA_EMITTER_CMD:?LUMBDA_EMITTER_CMD must be set by consumer (e.g. \"lumbda --fast \$LUMBDA_DOMAIN_DIR/lumbda/emit-stream.lsp\")}"
|
||||
LIB_HEARTBEAT="${LIB_HEARTBEAT:-$LUMBDA_FACTORY_DIR/lib-heartbeat.sh}"
|
||||
LIB_TIER="${LIB_TIER:-$LUMBDA_FACTORY_DIR/lib-tier.sh}"
|
||||
|
||||
mkdir -p "$QUEUE_DIR"
|
||||
|
||||
echo $$ > "$QUEUE_DIR/pool.pid"
|
||||
trap 'rm -f "$QUEUE_DIR/pool.pid"' EXIT
|
||||
|
||||
# Self-tag in cmdline so pkill -f bend-emit-pool only matches THIS script,
|
||||
# not other bash sessions that happen to mention factory paths.
|
||||
exec -a bend-emit-pool bash -c '
|
||||
QUEUE_DIR="'"$QUEUE_DIR"'"
|
||||
MAX="'"$MAX"'"
|
||||
MIN_BIN_SIZE="'"$MIN_BIN_SIZE"'"
|
||||
LUMBDA_DOMAIN_DIR="'"$LUMBDA_DOMAIN_DIR"'"
|
||||
LUMBDA_REPO_DIR="'"$LUMBDA_REPO_DIR"'"
|
||||
LUMBDA_EMITTER_CMD="'"$LUMBDA_EMITTER_CMD"'"
|
||||
LIB_HEARTBEAT="'"$LIB_HEARTBEAT"'"
|
||||
LIB_TIER="'"$LIB_TIER"'"
|
||||
|
||||
# Source liveness library — provides heartbeat_touch / heartbeat_is_alive /
|
||||
# heartbeat_exit_cause. Path resolved by parent shell before exec so inner
|
||||
# shell does not need to derive its own dirname.
|
||||
# shellcheck source=/dev/null
|
||||
. "$LIB_HEARTBEAT"
|
||||
# Source bin-size tier classifier — provides tier_of_size / tier_label_dlq.
|
||||
# Pool uses these to route oversize bins (above TIER_LARGE_MAX) to DLQ at
|
||||
# finalize time instead of letting them flow downstream to crush the
|
||||
# dispatcher fleet.
|
||||
# shellcheck source=/dev/null
|
||||
. "$LIB_TIER"
|
||||
|
||||
echo "[$(date +%H:%M:%S)] bend-emit-pool start MAX=$MAX queue=$QUEUE_DIR"
|
||||
|
||||
# Exit-cause writers. Every exit path (drain, STOP, signal) records its
|
||||
# reason via the library before falling off the loop. EXIT trap acts as
|
||||
# safety net for unforeseen crashes; the loop body sets a more specific
|
||||
# reason just before break + EXIT trap then forwards it.
|
||||
POOL_EXIT_REASON=""
|
||||
on_exit() {
|
||||
local r="${POOL_EXIT_REASON:-unknown-exit}"
|
||||
heartbeat_exit_cause "$QUEUE_DIR" "pool" "$r"
|
||||
echo "[$(date +%H:%M:%S)] bend-emit-pool exit reason=$r"
|
||||
}
|
||||
trap on_exit EXIT
|
||||
trap "POOL_EXIT_REASON=signal-int; exit 130" INT
|
||||
trap "POOL_EXIT_REASON=signal-term; exit 143" TERM
|
||||
|
||||
# Pull origin/master before consuming a cell. Fail-soft: log a warning
|
||||
# and return non-zero on failure (caller decides whether to keep emitting
|
||||
# against checkout we have). Cheap (~1s fetch over LAN). Locked by
|
||||
# .git/index.lock so concurrent pool restarts serialize cleanly.
|
||||
pull_master_or_log() {
|
||||
[ "${AUTO_PULL_MASTER:-1}" = "1" ] || return 0
|
||||
local out rc
|
||||
out=$(timeout "${AUTO_PULL_TIMEOUT:-30}" git -C "$LUMBDA_REPO_DIR" pull --ff-only origin master 2>&1)
|
||||
rc=$?
|
||||
if [ "$rc" -ne 0 ]; then
|
||||
echo "[$(date +%H:%M:%S)] PULL-FAIL rc=$rc (substrate may be stale): $(echo "$out" | tr "\n" "|" | cut -c-200)"
|
||||
return "$rc"
|
||||
fi
|
||||
if ! echo "$out" | grep -q "Already up to date"; then
|
||||
echo "[$(date +%H:%M:%S)] PULL ok: $(echo "$out" | tr "\n" "|" | cut -c-200)"
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# Self-healing janitor: clear .emitting markers whose emitter PID is dead.
|
||||
# Called before every slot-count check. Stale markers can accrue when emits
|
||||
# get SIGKILLed (OOM, manual cleanup) before they can clean up their own
|
||||
# marker.
|
||||
#
|
||||
# Subshell wrapper around emitter touches .ready (or .done) BEFORE removing
|
||||
# its own .emitting marker. A brief window exists where emitter has exited
|
||||
# (PID dead) but parent subshell has not finished tail cleanup. If this
|
||||
# janitor fires in that window, it must NOT treat emit as failed —
|
||||
# .ready/.done file proves completion. Likewise if a .bin already exists
|
||||
# at full size, emit was real and .ready marker may already have been
|
||||
# consumed by dispatcher.
|
||||
#
|
||||
# NOTE: comments inside bash -c body must NOT contain apostrophes — each
|
||||
# closes outer single-quoted argument early + corrupts parse.
|
||||
heal_stale_markers() {
|
||||
for em in "$QUEUE_DIR"/*.emitting; do
|
||||
[ ! -f "$em" ] && continue
|
||||
pid=$(cat "$em" 2>/dev/null)
|
||||
if [ -z "$pid" ] || ! kill -0 "$pid" 2>/dev/null; then
|
||||
tag=$(basename "$em" .emitting)
|
||||
# If ANY completion artifact already exists, the emit completed
|
||||
# before this janitor fired. Silently clear stale .emitting marker
|
||||
# — no HEAL log, no .done touch, no relaunch.
|
||||
if [ -f "$QUEUE_DIR/${tag}.ready" ] \
|
||||
|| [ -f "$QUEUE_DIR/${tag}.done" ]; then
|
||||
rm -f "$em"
|
||||
continue
|
||||
fi
|
||||
# Orphan-bin recovery: if .bin exists at full size but no .ready/.done,
|
||||
# subshell wrote .bin then died (SIGTERM mid-cleanup) before touching
|
||||
# .ready. Promote: bin is real, dispatcher should consume it. Truncated
|
||||
# bins (< MIN_BIN_SIZE) treated as failure — rm + .done so cell does
|
||||
# not re-emit endlessly.
|
||||
if [ -f "$QUEUE_DIR/${tag}.bin" ]; then
|
||||
sz=$(stat -c %s "$QUEUE_DIR/${tag}.bin" 2>/dev/null || echo 0)
|
||||
magic=$(head -c 8 "$QUEUE_DIR/${tag}.bin" 2>/dev/null)
|
||||
if [ "$magic" != "QECCOPS1" ]; then
|
||||
# Header-rewrite never fired. See finalize() comment for the
|
||||
# SIGKILL/OOM mechanism.
|
||||
mkdir -p "$QUEUE_DIR/dlq"
|
||||
echo 1 > "$QUEUE_DIR/dlq/${tag}.retries"
|
||||
{
|
||||
echo "tier=zero-magic bin_bytes=$sz"
|
||||
echo "reason: emit-interrupted (heal-stale path) — bin header magic missing"
|
||||
echo "first-8-bytes-hex: $(head -c 8 $QUEUE_DIR/${tag}.bin 2>/dev/null | xxd -p -c 8)"
|
||||
} > "$QUEUE_DIR/dlq/${tag}.reason"
|
||||
echo "[$(date +%H:%M:%S)] DLQ orphan-bin-zero-magic $tag sz=$sz"
|
||||
rm -f "$QUEUE_DIR/${tag}.bin"
|
||||
touch "$QUEUE_DIR/${tag}.done.fail"
|
||||
touch "$QUEUE_DIR/${tag}.done"
|
||||
elif [ "$sz" -ge "$MIN_BIN_SIZE" ]; then
|
||||
touch "$QUEUE_DIR/${tag}.ready"
|
||||
echo "[$(date +%H:%M:%S)] RECOVER orphan-bin $tag ($(du -h $QUEUE_DIR/${tag}.bin | cut -f1))"
|
||||
else
|
||||
rm -f "$QUEUE_DIR/${tag}.bin"
|
||||
touch "$QUEUE_DIR/${tag}.done"
|
||||
echo "[$(date +%H:%M:%S)] HEAL orphan-bin-truncated $tag sz=$sz"
|
||||
fi
|
||||
rm -f "$em"
|
||||
continue
|
||||
fi
|
||||
echo "[$(date +%H:%M:%S)] HEAL stale $tag (pid=$pid dead)"
|
||||
rm -f "$em"
|
||||
touch "$QUEUE_DIR/${tag}.done"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# Belt-and-suspenders: standalone scan for orphan .bin files whose .emitting
|
||||
# marker is already gone (different death path than heal_stale_markers).
|
||||
# Catches:
|
||||
# - pool subshell died AFTER .emitting was racy-cleaned but
|
||||
# BEFORE it touched .ready
|
||||
# - pool process murdered between unrelated janitor pass + .ready touch
|
||||
# - any .bin left behind by a manual operator intervention
|
||||
#
|
||||
# Same recovery rule as .emitting branch: full-size → .ready, truncated →
|
||||
# rm + .done so cell does not re-emit forever. Skips bins already in flight
|
||||
# (.inflight-N) or already complete (.ready / .done / .dispatch.log).
|
||||
# Idempotent — safe to run every loop iter alongside heal_stale_markers.
|
||||
heal_orphan_bins() {
|
||||
shopt -s nullglob
|
||||
for bin in "$QUEUE_DIR"/*.bin; do
|
||||
tag=$(basename "$bin" .bin)
|
||||
# Any in-progress or completion artifact = NOT orphan
|
||||
[ -f "$QUEUE_DIR/${tag}.ready" ] && continue
|
||||
[ -f "$QUEUE_DIR/${tag}.done" ] && continue
|
||||
[ -f "$QUEUE_DIR/${tag}.emitting" ] && continue
|
||||
[ -f "$QUEUE_DIR/${tag}.dispatch.log" ] && continue
|
||||
if ls "$QUEUE_DIR/${tag}.inflight-"* >/dev/null 2>&1; then
|
||||
continue
|
||||
fi
|
||||
sz=$(stat -c %s "$bin" 2>/dev/null || echo 0)
|
||||
magic=$(head -c 8 "$bin" 2>/dev/null)
|
||||
if [ "$magic" != "QECCOPS1" ]; then
|
||||
# Same emit-interrupted class as finalize() + heal_stale_markers.
|
||||
mkdir -p "$QUEUE_DIR/dlq"
|
||||
echo 1 > "$QUEUE_DIR/dlq/${tag}.retries"
|
||||
{
|
||||
echo "tier=zero-magic bin_bytes=$sz"
|
||||
echo "reason: emit-interrupted (orphan-bin-scan path) — bin header magic missing"
|
||||
echo "first-8-bytes-hex: $(head -c 8 $bin 2>/dev/null | xxd -p -c 8)"
|
||||
} > "$QUEUE_DIR/dlq/${tag}.reason"
|
||||
echo "[$(date +%H:%M:%S)] DLQ orphan-bin-scan-zero-magic $tag sz=$sz"
|
||||
rm -f "$bin"
|
||||
touch "$QUEUE_DIR/${tag}.done.fail"
|
||||
touch "$QUEUE_DIR/${tag}.done"
|
||||
elif [ "$sz" -ge "$MIN_BIN_SIZE" ]; then
|
||||
touch "$QUEUE_DIR/${tag}.ready"
|
||||
echo "[$(date +%H:%M:%S)] RECOVER orphan-bin-scan $tag ($(du -h $bin | cut -f1))"
|
||||
else
|
||||
rm -f "$bin"
|
||||
touch "$QUEUE_DIR/${tag}.done"
|
||||
echo "[$(date +%H:%M:%S)] HEAL orphan-bin-scan-truncated $tag sz=$sz"
|
||||
fi
|
||||
done
|
||||
shopt -u nullglob
|
||||
}
|
||||
|
||||
DISK_MIN_FREE_GB="${DISK_MIN_FREE_GB:-30}"
|
||||
# Memory-free guard. Each emit RSS sits ~3-4 GiB at peak. With MAX=6 emits
|
||||
# in flight + backend + dispatchers, total memory pressure can climb fast.
|
||||
# Hold launches when free RAM falls below MEM_MIN_FREE_GB (default 8 GiB).
|
||||
# Mirrors disk-free pattern: pool HOLDs rather than spawning an
|
||||
# overcommit-OOM-kill cascade.
|
||||
MEM_MIN_FREE_GB="${MEM_MIN_FREE_GB:-8}"
|
||||
# Idle tolerance — pool stays alive while feeder may push more .lsp cells.
|
||||
# Default 600 (x 5s = 50 min) — long enough to outlast feeder push cadence
|
||||
# in normal operation. Exit only on STOP marker OR sustained idle past this
|
||||
# bound.
|
||||
POOL_MAX_IDLE_LOOPS="${POOL_MAX_IDLE_LOOPS:-600}"
|
||||
# Substrate freshness — pull origin/master before emitting each cell so a
|
||||
# backend never consumes a .lsp against stale substrate. Fail-soft: if pull
|
||||
# fails (local mods, network), log + continue with checkout we have. Cell
|
||||
# still emits — log entry tells operator "this host ran against potentially
|
||||
# stale substrate".
|
||||
AUTO_PULL_MASTER="${AUTO_PULL_MASTER:-1}"
|
||||
AUTO_PULL_TIMEOUT="${AUTO_PULL_TIMEOUT:-30}"
|
||||
|
||||
# Startup orphan sweep — runs BEFORE main loop so any leftover bins from a
|
||||
# SIGKILL-d previous pool (whose subshell wrappers died before touching
|
||||
# .ready) get promoted immediately. In-loop heal also runs, but only after
|
||||
# a full poll cycle (~5s + admission checks), during which the new pool
|
||||
# could start spawning fresh emits + pushing past orphans. Catching them
|
||||
# at startup closes that window.
|
||||
echo "[$(date +%H:%M:%S)] startup orphan scan"
|
||||
heal_stale_markers
|
||||
heal_orphan_bins
|
||||
|
||||
idle_loops=0
|
||||
while true; do
|
||||
# Heartbeat UNCONDITIONALLY at top of loop. Records both "I am alive"
|
||||
# mtime + "my pid is X" content. Supervisor on this host reads it every
|
||||
# poll cycle to decide whether to restart us.
|
||||
heartbeat_touch "$QUEUE_DIR" "pool"
|
||||
|
||||
if [ -f "$QUEUE_DIR/STOP" ]; then
|
||||
echo "[$(date +%H:%M:%S)] STOP seen"
|
||||
POOL_EXIT_REASON="stop-marker"
|
||||
break
|
||||
fi
|
||||
pull_master_or_log || true
|
||||
heal_stale_markers
|
||||
heal_orphan_bins
|
||||
# Disk-free guard. Emits land 4-6 GiB bins; running near zero free disk
|
||||
# makes emitter spin on write retries instead of erroring out. Hold
|
||||
# launches until at least DISK_MIN_FREE_GB available.
|
||||
free_gb=$(df -BG --output=avail / 2>/dev/null | tail -1 | tr -dc 0-9)
|
||||
if [ "${free_gb:-0}" -lt "$DISK_MIN_FREE_GB" ]; then
|
||||
echo "[$(date +%H:%M:%S)] HOLD disk-free=${free_gb}G < ${DISK_MIN_FREE_GB}G, sleeping"
|
||||
sleep 30
|
||||
continue
|
||||
fi
|
||||
# Memory-free guard. MAX=6 concurrent emits can each climb to ~3-4 GiB
|
||||
# RSS; with backend + dispatchers also resident, sustained pressure
|
||||
# approaches host limits. Hold new launches until MemAvailable >=
|
||||
# MEM_MIN_FREE_GB so an OOM killer never picks a long-running emit
|
||||
# at the worst moment. Same backoff sleep as disk guard.
|
||||
free_mem_kb=$(grep ^MemAvailable: /proc/meminfo 2>/dev/null | tr -dc 0-9)
|
||||
free_mem_gb=$(( ${free_mem_kb:-0} / 1048576 ))
|
||||
if [ "$free_mem_gb" -lt "$MEM_MIN_FREE_GB" ]; then
|
||||
echo "[$(date +%H:%M:%S)] HOLD mem-free=${free_mem_gb}G < ${MEM_MIN_FREE_GB}G, sleeping"
|
||||
sleep 30
|
||||
continue
|
||||
fi
|
||||
# Read autoscaler advisory + apply dynamic MAX cap when RAM/swap pressure
|
||||
# detected. If advisory says slow_emit=1, cap concurrency at SLOW_MAX
|
||||
# (default = MAX/2). When pressure clears the advisory flips back to 0
|
||||
# and the cap restores. MEM_MIN_FREE_GB hold (above) is still the hard
|
||||
# floor — advisory acts as a graceful intermediate signal.
|
||||
effective_max="$MAX"
|
||||
if [ -f "$QUEUE_DIR/emit.advisory" ]; then
|
||||
slow=$(grep -E '^slow_emit=' "$QUEUE_DIR/emit.advisory" 2>/dev/null | tail -1 | cut -d= -f2)
|
||||
if [ "${slow:-0}" = "1" ]; then
|
||||
effective_max="${SLOW_MAX:-$(( MAX / 2 ))}"
|
||||
[ "$effective_max" -lt 1 ] && effective_max=1
|
||||
fi
|
||||
fi
|
||||
# Find next pending work item
|
||||
next=""
|
||||
for f in "$QUEUE_DIR"/*.lsp; do
|
||||
[ ! -f "$f" ] && continue
|
||||
tag=$(basename "$f" .lsp)
|
||||
[ -f "$QUEUE_DIR/${tag}.ready" ] && continue
|
||||
[ -f "$QUEUE_DIR/${tag}.done" ] && continue
|
||||
# .done.fail = operator/caller marked cell as skip (incompatible config,
|
||||
# poison cell, retracted batch). Without this check pool re-emits the
|
||||
# cell every iter even after the operator explicitly excluded it.
|
||||
[ -f "$QUEUE_DIR/${tag}.done.fail" ] && continue
|
||||
[ -f "$QUEUE_DIR/${tag}.emitting" ] && continue
|
||||
# Dispatcher worker claims .ready by renaming to .inflight-N (atomic).
|
||||
# If worker dies mid-dispatch (backend crash, kill, OOM) .inflight-N
|
||||
# stays + no .done written. Treat .inflight-* as downstream-owned and
|
||||
# do not re-emit. Dispatcher reclaim path runs at bend-dispatcher
|
||||
# startup + heals stale .inflight-N back to .ready or .done.
|
||||
# NOTE: comments here must NOT contain apostrophes — each closes outer
|
||||
# single-quoted bash -c argument early + corrupts parse.
|
||||
if ls "$QUEUE_DIR/${tag}.inflight-"* >/dev/null 2>&1; then
|
||||
continue
|
||||
fi
|
||||
# Belt-and-suspenders against HEAL race: if a .bin exists at any size,
|
||||
# an emit has run. Dispatcher consumes .bin + may delete .ready when
|
||||
# done, leaving no other markers — without this check main loop would
|
||||
# relaunch the cell after dispatcher consumed it.
|
||||
[ -f "$QUEUE_DIR/${tag}.bin" ] && continue
|
||||
next="$f"
|
||||
break
|
||||
done
|
||||
if [ -z "$next" ]; then
|
||||
# No new items — wait for in-flight or for feeder to push more.
|
||||
running=$(ls "$QUEUE_DIR"/*.emitting 2>/dev/null | wc -l)
|
||||
if [ "$running" = "0" ]; then
|
||||
idle_loops=$((idle_loops + 1))
|
||||
if [ "$idle_loops" -ge "$POOL_MAX_IDLE_LOOPS" ]; then
|
||||
echo "[$(date +%H:%M:%S)] pool drained + idle past $POOL_MAX_IDLE_LOOPS loops"
|
||||
POOL_EXIT_REASON="drain-timeout"
|
||||
break
|
||||
fi
|
||||
fi
|
||||
sleep 5
|
||||
continue
|
||||
fi
|
||||
idle_loops=0
|
||||
# Throttle to MAX concurrent (self-healing each check). Heartbeat again
|
||||
# inside throttle wait — long emits can hold us here for minutes; without
|
||||
# re-touching heartbeat supervisor would see us as dead.
|
||||
while true; do
|
||||
heartbeat_touch "$QUEUE_DIR" "pool"
|
||||
heal_stale_markers
|
||||
heal_orphan_bins
|
||||
running=$(ls "$QUEUE_DIR"/*.emitting 2>/dev/null | wc -l)
|
||||
# Compare against effective_max (advisory-aware) rather than raw MAX
|
||||
# so RAM-pressure slow-down actually reduces concurrency. effective_max
|
||||
# computed once per main-loop iter against latest advisory.
|
||||
[ "$running" -lt "$effective_max" ] && break
|
||||
sleep 3
|
||||
done
|
||||
tag=$(basename "$next" .lsp)
|
||||
echo "[$(date +%H:%M:%S)] launch $tag"
|
||||
# Subshell: traps EXIT/HUP/TERM so partial emit still finalizes (.ready
|
||||
# or .done) when parent pool dies during hot-reload. SIGHUP explicitly
|
||||
# ignored — propagated from a dying parent must NOT kill the wrapper
|
||||
# mid-cleanup. SIGKILL on us still leaks (no trap fires), but startup
|
||||
# heal_orphan_bins promotes those bins on next pool start.
|
||||
(
|
||||
trap "" HUP
|
||||
bin="$QUEUE_DIR/${tag}.bin"
|
||||
finalize() {
|
||||
if [ -f "$bin" ]; then
|
||||
sz=$(stat -c %s "$bin" 2>/dev/null || echo 0)
|
||||
# Bin-magic check FIRST. Interrupted emits (OOM-killed mid-write,
|
||||
# SIGKILL on emitter hang) leave the 16-byte header reserve as
|
||||
# zero bytes — emit-stream writes 16 zeros, streams ops, then
|
||||
# seeks back to byte 0 to rewrite QECCOPS1 + n_ops at the end.
|
||||
# SIGKILL between those steps strands a file with zero magic.
|
||||
# Downstream backend chokes with opaque "bad magic" → bin-load-fail
|
||||
# → DLQ with no useful forensic info. Route here with actual cause
|
||||
# named so operators can trace back to upstream cell hang instead
|
||||
# of guessing at load_ops_bin internals.
|
||||
magic=$(head -c 8 "$bin" 2>/dev/null)
|
||||
if [ "$magic" != "QECCOPS1" ]; then
|
||||
mkdir -p "$QUEUE_DIR/dlq"
|
||||
echo 1 > "$QUEUE_DIR/dlq/${tag}.retries"
|
||||
{
|
||||
echo "tier=zero-magic bin_bytes=$sz"
|
||||
echo "reason: emit-interrupted — bin header magic missing or non-QECCOPS1 (likely SIGKILL or OOM mid-emit; emit-stream header rewrite never fired)"
|
||||
echo "first-8-bytes-hex: $(head -c 8 "$bin" 2>/dev/null | xxd -p -c 8)"
|
||||
echo "--- last 10 lines of emit.log ---"
|
||||
tail -10 "$QUEUE_DIR/${tag}.emit.log" 2>/dev/null
|
||||
} > "$QUEUE_DIR/dlq/${tag}.reason"
|
||||
echo "[$(date +%H:%M:%S)] DLQ $tag zero-magic sz=$sz ($(printf %.0f $((sz/1024/1024)))MiB) — emit-interrupted"
|
||||
rm -f "$bin"
|
||||
touch "$QUEUE_DIR/${tag}.done.fail"
|
||||
touch "$QUEUE_DIR/${tag}.done"
|
||||
rm -f "$QUEUE_DIR/${tag}.emitting"
|
||||
return 0
|
||||
fi
|
||||
# 2nd sub-class: GOOD magic + n_ops=0 — emit wrote QECCOPS1 magic +
|
||||
# streamed ops but SIGKILL hit between stream-end + finalize seek-
|
||||
# back-to-byte-8 that rewrites n_ops. Detect via n_ops=0
|
||||
# implausibility check: any in-tier bin should have n_ops > 0;
|
||||
# if not, finalize never fired.
|
||||
n_ops_le=$(dd if="$bin" bs=1 count=8 skip=8 2>/dev/null | xxd -p -c 8)
|
||||
if [ "$n_ops_le" = "0000000000000000" ]; then
|
||||
mkdir -p "$QUEUE_DIR/dlq"
|
||||
echo 1 > "$QUEUE_DIR/dlq/${tag}.retries"
|
||||
{
|
||||
echo "tier=zero-n_ops bin_bytes=$sz"
|
||||
echo "reason: emit-finalize-never-fired — QECCOPS1 magic present but n_ops field at byte 8 is zero (SIGKILL between stream-end + emit-stream seek-back; downstream load_ops_bin would fail length-mismatch)"
|
||||
echo "n_ops_le_hex: $n_ops_le"
|
||||
echo "--- last 10 lines of emit.log ---"
|
||||
tail -10 "$QUEUE_DIR/${tag}.emit.log" 2>/dev/null
|
||||
} > "$QUEUE_DIR/dlq/${tag}.reason"
|
||||
echo "[$(date +%H:%M:%S)] DLQ $tag zero-n_ops sz=$sz ($(printf %.0f $((sz/1024/1024)))MiB) — emit-finalize-never-fired"
|
||||
rm -f "$bin"
|
||||
touch "$QUEUE_DIR/${tag}.done.fail"
|
||||
touch "$QUEUE_DIR/${tag}.done"
|
||||
rm -f "$QUEUE_DIR/${tag}.emitting"
|
||||
return 0
|
||||
fi
|
||||
# Tier-classify the bin. Tier "below-min" / "above-max" → DLQ with
|
||||
# a typed reason. In-band tiers (micro/small/medium/large) get
|
||||
# promoted to .ready + dispatcher routes through tier-specific
|
||||
# admission. lib-tier.sh owns boundary policy.
|
||||
tier=$(tier_of_size "$sz")
|
||||
dlq_reason=$(tier_label_dlq "$tier")
|
||||
if [ -n "$dlq_reason" ]; then
|
||||
mkdir -p "$QUEUE_DIR/dlq"
|
||||
echo 1 > "$QUEUE_DIR/dlq/${tag}.retries"
|
||||
{
|
||||
echo "tier=$tier bin_bytes=$sz"
|
||||
echo "reason: $dlq_reason"
|
||||
echo "--- last 10 lines of emit.log ---"
|
||||
tail -10 "$QUEUE_DIR/${tag}.emit.log" 2>/dev/null
|
||||
} > "$QUEUE_DIR/dlq/${tag}.reason"
|
||||
echo "[$(date +%H:%M:%S)] DLQ $tag tier=$tier sz=$sz ($(printf %.0f $((sz/1024/1024)))MiB) — $dlq_reason"
|
||||
rm -f "$bin"
|
||||
touch "$QUEUE_DIR/${tag}.done.fail"
|
||||
touch "$QUEUE_DIR/${tag}.done"
|
||||
else
|
||||
# In-tier bin → optional domain-specific sidecar (vorticity, etc),
|
||||
# then mark .ready. Sidecar lands next to .bin. Read-only
|
||||
# post-process; emit substrate untouched. Fail-soft: sidecar
|
||||
# script errors log + continue — bin still promotes to .ready.
|
||||
# Dispatcher reads .bin, ignores sidecar; operators + analysis
|
||||
# tooling consume sidecar opportunistically.
|
||||
vort="$QUEUE_DIR/${tag}.vorticity.tsv"
|
||||
if [ -n "$LUMBDA_DOMAIN_DIR" ] && [ -x "$LUMBDA_DOMAIN_DIR/scripts/qubit-vorticity.py" ]; then
|
||||
if "$LUMBDA_DOMAIN_DIR/scripts/qubit-vorticity.py" "$bin" --out "$vort" \
|
||||
> "$QUEUE_DIR/${tag}.vorticity.log" 2>&1; then
|
||||
:
|
||||
else
|
||||
echo "[$(date +%H:%M:%S)] vorticity $tag FAILED (see ${tag}.vorticity.log) — promoting bin anyway"
|
||||
fi
|
||||
fi
|
||||
touch "$QUEUE_DIR/${tag}.ready"
|
||||
echo "[$(date +%H:%M:%S)] ready $tag tier=$tier ($(du -h $bin | cut -f1))"
|
||||
fi
|
||||
else
|
||||
# No bin — emitter errored before writing (undefined flag, syntax
|
||||
# error, load failure, etc). Emit failures stay DETERMINISTIC —
|
||||
# retry will fail the same way. Route to DLQ for forensics, mark
|
||||
# .done.fail + .done so pool skips on next iter (no infinite
|
||||
# relaunch loop).
|
||||
mkdir -p "$QUEUE_DIR/dlq"
|
||||
echo 1 > "$QUEUE_DIR/dlq/${tag}.retries"
|
||||
tail -10 "$QUEUE_DIR/${tag}.emit.log" 2>/dev/null > "$QUEUE_DIR/dlq/${tag}.reason"
|
||||
echo "[$(date +%H:%M:%S)] DLQ $tag (emit error — see dlq/${tag}.reason)"
|
||||
touch "$QUEUE_DIR/${tag}.done.fail"
|
||||
touch "$QUEUE_DIR/${tag}.done"
|
||||
fi
|
||||
rm -f "$QUEUE_DIR/${tag}.emitting"
|
||||
}
|
||||
trap finalize EXIT
|
||||
trap "kill \$lpid 2>/dev/null; finalize; exit 130" INT
|
||||
trap "kill \$lpid 2>/dev/null; finalize; exit 143" TERM
|
||||
if [ -n "$LUMBDA_DOMAIN_DIR" ]; then
|
||||
cd "$LUMBDA_DOMAIN_DIR"
|
||||
fi
|
||||
PYTHONUNBUFFERED=1 ECDSA_OUT_BIN="$QUEUE_DIR/${tag}.bin" \
|
||||
$LUMBDA_EMITTER_CMD "$next" \
|
||||
> "$QUEUE_DIR/${tag}.emit.log" 2>&1 &
|
||||
lpid=$!
|
||||
echo "$lpid" > "$QUEUE_DIR/${tag}.emitting"
|
||||
wait "$lpid"
|
||||
) &
|
||||
done
|
||||
wait
|
||||
echo "[$(date +%H:%M:%S)] all emits done"
|
||||
# Loop exited cleanly via STOP / drain; POOL_EXIT_REASON already set.
|
||||
# EXIT trap calls heartbeat_exit_cause.
|
||||
'
|
||||
278
factory/bend-supervisor-dlq-runner.sh
Executable file
278
factory/bend-supervisor-dlq-runner.sh
Executable file
|
|
@ -0,0 +1,278 @@
|
|||
#!/usr/bin/env bash
|
||||
# bend-supervisor-dlq-runner.sh — auto-resolve loop for our DLQ.
|
||||
#
|
||||
# Polls $LUMBDA_QUEUE_DIR/dlq every $DLQ_POLL_S seconds. For each retry-
|
||||
# eligible entry (has a .bin in dlq/), classify by reason file, decide:
|
||||
#
|
||||
# AUTO-RETRY → move .bin back to queue + touch .ready. Counters in
|
||||
# $LUMBDA_QUEUE_DIR/dlq/<tag>.retries persist across passes.
|
||||
# When retries reach AUTO_RETRY_MAX for an auto class,
|
||||
# we ESCALATE instead.
|
||||
#
|
||||
# ESCALATE → move bin + reason + retries + copy .lsp into
|
||||
# $LUMBDA_QUEUE_DIR/rdlq/. Cell now waits for operator
|
||||
# to run a retry workflow.
|
||||
#
|
||||
# DROP → for cells whose .bin stays missing or 0-byte (emit truly
|
||||
# failed; nothing to preserve). Touch .done.fail.
|
||||
#
|
||||
# State-stage classification drives WHAT we preserve when escalating:
|
||||
# stage=emit → .lsp matters, .bin disposable (pool re-emits)
|
||||
# stage=dispatch → .bin good, dispatcher infrastructure broke; retry
|
||||
# preserves .bin
|
||||
# stage=sim → .bin good, but backend failed (OOM / illegal-addr
|
||||
# / etc). Operator decides: retry with different
|
||||
# n_batches, or patch backend + rebuild, or quarantine.
|
||||
#
|
||||
# Designed to run as one supervisor-managed worker. Heartbeats to
|
||||
# $LUMBDA_QUEUE_DIR/dlq-runner.heartbeat; supervisor restarts if stale.
|
||||
#
|
||||
# Exit signals:
|
||||
# - $LUMBDA_QUEUE_DIR/DLQ_RUNNER_STOP marker
|
||||
# - SIGTERM / SIGINT
|
||||
#
|
||||
# Env vars consumed: see $LUMBDA_FACTORY_DIR/CONTRACT.md (LUMBDA_QUEUE_DIR,
|
||||
# DLQ_POLL_S, AUTO_RETRY_MAX, DLQ_RUNNER_LOG).
|
||||
set -u
|
||||
|
||||
QUEUE_DIR="${LUMBDA_QUEUE_DIR:-${QUEUE_DIR:-/tmp/lumbda-queue}}"
|
||||
DLQ_POLL_S="${DLQ_POLL_S:-30}"
|
||||
AUTO_RETRY_MAX="${AUTO_RETRY_MAX:-3}"
|
||||
LOG="${DLQ_RUNNER_LOG:-$QUEUE_DIR/dlq-runner.log}"
|
||||
STOP_FILE="$QUEUE_DIR/DLQ_RUNNER_STOP"
|
||||
RDLQ_DIR="$QUEUE_DIR/rdlq"
|
||||
|
||||
LUMBDA_FACTORY_DIR="${LUMBDA_FACTORY_DIR:-$(dirname "$(readlink -f "$0")")}"
|
||||
LIB_HEARTBEAT="${LIB_HEARTBEAT:-$LUMBDA_FACTORY_DIR/lib-heartbeat.sh}"
|
||||
# shellcheck source=/dev/null
|
||||
. "$LIB_HEARTBEAT"
|
||||
|
||||
mkdir -p "$RDLQ_DIR"
|
||||
|
||||
log() {
|
||||
local ts msg
|
||||
ts=$(date +%H:%M:%S)
|
||||
msg="[$ts] $*"
|
||||
echo "$msg" | tee -a "$LOG"
|
||||
}
|
||||
|
||||
# Classification table — single source of truth. Returns one of:
|
||||
# auto-retry missing-bin bisect-pool-race cuda-error-transient no-portal
|
||||
# escalate cuda-oom cuda-illegal-addr bin-load-fail
|
||||
# memory-cap-refused tier-classify unknown
|
||||
#
|
||||
# classify_reason <reason-file>
|
||||
# echoes "<class> <stage> <salvage_bin>"
|
||||
# class — one of named classes above
|
||||
# stage — emit | dispatch | sim
|
||||
# salvage_bin — yes | no (yes = preserve .bin on escalate; no = re-emit needed)
|
||||
classify_reason() {
|
||||
local r="$1"
|
||||
if [ ! -f "$r" ]; then
|
||||
echo "unknown unknown no"; return
|
||||
fi
|
||||
# Empty or whitespace-only .reason files: classify as transient-empty-reason
|
||||
# directly. Auto-retry class. After AUTO_RETRY_MAX (3) escalates, but noise
|
||||
# stays contained AND operators see the right label.
|
||||
if [ ! -s "$r" ] || ! grep -qE '[^[:space:]]' "$r" 2>/dev/null; then
|
||||
echo "transient-empty-reason sim yes"; return
|
||||
fi
|
||||
# Specific (code XXX) matches FIRST — typed portals carry most reliable
|
||||
# signal. Generic "cuda-sim-error" / "cuda-error" catch-all comes last
|
||||
# so bin-load-fail / memory-cap-refused etc. don't get silently swallowed.
|
||||
if grep -q "cuda-oom\|cudaMalloc.*out of memory\|cudaErrorMemoryAllocation" "$r" 2>/dev/null; then
|
||||
echo "cuda-oom sim yes"; return
|
||||
fi
|
||||
if grep -q "cuda-illegal-addr\|cudaErrorIllegalAddress" "$r" 2>/dev/null; then
|
||||
echo "cuda-illegal-addr sim yes"; return
|
||||
fi
|
||||
if grep -q "memory-cap-refused" "$r" 2>/dev/null; then
|
||||
echo "memory-cap-refused sim yes"; return
|
||||
fi
|
||||
# Fast-fail (backend pre-flight cudaMemGetInfo). Bin is fine; needs
|
||||
# n_batches reduction or wait for less GPU contention.
|
||||
if grep -q "vram-budget-refused" "$r" 2>/dev/null; then
|
||||
echo "vram-budget-refused sim yes"; return
|
||||
fi
|
||||
if grep -q "bin-load-fail" "$r" 2>/dev/null; then
|
||||
echo "bin-load-fail emit no"; return
|
||||
fi
|
||||
# Emit-stage (pool DLQ'd at finalize)
|
||||
if grep -q "tier=zero-magic\|tier=zero-n_ops\|emit-interrupted" "$r" 2>/dev/null; then
|
||||
echo "emit-broken emit no"; return
|
||||
fi
|
||||
if grep -q "tier=above-max\|tier=below-min" "$r" 2>/dev/null; then
|
||||
echo "tier-classify emit no"; return
|
||||
fi
|
||||
# Generic typed cuda error catchall — fired when CUDA_CHECK surfaced an
|
||||
# error not matched by oom/illegal-addr/memory-cap.
|
||||
if grep -q "cuda-sim-error\|cuda-error" "$r" 2>/dev/null; then
|
||||
echo "cuda-error-transient sim yes"; return
|
||||
fi
|
||||
# Dispatch-stage (backend infrastructure)
|
||||
if grep -q "^missing:" "$r" 2>/dev/null; then
|
||||
echo "missing-bin dispatch yes"; return
|
||||
fi
|
||||
# BISECT-DIAG entries are NOT a step6 crash — they are pool finalize()
|
||||
# race vs heal_orphan_bins where .bin got consumed/deleted between
|
||||
# subshell-exit and finalize check. Cells actually completed emit. So
|
||||
# retry-emit is safe; the bin is gone.
|
||||
if grep -q "BISECT-DIAG" "$r" 2>/dev/null; then
|
||||
echo "bisect-pool-race emit no"; return
|
||||
fi
|
||||
# Generic no-portal — backend crashed without writing typed portal.
|
||||
if grep -q "no-portal" "$r" 2>/dev/null; then
|
||||
echo "no-portal sim yes"; return
|
||||
fi
|
||||
echo "unknown unknown no"
|
||||
}
|
||||
|
||||
# Auto-retry classes — these resolve themselves with a re-try.
|
||||
is_auto_class() {
|
||||
case "$1" in
|
||||
missing-bin|bisect-pool-race|cuda-error-transient|no-portal|transient-empty-reason) return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Escalate-on-first-fail classes — no point in auto-retrying.
|
||||
is_escalate_class() {
|
||||
case "$1" in
|
||||
cuda-oom|cuda-illegal-addr|memory-cap-refused|vram-budget-refused|bin-load-fail|tier-classify|emit-broken|unknown) return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Move <tag> from dlq/ back to queue/ for another dispatcher attempt.
|
||||
auto_retry_one() {
|
||||
local tag="$1" reason_class="$2"
|
||||
local bin="$QUEUE_DIR/dlq/${tag}.bin"
|
||||
local ready="$QUEUE_DIR/${tag}.ready"
|
||||
if [ ! -f "$bin" ]; then
|
||||
# Bin missing in DLQ — nothing to retry. Drop to .done.fail so pool
|
||||
# stops re-emitting.
|
||||
touch "$QUEUE_DIR/${tag}.done.fail"
|
||||
touch "$QUEUE_DIR/${tag}.done"
|
||||
rm -f "$QUEUE_DIR/dlq/${tag}.reason" "$QUEUE_DIR/dlq/${tag}.retries"
|
||||
log "DROP $tag class=$reason_class (no .bin in dlq, marking done.fail)"
|
||||
return
|
||||
fi
|
||||
mv "$bin" "$QUEUE_DIR/${tag}.bin"
|
||||
touch "$ready"
|
||||
log "AUTO-RETRY $tag class=$reason_class -> .ready"
|
||||
}
|
||||
|
||||
# Escalate <tag> from dlq/ to rdlq/. Preserves .bin if salvage_bin=yes,
|
||||
# always copies .lsp source for operator to inspect or re-emit from.
|
||||
escalate_one() {
|
||||
local tag="$1" reason_class="$2" stage="$3" salvage="$4"
|
||||
local dlq_bin="$QUEUE_DIR/dlq/${tag}.bin"
|
||||
local dlq_reason="$QUEUE_DIR/dlq/${tag}.reason"
|
||||
local dlq_retries="$QUEUE_DIR/dlq/${tag}.retries"
|
||||
local rdlq_bin="$RDLQ_DIR/${tag}.bin"
|
||||
local rdlq_reason="$RDLQ_DIR/${tag}.reason"
|
||||
local rdlq_retries="$RDLQ_DIR/${tag}.retries"
|
||||
local rdlq_stage="$RDLQ_DIR/${tag}.stage"
|
||||
local rdlq_class="$RDLQ_DIR/${tag}.class"
|
||||
local rdlq_lsp="$RDLQ_DIR/${tag}.lsp"
|
||||
local rdlq_first="$RDLQ_DIR/${tag}.first-seen"
|
||||
local src_lsp="$QUEUE_DIR/${tag}.lsp"
|
||||
local retries=0
|
||||
[ -f "$dlq_retries" ] && retries=$(cat "$dlq_retries" 2>/dev/null)
|
||||
[ -z "$retries" ] && retries=0
|
||||
|
||||
if [ "$salvage" = "yes" ] && [ -f "$dlq_bin" ]; then
|
||||
mv "$dlq_bin" "$rdlq_bin"
|
||||
elif [ -f "$dlq_bin" ]; then
|
||||
# salvage=no — bin is suspect; remove so retry-reemit is the only path
|
||||
rm -f "$dlq_bin"
|
||||
fi
|
||||
# Copy .lsp if it still exists in queue. (Pool may have already
|
||||
# rm'd it once the cell hit .done.) If missing, escalation goes
|
||||
# forward without source — operator pulls from git.
|
||||
if [ -f "$src_lsp" ]; then
|
||||
cp "$src_lsp" "$rdlq_lsp"
|
||||
fi
|
||||
{
|
||||
echo "# rDLQ entry — escalated $(date -u +%FT%TZ)"
|
||||
echo "# class: $reason_class"
|
||||
echo "# stage: $stage"
|
||||
echo "# salvage_bin: $salvage"
|
||||
echo "# retries-at-escalation: $retries"
|
||||
echo ""
|
||||
echo "## Original DLQ reason:"
|
||||
if [ -f "$dlq_reason" ]; then cat "$dlq_reason"; fi
|
||||
} > "$rdlq_reason"
|
||||
echo "$retries" > "$rdlq_retries"
|
||||
echo "$stage" > "$rdlq_stage"
|
||||
echo "$reason_class" > "$rdlq_class"
|
||||
[ ! -f "$rdlq_first" ] && date -u +%FT%TZ > "$rdlq_first"
|
||||
rm -f "$dlq_bin" "$dlq_reason" "$dlq_retries"
|
||||
# Mark .done.fail so pool stops re-emitting while cell sits in rDLQ.
|
||||
# Re-emit path clears these.
|
||||
touch "$QUEUE_DIR/${tag}.done.fail"
|
||||
touch "$QUEUE_DIR/${tag}.done"
|
||||
log "ESCALATE $tag class=$reason_class stage=$stage salvage=$salvage retries=$retries -> rdlq/"
|
||||
}
|
||||
|
||||
# One pass over the DLQ. Increments cell-local retry only on auto-retry
|
||||
# decisions (so escalate paths don't inflate counter unfairly).
|
||||
process_one_pass() {
|
||||
shopt -s nullglob
|
||||
local f tag class stage salvage parts
|
||||
for f in "$QUEUE_DIR"/dlq/*.reason; do
|
||||
[ -f "$f" ] || continue
|
||||
tag=$(basename "$f" .reason)
|
||||
parts=$(classify_reason "$f")
|
||||
class=$(echo "$parts" | awk '{print $1}')
|
||||
stage=$(echo "$parts" | awk '{print $2}')
|
||||
salvage=$(echo "$parts" | awk '{print $3}')
|
||||
|
||||
local retries=0
|
||||
[ -f "$QUEUE_DIR/dlq/${tag}.retries" ] && retries=$(cat "$QUEUE_DIR/dlq/${tag}.retries" 2>/dev/null)
|
||||
[ -z "$retries" ] && retries=0
|
||||
|
||||
if is_escalate_class "$class"; then
|
||||
escalate_one "$tag" "$class" "$stage" "$salvage"
|
||||
continue
|
||||
fi
|
||||
if is_auto_class "$class"; then
|
||||
if [ "$retries" -ge "$AUTO_RETRY_MAX" ]; then
|
||||
# Auto class exhausted retries — escalate.
|
||||
escalate_one "$tag" "$class" "$stage" "$salvage"
|
||||
continue
|
||||
fi
|
||||
retries=$((retries + 1))
|
||||
echo "$retries" > "$QUEUE_DIR/dlq/${tag}.retries"
|
||||
auto_retry_one "$tag" "$class"
|
||||
continue
|
||||
fi
|
||||
# Unknown classifier — escalate defensively (operator decides).
|
||||
escalate_one "$tag" "$class" "$stage" "$salvage"
|
||||
done
|
||||
shopt -u nullglob
|
||||
}
|
||||
|
||||
EXIT_REASON=""
|
||||
on_exit() {
|
||||
local r="${EXIT_REASON:-unknown-exit}"
|
||||
heartbeat_exit_cause "$QUEUE_DIR" "dlq-runner" "$r"
|
||||
echo "[$(date +%H:%M:%S)] bend-supervisor-dlq-runner exit reason=$r" | tee -a "$LOG"
|
||||
}
|
||||
trap on_exit EXIT
|
||||
trap 'EXIT_REASON=signal-int; exit 130' INT
|
||||
trap 'EXIT_REASON=signal-term; exit 143' TERM
|
||||
|
||||
log "bend-supervisor-dlq-runner start queue=$QUEUE_DIR poll=${DLQ_POLL_S}s auto_max=$AUTO_RETRY_MAX"
|
||||
|
||||
while true; do
|
||||
heartbeat_touch "$QUEUE_DIR" "dlq-runner"
|
||||
if [ -f "$STOP_FILE" ]; then
|
||||
log "STOP marker — exiting"
|
||||
rm -f "$STOP_FILE"
|
||||
EXIT_REASON="stop-marker"
|
||||
break
|
||||
fi
|
||||
process_one_pass
|
||||
sleep "$DLQ_POLL_S"
|
||||
done
|
||||
353
factory/bend-supervisor.sh
Executable file
353
factory/bend-supervisor.sh
Executable file
|
|
@ -0,0 +1,353 @@
|
|||
#!/usr/bin/env bash
|
||||
# bend-supervisor.sh — per-host local watchdog for a bend factory.
|
||||
#
|
||||
# Runs ON each backend host. Owns local liveness for that host's components:
|
||||
# 1. backend (gpu-worker) on $BEND_PORT_LISTEN
|
||||
# 2. bend-emit-pool.sh (consumes .lsp from $LUMBDA_QUEUE_DIR)
|
||||
# 3. bend-dispatcher.sh (consumes .ready, writes results.tsv)
|
||||
# 4. bend-supervisor-dlq-runner.sh (DLQ classifier + auto-retry)
|
||||
# 5. bend-autoscaler.sh (V2 live-VRAM controller; optional)
|
||||
#
|
||||
# Reads heartbeats produced by lib-heartbeat.sh:
|
||||
# $LUMBDA_QUEUE_DIR/pool.heartbeat (pool)
|
||||
# $LUMBDA_QUEUE_DIR/dispatcher-<wid>.heartbeat (dispatcher per worker)
|
||||
# backend liveness: TCP LISTEN socket on $BEND_PORT_LISTEN (not an hb)
|
||||
#
|
||||
# Restart policy:
|
||||
# - backend: restart when port not listening
|
||||
# - pool: restart when heartbeat stale AND any .lsp lacks marker
|
||||
# - dispatcher: restart when ALL workers' heartbeats stale AND .ready
|
||||
# or .bin exists (work waiting).
|
||||
#
|
||||
# Persistent loop. Exit only on:
|
||||
# - $LUMBDA_QUEUE_DIR/SUPERVISOR_STOP marker
|
||||
# - SIGINT / SIGTERM
|
||||
# No auto-exit on idle.
|
||||
#
|
||||
# Env vars consumed: see $LUMBDA_FACTORY_DIR/CONTRACT.md (LUMBDA_QUEUE_DIR,
|
||||
# LUMBDA_FACTORY_DIR, LUMBDA_DOMAIN_DIR, LUMBDA_REPO_DIR, LUMBDA_BACKEND_CMD,
|
||||
# LUMBDA_EMITTER_CMD, BEND_PORT_LISTEN, BEND_ENDPOINTS, POOL_MAX,
|
||||
# POOL_MIN_BIN, POOL_BATCHES, POLL_SECONDS, POOL_STALE_SEC, DISP_STALE_SEC,
|
||||
# SUPERVISOR_LOG).
|
||||
#
|
||||
# Usage:
|
||||
# LUMBDA_QUEUE_DIR=/tmp/lumbda-queue BEND_PORT_LISTEN=8320 \
|
||||
# bend-supervisor.sh
|
||||
set -u
|
||||
|
||||
QUEUE_DIR="${LUMBDA_QUEUE_DIR:-${QUEUE_DIR:-/tmp/lumbda-queue}}"
|
||||
LUMBDA_DOMAIN_DIR="${LUMBDA_DOMAIN_DIR:-}"
|
||||
LUMBDA_REPO_DIR="${LUMBDA_REPO_DIR:-$HOME/git/lumbda}"
|
||||
LUMBDA_FACTORY_DIR="${LUMBDA_FACTORY_DIR:-$(dirname "$(readlink -f "$0")")}"
|
||||
# Backend launch knobs — set by consumer.
|
||||
LUMBDA_BACKEND_CMD="${LUMBDA_BACKEND_CMD:-bend-cuda}"
|
||||
# Optional cuda-fanout dir for lumbda-worker bootstrap script.
|
||||
CUDA_FANOUT_DIR="${CUDA_FANOUT_DIR:-$LUMBDA_REPO_DIR/examples/cuda-fanout}"
|
||||
# Lumbda C binary — needed only for the gpu-worker.lsp bootstrap path.
|
||||
# Consumer can override; if missing we skip the worker launch helper and
|
||||
# the operator handles backend lifecycle out-of-band.
|
||||
LUMBDA_C_BIN="${LUMBDA_C_BIN:-$LUMBDA_REPO_DIR/c/lumbda}"
|
||||
BEND_PORT_LISTEN="${BEND_PORT_LISTEN:-8320}"
|
||||
BEND_ENDPOINTS="${BEND_ENDPOINTS:-127.0.0.1:${BEND_PORT_LISTEN}}"
|
||||
POOL_MAX="${POOL_MAX:-12}"
|
||||
POOL_MIN_BIN="${POOL_MIN_BIN:-268435456}"
|
||||
POOL_BATCHES="${POOL_BATCHES:-141}"
|
||||
POLL_SECONDS="${POLL_SECONDS:-30}"
|
||||
POOL_STALE_SEC="${POOL_STALE_SEC:-180}"
|
||||
DISP_STALE_SEC="${DISP_STALE_SEC:-300}"
|
||||
SUPERVISOR_LOG="${SUPERVISOR_LOG:-$QUEUE_DIR/supervisor.log}"
|
||||
LIB_HEARTBEAT="${LIB_HEARTBEAT:-$LUMBDA_FACTORY_DIR/lib-heartbeat.sh}"
|
||||
LIB_TIER="${LIB_TIER:-$LUMBDA_FACTORY_DIR/lib-tier.sh}"
|
||||
|
||||
# shellcheck source=/dev/null
|
||||
. "$LIB_HEARTBEAT"
|
||||
# Source tier policy so reload_config's tier_fingerprint sees defaults at
|
||||
# first boot. supervisor.config overrides flow through `set -a; . "$CONFIG_FILE";
|
||||
# set +a` below + propagate to child env.
|
||||
# shellcheck source=/dev/null
|
||||
. "$LIB_TIER"
|
||||
|
||||
mkdir -p "$QUEUE_DIR"
|
||||
|
||||
PID_FILE="$QUEUE_DIR/supervisor.pid"
|
||||
STOP_FILE="$QUEUE_DIR/SUPERVISOR_STOP"
|
||||
|
||||
# Singleton via PID file. If a prior supervisor's pid stays alive, refuse
|
||||
# to start. If pidfile stays stale (pid dead), claim it.
|
||||
if [ -f "$PID_FILE" ]; then
|
||||
old_pid=$(cat "$PID_FILE" 2>/dev/null)
|
||||
if [ -n "$old_pid" ] && kill -0 "$old_pid" 2>/dev/null; then
|
||||
echo "[$(date +%H:%M:%S)] bend-supervisor: another instance alive (pid=$old_pid); refusing to start" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
echo $$ > "$PID_FILE"
|
||||
|
||||
SUP_EXIT_REASON=""
|
||||
on_exit() {
|
||||
local r="${SUP_EXIT_REASON:-unknown-exit}"
|
||||
heartbeat_exit_cause "$QUEUE_DIR" "supervisor" "$r"
|
||||
rm -f "$PID_FILE"
|
||||
echo "[$(date +%H:%M:%S)] bend-supervisor exit reason=$r" >> "$SUPERVISOR_LOG"
|
||||
}
|
||||
trap on_exit EXIT
|
||||
trap 'SUP_EXIT_REASON=signal-int; exit 130' INT
|
||||
trap 'SUP_EXIT_REASON=signal-term; exit 143' TERM
|
||||
|
||||
log() {
|
||||
local ts msg
|
||||
ts=$(date +%H:%M:%S)
|
||||
msg="[$ts] $*"
|
||||
echo "$msg" | tee -a "$SUPERVISOR_LOG"
|
||||
}
|
||||
|
||||
log "bend-supervisor start queue=$QUEUE_DIR port=$BEND_PORT_LISTEN"
|
||||
log " pool-stale=${POOL_STALE_SEC}s disp-stale=${DISP_STALE_SEC}s poll=${POLL_SECONDS}s"
|
||||
|
||||
# ── component-1: backend (gpu-worker) ───────────────────────────────
|
||||
# Listening on TCP $BEND_PORT_LISTEN means alive. No heartbeat file —
|
||||
# backend = lumbda + cuda, not a script we own.
|
||||
bend_port_listening() {
|
||||
ss -tnlp 2>/dev/null | grep -q "0.0.0.0:${BEND_PORT_LISTEN} "
|
||||
}
|
||||
|
||||
restart_bend() {
|
||||
log "restart backend gpu-worker on :${BEND_PORT_LISTEN}"
|
||||
local launch_lsp="/tmp/launch-worker-${BEND_PORT_LISTEN}.lsp"
|
||||
local bend_log="/tmp/bend-worker-${BEND_PORT_LISTEN}.log"
|
||||
if [ ! -x "$LUMBDA_C_BIN" ] || [ ! -d "$CUDA_FANOUT_DIR" ]; then
|
||||
log "skip backend restart — LUMBDA_C_BIN ($LUMBDA_C_BIN) or CUDA_FANOUT_DIR ($CUDA_FANOUT_DIR) missing; consumer owns backend lifecycle"
|
||||
return 1
|
||||
fi
|
||||
printf '%s\n' \
|
||||
"(define *argv* (quote (\"--port\" \"${BEND_PORT_LISTEN}\")))" \
|
||||
"(load \"wire.lsp\")" \
|
||||
"(load \"gpu-worker.lsp\")" \
|
||||
"(main)" \
|
||||
> "$launch_lsp"
|
||||
(
|
||||
cd "$CUDA_FANOUT_DIR" || exit 1
|
||||
PYTHONUNBUFFERED=1 nohup "$LUMBDA_C_BIN" "$launch_lsp" \
|
||||
>> "$bend_log" 2>&1 &
|
||||
)
|
||||
sleep 5
|
||||
if bend_port_listening; then
|
||||
log "backend back up on :${BEND_PORT_LISTEN}"
|
||||
return 0
|
||||
fi
|
||||
log "backend FAILED to come back up — will retry next poll"
|
||||
return 1
|
||||
}
|
||||
|
||||
# ── component-2: bend-emit-pool ─────────────────────────────────────
|
||||
# Heartbeat at $QUEUE_DIR/pool.heartbeat. Stale check delegated to lib.
|
||||
# Restart predicate: stale AND there's actually un-emitted work.
|
||||
pool_alive() {
|
||||
heartbeat_is_alive "$QUEUE_DIR" "pool" "$POOL_STALE_SEC"
|
||||
}
|
||||
|
||||
count_unworked_lsp() {
|
||||
local n=0
|
||||
for f in "$QUEUE_DIR"/*.lsp; do
|
||||
[ -f "$f" ] || continue
|
||||
local tag
|
||||
tag=$(basename "$f" .lsp)
|
||||
[ -f "$QUEUE_DIR/${tag}.ready" ] && continue
|
||||
[ -f "$QUEUE_DIR/${tag}.done" ] && continue
|
||||
[ -f "$QUEUE_DIR/${tag}.emitting" ] && continue
|
||||
n=$((n + 1))
|
||||
done
|
||||
echo "$n"
|
||||
}
|
||||
|
||||
restart_pool() {
|
||||
# Factory-protection: bash -n the pool script BEFORE spawning. A
|
||||
# one-character apostrophe in a comment can close the outer
|
||||
# exec -a NAME bash -c quote and crash-loop a pool every 30s.
|
||||
# bash -n catches that statically. If a script breaks, refuse to
|
||||
# spawn + log loudly. Last-known-good in-memory pool keeps running
|
||||
# until a human fixes master.
|
||||
local pool_script="$LUMBDA_FACTORY_DIR/bend-emit-pool.sh"
|
||||
if ! bash -n "$pool_script" 2>/tmp/pool-syntax.err; then
|
||||
log "REFUSING to spawn pool — bend-emit-pool.sh failed bash -n:"
|
||||
sed 's/^/ /' /tmp/pool-syntax.err | while read line; do log "$line"; done
|
||||
log "fix master + push; supervisor will retry on next loop"
|
||||
rm -f /tmp/pool-syntax.err
|
||||
return 1
|
||||
fi
|
||||
rm -f /tmp/pool-syntax.err
|
||||
log "restart bend-emit-pool"
|
||||
# Clean stale .emitting + STOP markers before relaunch — the old pool
|
||||
# may have left these around.
|
||||
rm -f "$QUEUE_DIR"/*.emitting "$QUEUE_DIR/STOP" 2>/dev/null
|
||||
LUMBDA_DOMAIN_DIR="$LUMBDA_DOMAIN_DIR" \
|
||||
LUMBDA_REPO_DIR="$LUMBDA_REPO_DIR" \
|
||||
LUMBDA_FACTORY_DIR="$LUMBDA_FACTORY_DIR" \
|
||||
LUMBDA_EMITTER_CMD="${LUMBDA_EMITTER_CMD:-}" \
|
||||
setsid nohup "$pool_script" \
|
||||
"$POOL_MAX" "$QUEUE_DIR" "$POOL_MIN_BIN" \
|
||||
>> "$QUEUE_DIR/pool.log" 2>&1 < /dev/null &
|
||||
}
|
||||
|
||||
# ── component-3: bend-dispatcher ────────────────────────────────────
|
||||
# Multi-worker dispatcher writes per-worker heartbeats. Considered alive
|
||||
# if ANY worker's heartbeat stays fresh — single-worker stalls absorbed
|
||||
# by others. Considered dead only if NO worker heartbeat stays fresh
|
||||
# AND .ready or .bin work waits.
|
||||
#
|
||||
# Discovery: glob $QUEUE_DIR/dispatcher-*.heartbeat. Empty glob → no
|
||||
# dispatcher ever ran → restart if work waits.
|
||||
dispatcher_any_alive() {
|
||||
local hb
|
||||
for hb in "$QUEUE_DIR"/dispatcher-*.heartbeat; do
|
||||
[ -f "$hb" ] || continue
|
||||
local svc
|
||||
svc=$(basename "$hb" .heartbeat)
|
||||
if heartbeat_is_alive "$QUEUE_DIR" "$svc" "$DISP_STALE_SEC"; then
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
# Fall through: singleton dispatcher (this script) writes
|
||||
# dispatcher.heartbeat directly when present.
|
||||
heartbeat_is_alive "$QUEUE_DIR" "dispatcher" "$DISP_STALE_SEC"
|
||||
}
|
||||
|
||||
count_dispatchable() {
|
||||
local n
|
||||
n=$(ls "$QUEUE_DIR"/*.ready "$QUEUE_DIR"/*.bin 2>/dev/null | wc -l)
|
||||
echo "$n"
|
||||
}
|
||||
|
||||
restart_dispatcher() {
|
||||
log "restart bend-dispatcher BEND_ENDPOINTS=$BEND_ENDPOINTS"
|
||||
# Stale heartbeats / inflight markers will be reclaimed by a new
|
||||
# dispatcher's startup orphan-reclaim block. Clean only the lock so
|
||||
# new instance can flock.
|
||||
rm -f "$QUEUE_DIR/dispatcher.lock" "$QUEUE_DIR"/dispatcher-*.heartbeat 2>/dev/null
|
||||
BEND_ENDPOINTS="$BEND_ENDPOINTS" \
|
||||
LUMBDA_DOMAIN_DIR="$LUMBDA_DOMAIN_DIR" \
|
||||
LUMBDA_BACKEND_CMD="$LUMBDA_BACKEND_CMD" \
|
||||
setsid nohup "$LUMBDA_FACTORY_DIR/bend-dispatcher.sh" \
|
||||
"$QUEUE_DIR" "$QUEUE_DIR/results.tsv" "$POOL_BATCHES" \
|
||||
>> "$QUEUE_DIR/dispatcher.log" 2>&1 < /dev/null &
|
||||
}
|
||||
|
||||
# DLQ runner — auto-resolves transient DLQ classes (missing-bin,
|
||||
# bisect-pool-race, transient cuda-error, no-portal) and escalates the
|
||||
# persistent classes to rdlq/.
|
||||
DLQ_RUNNER_STALE_SEC="${DLQ_RUNNER_STALE_SEC:-180}"
|
||||
|
||||
dlq_runner_alive() {
|
||||
heartbeat_is_alive "$QUEUE_DIR" "dlq-runner" "$DLQ_RUNNER_STALE_SEC"
|
||||
}
|
||||
|
||||
restart_dlq_runner() {
|
||||
log "restart bend-supervisor-dlq-runner"
|
||||
LUMBDA_QUEUE_DIR="$QUEUE_DIR" \
|
||||
LUMBDA_FACTORY_DIR="$LUMBDA_FACTORY_DIR" \
|
||||
setsid nohup "$LUMBDA_FACTORY_DIR/bend-supervisor-dlq-runner.sh" \
|
||||
>> "$QUEUE_DIR/dlq-runner.log" 2>&1 < /dev/null &
|
||||
}
|
||||
|
||||
# ── hot-reload tunable settings ─────────────────────────────────────
|
||||
# Lets operators bump POOL_MAX, BEND_ENDPOINTS, POOL_BATCHES live without
|
||||
# restarting the supervisor. Drop a KEY=VALUE file at $QUEUE_DIR/supervisor.config;
|
||||
# next poll iter reads it, kills affected child (pool / dispatcher),
|
||||
# normal restart loop respawns with new value.
|
||||
CONFIG_FILE="$QUEUE_DIR/supervisor.config"
|
||||
# Fingerprint TIER_* keys so hot-reload knows when any per-tier cap or
|
||||
# threshold changed. Compares concatenated env values, not file mtime, so
|
||||
# unrelated edits (POOL_MAX bump alone) don't churn the dispatcher.
|
||||
tier_fingerprint() {
|
||||
printf '%s' \
|
||||
"${TIER_MICRO_MIN:-}|${TIER_MICRO_MAX:-}|${TIER_SMALL_MAX:-}|${TIER_MEDIUM_MAX:-}|${TIER_LARGE_MAX:-}|" \
|
||||
"${TIER_MICRO_POOL_MAX:-}|${TIER_MICRO_DISPATCH:-}|" \
|
||||
"${TIER_SMALL_POOL_MAX:-}|${TIER_SMALL_DISPATCH:-}|" \
|
||||
"${TIER_MEDIUM_POOL_MAX:-}|${TIER_MEDIUM_DISPATCH:-}|" \
|
||||
"${TIER_LARGE_POOL_MAX:-}|${TIER_LARGE_DISPATCH:-}"
|
||||
}
|
||||
reload_config() {
|
||||
[ -f "$CONFIG_FILE" ] || return 0
|
||||
local prev_pool_max="$POOL_MAX"
|
||||
local prev_endpoints="$BEND_ENDPOINTS"
|
||||
local prev_batches="$POOL_BATCHES"
|
||||
local prev_pool_min_bin="$POOL_MIN_BIN"
|
||||
local prev_tier_fp
|
||||
prev_tier_fp=$(tier_fingerprint)
|
||||
# `set -a` exports every var assigned during the source so children
|
||||
# (pool, dispatcher) inherit them. Without this, TIER_* + POOL_* set
|
||||
# in supervisor.config stay local to this shell + dispatcher respawn
|
||||
# would pick up lib-tier defaults instead of operator overrides.
|
||||
set -a
|
||||
# shellcheck source=/dev/null
|
||||
. "$CONFIG_FILE"
|
||||
set +a
|
||||
local new_tier_fp
|
||||
new_tier_fp=$(tier_fingerprint)
|
||||
if [ "$POOL_MAX" != "$prev_pool_max" ] || [ "$POOL_MIN_BIN" != "$prev_pool_min_bin" ]; then
|
||||
log "hot-reload POOL_MAX=$POOL_MAX POOL_MIN_BIN=$POOL_MIN_BIN (was MAX=$prev_pool_max MIN=$prev_pool_min_bin); killing pool, restart loop respawns"
|
||||
pkill -f "bend-emit-pool.sh" 2>/dev/null
|
||||
fi
|
||||
# Dispatcher kill ONLY on endpoint / batches changes — tier-only
|
||||
# changes flow live into workers via tier_reload_caps_from_file in
|
||||
# lib-tier.sh.
|
||||
if [ "$BEND_ENDPOINTS" != "$prev_endpoints" ] || [ "$POOL_BATCHES" != "$prev_batches" ]; then
|
||||
log "hot-reload dispatcher config changed (killing dispatcher, restart loop respawns)"
|
||||
pkill -f "bend-dispatcher.sh" 2>/dev/null
|
||||
elif [ "$new_tier_fp" != "$prev_tier_fp" ]; then
|
||||
log "hot-reload tier policy changed (live-reload — no dispatcher kill, workers read tier caps live)"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── main loop ───────────────────────────────────────────────────────
|
||||
while true; do
|
||||
# Heartbeat first thing — supervisor must prove its own liveness too.
|
||||
# factory-status reads supervisor.heartbeat to know whether a host
|
||||
# has its own caretaker.
|
||||
heartbeat_touch "$QUEUE_DIR" "supervisor"
|
||||
reload_config
|
||||
|
||||
if [ -f "$STOP_FILE" ]; then
|
||||
log "STOP marker seen — exiting"
|
||||
rm -f "$STOP_FILE"
|
||||
SUP_EXIT_REASON="stop-marker"
|
||||
break
|
||||
fi
|
||||
|
||||
# backend first — pool + dispatcher both need a live backend.
|
||||
if ! bend_port_listening; then
|
||||
log "backend DOWN on :${BEND_PORT_LISTEN}"
|
||||
restart_bend
|
||||
fi
|
||||
|
||||
# pool
|
||||
if ! pool_alive; then
|
||||
n_unworked=$(count_unworked_lsp)
|
||||
if [ "$n_unworked" -gt 0 ]; then
|
||||
log "pool DEAD with $n_unworked un-emitted .lsp"
|
||||
restart_pool
|
||||
else
|
||||
log "pool DEAD but queue clean — nothing to restart for"
|
||||
fi
|
||||
fi
|
||||
|
||||
# dispatcher
|
||||
if ! dispatcher_any_alive; then
|
||||
n_dispatchable=$(count_dispatchable)
|
||||
if [ "$n_dispatchable" -gt 0 ]; then
|
||||
log "dispatcher DEAD with $n_dispatchable .ready+.bin waiting"
|
||||
restart_dispatcher
|
||||
else
|
||||
log "dispatcher DEAD but no .ready / .bin — nothing to restart for"
|
||||
fi
|
||||
fi
|
||||
|
||||
# DLQ runner — auto-resolve or escalate. Always restart when stale;
|
||||
# cheap to run idle (scans an empty dlq/ in milliseconds).
|
||||
if ! dlq_runner_alive; then
|
||||
log "dlq-runner DEAD (no fresh heartbeat) — restarting"
|
||||
restart_dlq_runner
|
||||
fi
|
||||
|
||||
sleep "$POLL_SECONDS"
|
||||
done
|
||||
108
factory/lib-heartbeat.sh
Executable file
108
factory/lib-heartbeat.sh
Executable file
|
|
@ -0,0 +1,108 @@
|
|||
#!/usr/bin/env bash
|
||||
# lib-heartbeat.sh — shared library for factory liveness signals.
|
||||
#
|
||||
# Sourced by bend-emit-pool.sh, bend-dispatcher.sh, bend-supervisor.sh,
|
||||
# bend-autoscaler.sh, bend-supervisor-dlq-runner.sh. Pure POSIX-ish bash;
|
||||
# no external daemons; portable to busybox-bash + flock + setsid + nohup.
|
||||
#
|
||||
# Env vars consumed: none. See $LUMBDA_FACTORY_DIR/CONTRACT.md for full
|
||||
# factory-wide knob list.
|
||||
#
|
||||
# Contract:
|
||||
# - heartbeat_touch <state_dir> <service_name>
|
||||
# Writes "<pid> <unix_ts>" to <state_dir>/<service_name>.heartbeat.
|
||||
# Called at top of every loop iteration; mtime + content authoritative.
|
||||
# Returns 0 on success, 1 on write failure.
|
||||
#
|
||||
# - heartbeat_is_alive <state_dir> <service_name> <stale_threshold_sec>
|
||||
# Returns 0 if BOTH:
|
||||
# (a) heartbeat file mtime within <stale_threshold_sec> of now
|
||||
# (b) pid recorded in heartbeat file responds to kill -0
|
||||
# Returns 1 otherwise (missing file, stale mtime, dead pid).
|
||||
# (a) gates daemon liveness; (b) gates against post-mortem stale files.
|
||||
#
|
||||
# - heartbeat_exit_cause <state_dir> <service_name> <reason>
|
||||
# Appends "<utc_iso8601> <pid> <reason>" to
|
||||
# <state_dir>/<service_name>.exit-cause, removes <service_name>.heartbeat.
|
||||
# Caller invokes from every exit path (STOP marker, signal trap, drain
|
||||
# timeout, error abort). Operators read .exit-cause to triage post-mortem.
|
||||
#
|
||||
# File formats:
|
||||
# <service>.heartbeat — single line: "<pid> <unix_ts>" (whitespace-sep)
|
||||
# <service>.exit-cause — append-only log, one line per exit:
|
||||
# "<utc_iso8601> <pid> <reason>"
|
||||
#
|
||||
# Backwards compatibility: legacy per-worker dispatcher.heartbeat-<wid>
|
||||
# files used freeform text content + only mtime for liveness. heartbeat_is_alive
|
||||
# falls back to mtime-only when a heartbeat file does not parse as
|
||||
# "<pid> <unix_ts>".
|
||||
#
|
||||
# Caller responsibilities:
|
||||
# - mkdir -p "$state_dir" BEFORE calling any function (library does NOT
|
||||
# auto-create; refusing to silently swallow path typos).
|
||||
# - Wrap heartbeat_touch in an EXIT trap that calls heartbeat_exit_cause
|
||||
# so an unexpected crash still leaves a forensic trail.
|
||||
|
||||
# heartbeat_touch <state_dir> <service_name>
|
||||
heartbeat_touch() {
|
||||
local state_dir="$1"
|
||||
local service_name="$2"
|
||||
local hb_file="${state_dir}/${service_name}.heartbeat"
|
||||
printf '%s %s\n' "$$" "$(date +%s)" > "$hb_file" 2>/dev/null
|
||||
}
|
||||
|
||||
# heartbeat_is_alive <state_dir> <service_name> <stale_threshold_sec>
|
||||
# Returns 0 alive, 1 dead/stale.
|
||||
heartbeat_is_alive() {
|
||||
local state_dir="$1"
|
||||
local service_name="$2"
|
||||
local threshold="$3"
|
||||
local hb_file="${state_dir}/${service_name}.heartbeat"
|
||||
|
||||
[ -f "$hb_file" ] || return 1
|
||||
|
||||
# mtime gate
|
||||
local now mtime age
|
||||
now=$(date +%s)
|
||||
mtime=$(stat -c %Y "$hb_file" 2>/dev/null) || return 1
|
||||
age=$(( now - mtime ))
|
||||
[ "$age" -le "$threshold" ] || return 1
|
||||
|
||||
# pid gate: parse first token. Tolerate freeform legacy content by
|
||||
# treating non-numeric first token as "mtime-only mode" — return 0 if
|
||||
# mtime gate already passed.
|
||||
local first_token
|
||||
first_token=$(awk 'NR==1 {print $1}' "$hb_file" 2>/dev/null)
|
||||
if [ -z "$first_token" ]; then
|
||||
return 0
|
||||
fi
|
||||
case "$first_token" in
|
||||
''|*[!0-9]*)
|
||||
# non-numeric (e.g., ISO timestamp from legacy writer) → mtime-only
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
|
||||
# Numeric pid: must respond to kill -0. Cross-host caveat: this library
|
||||
# runs local to whichever process reads our heartbeat. heartbeat_is_alive
|
||||
# called against a remote host's state dir over SSH must run on that
|
||||
# host (kill -0 lives in local pid space only). Supervisor + status
|
||||
# callers SSH per-host before calling, so this holds.
|
||||
if kill -0 "$first_token" 2>/dev/null; then
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
# heartbeat_exit_cause <state_dir> <service_name> <reason>
|
||||
heartbeat_exit_cause() {
|
||||
local state_dir="$1"
|
||||
local service_name="$2"
|
||||
local reason="$3"
|
||||
local ec_file="${state_dir}/${service_name}.exit-cause"
|
||||
local hb_file="${state_dir}/${service_name}.heartbeat"
|
||||
local ts
|
||||
ts=$(date -u +%FT%TZ)
|
||||
printf '%s %s %s\n' "$ts" "$$" "$reason" >> "$ec_file" 2>/dev/null
|
||||
rm -f "$hb_file" 2>/dev/null
|
||||
}
|
||||
203
factory/lib-tier.sh
Executable file
203
factory/lib-tier.sh
Executable file
|
|
@ -0,0 +1,203 @@
|
|||
#!/usr/bin/env bash
|
||||
# lib-tier.sh — bin-size tier classifier + policy table.
|
||||
#
|
||||
# Bins sized below MIN or above MAX go to DLQ unconditionally. In-range
|
||||
# bins get classified into a tier name. Each tier carries a default
|
||||
# concurrency cap pair (POOL_MAX, DISPATCH) that supervisor / pool /
|
||||
# dispatcher consult — overridable via $LUMBDA_QUEUE_DIR/supervisor.config
|
||||
# `TIER_<NAME>_*` keys for hot-reload tuning.
|
||||
#
|
||||
# Why tiers: a backend's CPU+VRAM residency scales ~linearly with bin
|
||||
# size. A 24-way fleet that fits 250-MB bins crushes a box on 2.4-GB
|
||||
# bins. Per-tier admission lets a factory autopilot through a mixed
|
||||
# workload without dead-zoning either extreme.
|
||||
#
|
||||
# Pure bash — no jq / awk / python deps, sourceable by every factory
|
||||
# component.
|
||||
#
|
||||
# Env vars consumed: see $LUMBDA_FACTORY_DIR/CONTRACT.md for our canonical
|
||||
# table. TIER_* knobs (boundaries + per-tier caps + per-tier MIB estimates)
|
||||
# all readable via env override; defaults below match a 24-core / 62 GB RAM
|
||||
# / 24 GB VRAM workstation profile.
|
||||
|
||||
# ── tier boundaries (bytes) ──────────────────────────────────────
|
||||
# A bin's tier = lowest band whose MAX it does NOT exceed.
|
||||
# A bin below TIER_MICRO_MIN gets DLQ'd as "too-small" (broken emit).
|
||||
# A bin above TIER_LARGE_MAX gets DLQ'd as "too-large" (would OOM a fleet).
|
||||
TIER_MICRO_MIN="${TIER_MICRO_MIN:-33554432}" # 32 MB — emit-broken floor
|
||||
TIER_MICRO_MAX="${TIER_MICRO_MAX:-268435456}" # 256 MB
|
||||
TIER_SMALL_MAX="${TIER_SMALL_MAX:-536870912}" # 512 MB
|
||||
TIER_MEDIUM_MAX="${TIER_MEDIUM_MAX:-2147483648}" # 2 GB
|
||||
TIER_LARGE_MAX="${TIER_LARGE_MAX:-4294967296}" # 4 GB — co-resident ceiling
|
||||
# huge tier: bins above LARGE that still fit a fleet but only when
|
||||
# a card has no co-tenants. Largest candidate bins land here (6-8 GB
|
||||
# observed). HUGE_MAX is "single bin needs ALL of VRAM" — our autoscaler
|
||||
# must drain other tiers before dispatching huge.
|
||||
# At 8 GB bin × 1.55 ratio = ~12.4 GB RSS, well under 24 GB VRAM / 62 GB
|
||||
# RAM ceilings of a typical workstation when run alone.
|
||||
TIER_HUGE_MAX="${TIER_HUGE_MAX:-12884901888}" # 12 GB — solo-dispatch ceiling
|
||||
|
||||
# ── default concurrency caps per tier ────────────────────────────
|
||||
# Numbers tuned for a 24-core, 62 GB RAM, 24 GB VRAM workstation.
|
||||
# Override via supervisor.config: TIER_<NAME>_POOL_MAX / TIER_<NAME>_DISPATCH.
|
||||
# Autoscaler may rewrite these based on live RAM headroom.
|
||||
TIER_MICRO_POOL_MAX="${TIER_MICRO_POOL_MAX:-24}"
|
||||
TIER_MICRO_DISPATCH="${TIER_MICRO_DISPATCH:-24}"
|
||||
TIER_SMALL_POOL_MAX="${TIER_SMALL_POOL_MAX:-16}"
|
||||
TIER_SMALL_DISPATCH="${TIER_SMALL_DISPATCH:-16}"
|
||||
TIER_MEDIUM_POOL_MAX="${TIER_MEDIUM_POOL_MAX:-12}"
|
||||
TIER_MEDIUM_DISPATCH="${TIER_MEDIUM_DISPATCH:-12}"
|
||||
TIER_LARGE_POOL_MAX="${TIER_LARGE_POOL_MAX:-6}"
|
||||
TIER_LARGE_DISPATCH="${TIER_LARGE_DISPATCH:-6}"
|
||||
# HUGE: solo-dispatch tier. Both caps = 1; one huge bin at a time.
|
||||
# Autoscaler must enforce "no other tier dispatches in flight before
|
||||
# admitting a huge dispatch" — cap=1 alone does not guarantee that
|
||||
# (small + huge could overlap if scheduled naively).
|
||||
TIER_HUGE_POOL_MAX="${TIER_HUGE_POOL_MAX:-1}"
|
||||
TIER_HUGE_DISPATCH="${TIER_HUGE_DISPATCH:-1}"
|
||||
|
||||
# ── per-tier per-dispatch memory estimates (MiB) ──────────────────
|
||||
# Used by our autoscaler to compute "total estimated memory at current
|
||||
# caps" for tier-mix admission. Calibrated from /usr/bin/time -v on a
|
||||
# backend running against representative bins, scaled to each tier ceiling.
|
||||
#
|
||||
# Sample calibration data (peak RSS):
|
||||
# 683 MB bin → 1.05 GB RSS (ratio 1.54×) → MEDIUM tier sample
|
||||
# 2.4 GB bin → 3.55 GB RSS (ratio 1.48×) → LARGE tier sample
|
||||
#
|
||||
# CPU_MIB = tier-ceiling × 1.55× × small safety pad, so a tier-full bin
|
||||
# never exceeds an admitted budget.
|
||||
# MICRO ceil 256 MB × 1.55 → ~397 → round 512
|
||||
# SMALL ceil 512 MB × 1.55 → ~794 → round 1024
|
||||
# MEDIUM ceil 2 GB × 1.55 → ~3174 → round 3328
|
||||
# LARGE ceil 4 GB × 1.55 → ~6349 → round 6400
|
||||
TIER_MICRO_CPU_MIB="${TIER_MICRO_CPU_MIB:-512}"
|
||||
TIER_MICRO_VRAM_MIB="${TIER_MICRO_VRAM_MIB:-512}"
|
||||
TIER_SMALL_CPU_MIB="${TIER_SMALL_CPU_MIB:-1024}"
|
||||
TIER_SMALL_VRAM_MIB="${TIER_SMALL_VRAM_MIB:-1024}"
|
||||
TIER_MEDIUM_CPU_MIB="${TIER_MEDIUM_CPU_MIB:-3328}"
|
||||
TIER_MEDIUM_VRAM_MIB="${TIER_MEDIUM_VRAM_MIB:-2048}"
|
||||
TIER_LARGE_CPU_MIB="${TIER_LARGE_CPU_MIB:-6400}"
|
||||
TIER_LARGE_VRAM_MIB="${TIER_LARGE_VRAM_MIB:-3072}"
|
||||
# HUGE: 12 GB bin × 1.55 ratio ≈ 19000 MiB RSS at peak. Numbers below
|
||||
# act as upper-bound "needs whole card" — autoscaler treats them as
|
||||
# a wholesale claim, not adds-to-budget like other tiers.
|
||||
TIER_HUGE_CPU_MIB="${TIER_HUGE_CPU_MIB:-19000}"
|
||||
TIER_HUGE_VRAM_MIB="${TIER_HUGE_VRAM_MIB:-20480}"
|
||||
|
||||
# tier_of_size BYTES → echoes one of:
|
||||
# below-min bin too small; DLQ as broken emit
|
||||
# micro in-band [TIER_MICRO_MIN, TIER_MICRO_MAX]
|
||||
# small (TIER_MICRO_MAX, TIER_SMALL_MAX]
|
||||
# medium (TIER_SMALL_MAX, TIER_MEDIUM_MAX]
|
||||
# large (TIER_MEDIUM_MAX, TIER_LARGE_MAX]
|
||||
# huge (TIER_LARGE_MAX, TIER_HUGE_MAX] — solo-dispatch
|
||||
# above-max bin too large; DLQ as fleet overflow
|
||||
tier_of_size() {
|
||||
local sz="$1"
|
||||
if [ -z "$sz" ] || [ "$sz" -lt "$TIER_MICRO_MIN" ] 2>/dev/null; then
|
||||
echo "below-min"; return
|
||||
fi
|
||||
if [ "$sz" -le "$TIER_MICRO_MAX" ]; then echo "micro"; return; fi
|
||||
if [ "$sz" -le "$TIER_SMALL_MAX" ]; then echo "small"; return; fi
|
||||
if [ "$sz" -le "$TIER_MEDIUM_MAX" ]; then echo "medium"; return; fi
|
||||
if [ "$sz" -le "$TIER_LARGE_MAX" ]; then echo "large"; return; fi
|
||||
if [ "$sz" -le "$TIER_HUGE_MAX" ]; then echo "huge"; return; fi
|
||||
echo "above-max"
|
||||
}
|
||||
|
||||
# Re-read tier caps from supervisor.config each call so autoscaler tweaks
|
||||
# land in long-running dispatcher / pool workers WITHOUT a process restart.
|
||||
# Old behavior baked TIER_* env at fork time → autoscaler had to kill the
|
||||
# dispatcher to update caps → 30 s downtime per autoscale event. With
|
||||
# live-reload, autoscaler edits supervisor.config + next tier_dispatch /
|
||||
# tier_pool_max call sees a new value with no restart.
|
||||
#
|
||||
# Cost: sourcing a small KEY=VALUE file via grep+eval per call costs a
|
||||
# few ms. tier_dispatch fires once per cell claim ≈ every 10-30 s per
|
||||
# worker, so overhead stays invisible.
|
||||
#
|
||||
# Gated by TIER_CONFIG_LIVE_RELOAD=1 (default ON); set to 0 to fall back
|
||||
# to env-only behavior (legacy / debugging).
|
||||
TIER_CONFIG_FILE="${TIER_CONFIG_FILE:-${LUMBDA_QUEUE_DIR:-/tmp/lumbda-queue}/supervisor.config}"
|
||||
TIER_CONFIG_LIVE_RELOAD="${TIER_CONFIG_LIVE_RELOAD:-1}"
|
||||
|
||||
tier_reload_caps_from_file() {
|
||||
[ "$TIER_CONFIG_LIVE_RELOAD" = "1" ] || return 0
|
||||
[ -f "$TIER_CONFIG_FILE" ] || return 0
|
||||
# Eval only TIER_* lines from a config file — never inherit
|
||||
# unrelated vars (POOL_MAX etc. stay supervisor-only, change those
|
||||
# via supervisor restart).
|
||||
eval "$(grep -E '^TIER_[A-Z_]+=' "$TIER_CONFIG_FILE" 2>/dev/null)"
|
||||
}
|
||||
|
||||
# tier_pool_max NAME → echoes integer cap (0 for DLQ tiers)
|
||||
tier_pool_max() {
|
||||
tier_reload_caps_from_file
|
||||
case "$1" in
|
||||
micro) echo "$TIER_MICRO_POOL_MAX" ;;
|
||||
small) echo "$TIER_SMALL_POOL_MAX" ;;
|
||||
medium) echo "$TIER_MEDIUM_POOL_MAX" ;;
|
||||
large) echo "$TIER_LARGE_POOL_MAX" ;;
|
||||
huge) echo "$TIER_HUGE_POOL_MAX" ;;
|
||||
*) echo 0 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# tier_dispatch NAME → echoes integer cap (0 for DLQ tiers)
|
||||
tier_dispatch() {
|
||||
tier_reload_caps_from_file
|
||||
case "$1" in
|
||||
micro) echo "$TIER_MICRO_DISPATCH" ;;
|
||||
small) echo "$TIER_SMALL_DISPATCH" ;;
|
||||
medium) echo "$TIER_MEDIUM_DISPATCH" ;;
|
||||
large) echo "$TIER_LARGE_DISPATCH" ;;
|
||||
huge) echo "$TIER_HUGE_DISPATCH" ;;
|
||||
*) echo 0 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# tier_cpu_mib NAME → estimated CPU RSS per dispatch in MiB
|
||||
tier_cpu_mib() {
|
||||
case "$1" in
|
||||
micro) echo "$TIER_MICRO_CPU_MIB" ;;
|
||||
small) echo "$TIER_SMALL_CPU_MIB" ;;
|
||||
medium) echo "$TIER_MEDIUM_CPU_MIB" ;;
|
||||
large) echo "$TIER_LARGE_CPU_MIB" ;;
|
||||
huge) echo "$TIER_HUGE_CPU_MIB" ;;
|
||||
*) echo 0 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# tier_vram_mib NAME → estimated VRAM per dispatch in MiB
|
||||
tier_vram_mib() {
|
||||
case "$1" in
|
||||
micro) echo "$TIER_MICRO_VRAM_MIB" ;;
|
||||
small) echo "$TIER_SMALL_VRAM_MIB" ;;
|
||||
medium) echo "$TIER_MEDIUM_VRAM_MIB" ;;
|
||||
large) echo "$TIER_LARGE_VRAM_MIB" ;;
|
||||
huge) echo "$TIER_HUGE_VRAM_MIB" ;;
|
||||
*) echo 0 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# tier_solo NAME → 1 when a tier needs an empty card (no co-tenant
|
||||
# dispatches); 0 when it shares a fleet with other tiers. Used by our
|
||||
# autoscaler / dispatcher: before admitting a `huge` dispatch, poll all
|
||||
# other tiers + verify zero inflight. lib-tier owns CLASSIFICATION;
|
||||
# a controller owns ENFORCEMENT.
|
||||
tier_solo() {
|
||||
case "$1" in
|
||||
huge) echo 1 ;;
|
||||
*) echo 0 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# tier_label_dlq NAME → reason string for DLQ (or empty if not DLQ)
|
||||
tier_label_dlq() {
|
||||
case "$1" in
|
||||
below-min) echo "bin-too-small (under tier-micro min ${TIER_MICRO_MIN} bytes) — emit produced no usable circuit" ;;
|
||||
above-max) echo "bin-too-large (over tier-huge max ${TIER_HUGE_MAX} bytes) — would exceed fleet RAM/VRAM ceiling even with solo dispatch" ;;
|
||||
*) echo "" ;;
|
||||
esac
|
||||
}
|
||||
43
quantum/README.md
Normal file
43
quantum/README.md
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
# quantum/ — circuit emission primitives
|
||||
|
||||
Reversible-circuit emission primitives written in lumbda Scheme: Clifford gates, ripple-carry adder (Cuccaro), modular arithmetic, modular inverse strategies, Karatsuba multiplier, Clifford tableau simulator, circuit scoring. Originally developed at foxhop for secp256k1 attack-surface research; shared upstream under AGPLv3.
|
||||
|
||||
## Layers
|
||||
|
||||
### Core gates
|
||||
|
||||
- `gates.lsp` — Clifford gate library: `CX`, `CCX` (Toffoli), `X`. Counts Toffoli + Clifford totals as side effect for scoring.
|
||||
|
||||
### Arithmetic
|
||||
|
||||
- `adder.lsp` — Cuccaro ripple-carry reversible n-bit adder. Width-parametric.
|
||||
- `mod-arith.lsp` — modular add/sub/cmp/neg/mul/square under `*field-prime*`. Default unset; consumer binds (foxhop sets secp256k1 `p`).
|
||||
- `mod-karatsuba.lsp` — Karatsuba reversible multiplier for ⊕-bit widths. Width-parametric.
|
||||
|
||||
### Modular inverse strategies
|
||||
|
||||
- `mod-inv-by.lsp` — Bennett by-value-uncompute pattern (host classical `1/x`, then quantum verify-and-zero).
|
||||
- `mod-inv-by-dialog-gcd.lsp` — Dialog GCD (binary extended Euclidean) reversible mod-inv. General algorithm, not field-specific.
|
||||
- `mod-inv-by-dialog-gcd-host.lsp` — Host-assisted variant: classical GCD trace replayed as quantum dialog log.
|
||||
|
||||
### Simulation + scoring
|
||||
|
||||
- `sim.lsp` — Clifford tableau simulator. Verifies emitted circuits against expected truth tables.
|
||||
- `score.lsp` — circuit metric collector. Tracks Toffoli + qubit + Clifford counts during emission.
|
||||
|
||||
## Width discipline
|
||||
|
||||
Every primitive width-parametric — no hardcoded `n = 256`. Consumers bind `*field-prime*`, `*width*`, and optional flags (e.g. `*mod-mul-use-karatsuba*`).
|
||||
|
||||
## Hard rule — bound every loop, bound every ancilla
|
||||
|
||||
- `*max-shots*` caps shot counts in sim (default 64 for dev; raise only by explicit instruction).
|
||||
- Every `alloc!` call site asserts width-cap at top of caller. Unbounded growth = host hard-freeze on neoblanka (proven 2026-06-11/12).
|
||||
|
||||
## Lumbda tier compatibility
|
||||
|
||||
These primitives run under any lumbda tier (Python VM, C tree-walker + JIT, C + x86 JIT, pure x86 asm). Cross-tier portal validation: same `.lsp` on Python-tier + C-tier must produce byte-identical result S-expressions. Mismatch halts promotion.
|
||||
|
||||
## License
|
||||
|
||||
AGPLv3.
|
||||
2824
quantum/adder.lsp
Normal file
2824
quantum/adder.lsp
Normal file
File diff suppressed because it is too large
Load diff
441
quantum/gates.lsp
Normal file
441
quantum/gates.lsp
Normal file
|
|
@ -0,0 +1,441 @@
|
|||
;;; gates.lsp — Reversible gate primitives, emit S-expression circuit stream.
|
||||
;;;
|
||||
;;; A circuit builder accumulates a list of ops. emit-circuit walks our
|
||||
;;; builder into a tagged S-expression that any portal reader (sim.lsp on
|
||||
;;; any tier, a future asm reader, a Rust codegen step) can consume.
|
||||
;;;
|
||||
;;; Wire format (`portals/circuit.portal`):
|
||||
;;;
|
||||
;;; (circuit
|
||||
;;; (version 1)
|
||||
;;; (curve <name>)
|
||||
;;; (registers (<name> <width>) ...)
|
||||
;;; (input (<name> <integer>) ...)
|
||||
;;; (constants (<name> <integer>) ...) ; optional
|
||||
;;; (ops <op> ...)
|
||||
;;; (expected-output (<name> <integer>) ...)) ; optional
|
||||
;;;
|
||||
;;; Ops:
|
||||
;;; (alloc <name> <width>)
|
||||
;;; (free <name>)
|
||||
;;; (x (<reg> <idx>))
|
||||
;;; (cx (<reg> <idx>) (<reg> <idx>))
|
||||
;;; (ccx (<reg> <idx>) (<reg> <idx>) (<reg> <idx>))
|
||||
|
||||
;;; ── width caps (guard asm-tier bump allocator territory) ──────
|
||||
;;;
|
||||
;;; Per foxhop CLAUDE.md ### ecdsa/ — lumbda asm-tier safety. asm tier
|
||||
;;; default binary lacks GC; every register width feeds into r15 growth.
|
||||
;;; Override at top of main.lsp via set! when a phase needs more
|
||||
;;; headroom — explicit, traceable, never silent.
|
||||
|
||||
(define *max-register-width* 4096)
|
||||
(define *max-ancilla-width* 1024)
|
||||
|
||||
(define (assert-width! kind name width)
|
||||
(cond
|
||||
((not (integer? width))
|
||||
(error "non-integer width" (list kind name width)))
|
||||
((< width 1)
|
||||
(error "width must be positive" (list kind name width)))
|
||||
((and (eq? kind 'register) (> width *max-register-width*))
|
||||
(error "register width cap exceeded"
|
||||
(list 'register name 'requested width
|
||||
'cap *max-register-width*)))
|
||||
((and (eq? kind 'ancilla) (> width *max-ancilla-width*))
|
||||
(error "ancilla width cap exceeded"
|
||||
(list 'ancilla name 'requested width
|
||||
'cap *max-ancilla-width*)))))
|
||||
|
||||
;;; ── circuit builder (mutable cell-of-lists) ───────────────────
|
||||
|
||||
;;; Slot 5 (mirrors) is the Phase B step 10 classical-mirror channel:
|
||||
;;; an alist (name value) tracking the live classical value of a register
|
||||
;;; as it mutates across ops. Distinct from slot 1 (inputs), which the
|
||||
;;; simulator reads to seed register start-of-circuit bit patterns and
|
||||
;;; therefore must NOT be mutated mid-circuit. mod-inv-by-refined!'s
|
||||
;;; find-classical-value queries the mirror first, falling back to the
|
||||
;;; bound input — so standalone callers (test-mod-inv-by) need no mirror,
|
||||
;;; while real-point-add! rebinds its mirror before each mod-inv! call
|
||||
;;; to feed the current classical value of tx-reg into the refined path.
|
||||
|
||||
;;; Slot 6 (emit-fn) is the Phase B step 12 streaming-construction
|
||||
;;; channel: optional one-argument procedure `(emit-fn op)`. When non-#f,
|
||||
;;; emit-op! invokes it with each lumbda op form INSTEAD of consing onto
|
||||
;;; circ-ops. Callers that need O(1) construction RAM (production-width
|
||||
;;; secp256k1 emit) install a sink that walks each op to its upstream
|
||||
;;; op-spec representation & drops the lumbda cons graph immediately.
|
||||
;;; Default #f preserves the legacy accumulator path so every existing
|
||||
;;; test & sweep keeps working byte-identical.
|
||||
|
||||
(define (make-circuit)
|
||||
(vector '() '() '() '() '() '() #f))
|
||||
;; registers, inputs, constants, ops, expected, mirrors, emit-fn
|
||||
|
||||
(define (circ-registers c) (vector-ref c 0))
|
||||
(define (circ-inputs c) (vector-ref c 1))
|
||||
(define (circ-constants c) (vector-ref c 2))
|
||||
(define (circ-ops c) (vector-ref c 3))
|
||||
(define (circ-expected c) (vector-ref c 4))
|
||||
(define (circ-mirrors c) (vector-ref c 5))
|
||||
(define (circ-emit-fn c) (vector-ref c 6))
|
||||
|
||||
(define (set-circ-registers! c v) (vector-set! c 0 v))
|
||||
(define (set-circ-inputs! c v) (vector-set! c 1 v))
|
||||
(define (set-circ-constants! c v) (vector-set! c 2 v))
|
||||
(define (set-circ-ops! c v) (vector-set! c 3 v))
|
||||
(define (set-circ-expected! c v) (vector-set! c 4 v))
|
||||
(define (set-circ-mirrors! c v) (vector-set! c 5 v))
|
||||
(define (set-circ-emit-fn! c v) (vector-set! c 6 v))
|
||||
|
||||
;;; ── declarations ──────────────────────────────────────────────
|
||||
|
||||
(define (declare-register! c name width)
|
||||
(assert-width! 'register name width)
|
||||
(set-circ-registers! c (cons (list name width) (circ-registers c))))
|
||||
|
||||
;;; *static-circuit-mode* — when #t, lumbda emits a HEAD-style single
|
||||
;;; circuit that processes any input. Per-input binding (bind-input!,
|
||||
;;; bind-mirror!, rebind-mirror!) errors so we can't accidentally bake
|
||||
;;; an a-value into the gate stream; find-classical-value returns #f
|
||||
;;; so primitives that rely on a classical oracle (mod-inv-by-refined!,
|
||||
;;; real-point-add!'s K-correction replay) fail loudly instead of
|
||||
;;; silently producing a per-input circuit. Default #f preserves every
|
||||
;;; existing research cell's byte-identity.
|
||||
;;;
|
||||
;;; To flip on at runtime: cells that want the static-circuit path
|
||||
;;; do `(set! *static-circuit-mode* #t)` at top alongside their other
|
||||
;;; substrate flags. Phase C's Makefile wrapper injects this line into
|
||||
;;; a wrapper-cell.lsp ahead of the cell's body.
|
||||
;;;
|
||||
;;; PRIOR REVISION used (getenv "STATIC_CIRCUIT_MODE") for env-driven
|
||||
;;; activation — lumbda --fast hangs on `getenv` (unbound, late-bind
|
||||
;;; retries forever). Reverted to plain (define ... #f). 2026-06-12.
|
||||
(define *static-circuit-mode* #f)
|
||||
|
||||
(define (bind-input! c name value)
|
||||
(cond
|
||||
(*static-circuit-mode*
|
||||
(error "bind-input! forbidden under *static-circuit-mode* — a static circuit cannot bake input values" name))
|
||||
(else
|
||||
(set-circ-inputs! c (cons (list name value) (circ-inputs c))))))
|
||||
|
||||
;;; find-bound-input — read the binding for a register name (slot 1 only).
|
||||
;;; Returns the bound classical integer, or #f if name has no binding.
|
||||
;;; The simulator's initial-value semantics — never mutated mid-circuit.
|
||||
(define (find-bound-input c name)
|
||||
(let ((row (assoc name (circ-inputs c))))
|
||||
(cond
|
||||
(row (car (cdr row)))
|
||||
(else #f))))
|
||||
|
||||
;;; bind-mirror! / rebind-mirror! — Phase B step 10 classical-mirror channel
|
||||
;;; (slot 5). Distinct from bind-input! because the simulator reads slot 1
|
||||
;;; to initialize register bit patterns at the START of the circuit;
|
||||
;;; mutating it mid-stream would change what the simulator believes the
|
||||
;;; register starts as. The mirror tracks the LIVE classical value of a
|
||||
;;; register as it mutates across ops. Callers like real-point-add! update
|
||||
;;; the mirror before each mod-inv! call so mod-inv-by-refined!'s
|
||||
;;; find-classical-value sees the current classical value.
|
||||
|
||||
(define (bind-mirror! c name value)
|
||||
(cond
|
||||
(*static-circuit-mode*
|
||||
(error "bind-mirror! forbidden under *static-circuit-mode* — classical mirror channel requires per-input emit" name))
|
||||
(else
|
||||
(set-circ-mirrors! c (cons (list name value) (circ-mirrors c))))))
|
||||
|
||||
(define (rebind-mirror! c name value)
|
||||
"Replace name's existing mirror with a fresh value; bind if absent.
|
||||
Mutates in place so circ-mirrors never accumulates stale duplicates."
|
||||
(cond
|
||||
(*static-circuit-mode*
|
||||
(error "rebind-mirror! forbidden under *static-circuit-mode* — classical mirror channel requires per-input emit" name))
|
||||
(else
|
||||
(let loop ((rest (circ-mirrors c)) (acc '()) (found #f))
|
||||
(cond
|
||||
((null? rest)
|
||||
(cond
|
||||
(found (set-circ-mirrors! c (reverse acc)))
|
||||
(else (bind-mirror! c name value))))
|
||||
((eq? (car (car rest)) name)
|
||||
(loop (cdr rest) (cons (list name value) acc) #t))
|
||||
(else
|
||||
(loop (cdr rest) (cons (car rest) acc) found)))))))
|
||||
|
||||
(define (find-mirror c name)
|
||||
(let ((row (assoc name (circ-mirrors c))))
|
||||
(cond
|
||||
(row (car (cdr row)))
|
||||
(else #f))))
|
||||
|
||||
;;; find-classical-value — preferred lookup for refined mod-inv. Returns
|
||||
;;; the mirror value if present (the LIVE classical value), else falls
|
||||
;;; back to the bound input (the start-of-circuit value). Standalone
|
||||
;;; callers that never set mirrors get the input — same behavior as
|
||||
;;; before mirrors existed.
|
||||
(define (find-classical-value c name)
|
||||
(cond
|
||||
(*static-circuit-mode* #f)
|
||||
(else
|
||||
(let ((mv (find-mirror c name)))
|
||||
(cond
|
||||
(mv mv)
|
||||
(else (find-bound-input c name)))))))
|
||||
|
||||
(define (bind-constant! c name value)
|
||||
(set-circ-constants! c (cons (list name value) (circ-constants c))))
|
||||
|
||||
(define (expect-output! c name value)
|
||||
(set-circ-expected! c (cons (list name value) (circ-expected c))))
|
||||
|
||||
;;; ── gate primitives ───────────────────────────────────────────
|
||||
|
||||
;;; emit-op! is the single chokepoint every Phase B primitive
|
||||
;;; (cuccaro!, mod-add!, mod-mul!, mod-inv*, real-point-add!) bottoms
|
||||
;;; out at via the gate-x!/gate-cx!/gate-ccx!/alloc!/free! constructors
|
||||
;;; above. Two-mode dispatch: when (circ-emit-fn c) is set, we hand the
|
||||
;;; op to a caller-installed sink & skip the accumulator. When unset
|
||||
;;; (default), we cons onto circ-ops exactly as before. Refactoring
|
||||
;;; here flows automatically through every primitive — no per-primitive
|
||||
;;; signature change needed.
|
||||
(define (emit-op! c op)
|
||||
(let ((sink (circ-emit-fn c)))
|
||||
(cond
|
||||
(sink (sink op))
|
||||
(else (set-circ-ops! c (cons op (circ-ops c)))))))
|
||||
|
||||
;;; Fast-path gate constructors: when (circ-emit-fn c) is set, skip
|
||||
;;; lumbda S-exp construction entirely & call sink with positional
|
||||
;;; tag + raw refs. The sink walks ref→qid + builds op-spec in one
|
||||
;;; shot. Avoids 4 cons cells per gate (mod-mul-solinas! does that
|
||||
;;; ~256 times per Toffoli at secp256k1 width). Accumulator path
|
||||
;;; keeps existing S-exp shape so every downstream walker
|
||||
;;; (point-add->ops, sim.lsp, emit-circuit) sees byte-identical input.
|
||||
;;;
|
||||
;;; Sink contract (streaming mode) — FIXED-ARITY 8 args (tag + 7 slots):
|
||||
;;; (sink 'x reg idx #f #f #f #f #f) — gate-x
|
||||
;;; (sink 'cx cr ci tr ti #f #f #f) — gate-cx
|
||||
;;; (sink 'ccx c1r c1i c2r c2i tr ti #f) — gate-ccx
|
||||
;;; (sink 'alloc name width #f #f #f #f #f) — alloc
|
||||
;;; (sink 'free name #f #f #f #f #f #f) — free
|
||||
;;;
|
||||
;;; Fixed-arity dispatch avoids lumbda's variadic-args list allocation
|
||||
;;; (which costs ~one Pair per call). At secp256k1 width with ~100M
|
||||
;;; gates, that's ~100M extra Pair allocations on the hot path —
|
||||
;;; benchmark showed sink call cost dropping from ~600 us/op to a
|
||||
;;; few μs/op. Unused slots hold #f sentinel.
|
||||
|
||||
(define (gate-x! c reg idx)
|
||||
"NOT on a single qubit. Clifford."
|
||||
(let ((sink (circ-emit-fn c)))
|
||||
(cond
|
||||
(sink (sink 'x reg idx #f #f #f #f #f))
|
||||
(else (set-circ-ops! c (cons (list 'x (list reg idx)) (circ-ops c)))))))
|
||||
|
||||
(define (gate-cx! c ctrl-reg ctrl-idx tgt-reg tgt-idx)
|
||||
"Controlled-NOT (CNOT). Clifford."
|
||||
(let ((sink (circ-emit-fn c)))
|
||||
(cond
|
||||
(sink (sink 'cx ctrl-reg ctrl-idx tgt-reg tgt-idx #f #f #f))
|
||||
(else (set-circ-ops! c
|
||||
(cons (list 'cx
|
||||
(list ctrl-reg ctrl-idx)
|
||||
(list tgt-reg tgt-idx))
|
||||
(circ-ops c)))))))
|
||||
|
||||
(define (gate-ccx! c c1-reg c1-idx c2-reg c2-idx tgt-reg tgt-idx)
|
||||
"Doubly-controlled-NOT (Toffoli). One Toffoli charge."
|
||||
(let ((sink (circ-emit-fn c)))
|
||||
(cond
|
||||
(sink (sink 'ccx c1-reg c1-idx c2-reg c2-idx tgt-reg tgt-idx #f))
|
||||
(else (set-circ-ops! c
|
||||
(cons (list 'ccx
|
||||
(list c1-reg c1-idx)
|
||||
(list c2-reg c2-idx)
|
||||
(list tgt-reg tgt-idx))
|
||||
(circ-ops c)))))))
|
||||
|
||||
(define (gate-z! c reg idx)
|
||||
"Single-qubit phase flip on |1⟩. Clifford. QECCOPS1 op kind 7."
|
||||
(let ((sink (circ-emit-fn c)))
|
||||
(cond
|
||||
(sink (sink 'z reg idx #f #f #f #f #f))
|
||||
(else (set-circ-ops! c (cons (list 'z (list reg idx)) (circ-ops c)))))))
|
||||
|
||||
(define (gate-cz! c ctrl-reg ctrl-idx tgt-reg tgt-idx)
|
||||
"Controlled-phase. Clifford. Kind 9."
|
||||
(let ((sink (circ-emit-fn c)))
|
||||
(cond
|
||||
(sink (sink 'cz ctrl-reg ctrl-idx tgt-reg tgt-idx #f #f #f))
|
||||
(else (set-circ-ops! c
|
||||
(cons (list 'cz
|
||||
(list ctrl-reg ctrl-idx)
|
||||
(list tgt-reg tgt-idx))
|
||||
(circ-ops c)))))))
|
||||
|
||||
(define (gate-ccz! c c1-reg c1-idx c2-reg c2-idx tgt-reg tgt-idx)
|
||||
"Doubly-controlled-phase. One Toffoli charge. Kind 14."
|
||||
(let ((sink (circ-emit-fn c)))
|
||||
(cond
|
||||
(sink (sink 'ccz c1-reg c1-idx c2-reg c2-idx tgt-reg tgt-idx #f))
|
||||
(else (set-circ-ops! c
|
||||
(cons (list 'ccz
|
||||
(list c1-reg c1-idx)
|
||||
(list c2-reg c2-idx)
|
||||
(list tgt-reg tgt-idx))
|
||||
(circ-ops c)))))))
|
||||
|
||||
(define (gate-r! c reg idx)
|
||||
"Single-qubit reset to |0⟩ via RNG-driven measure + conditional flip.
|
||||
Clifford. Kind 11. CPU sim: sim_cpu.c lines 86-90."
|
||||
(let ((sink (circ-emit-fn c)))
|
||||
(cond
|
||||
(sink (sink 'r reg idx #f #f #f #f #f))
|
||||
(else (set-circ-ops! c (cons (list 'r (list reg idx)) (circ-ops c)))))))
|
||||
|
||||
;;; ── Tier-2 classical-bit primitives ──────────────────────────────
|
||||
;;;
|
||||
;;; Classical bits live as integer IDs in QECCOPS1; analyze.c auto-sizes
|
||||
;;; num_bits from max(c_target, c_condition) seen across the op stream.
|
||||
;;; No Register/Append boilerplate needed for bits — caller manages
|
||||
;;; integer IDs directly (a monotonic counter at point-add scope is
|
||||
;;; sufficient).
|
||||
;;;
|
||||
;;; Sink calling convention for Tier-2 (still fixed-arity-8 to avoid
|
||||
;;; variadic-args list allocation on the hot path):
|
||||
;;; 'bit-invert : (sink 'bit-invert bit-id #f #f #f #f #f #f)
|
||||
;;; 'bit-store0 : (sink 'bit-store0 bit-id #f #f #f #f #f #f)
|
||||
;;; 'bit-store1 : (sink 'bit-store1 bit-id #f #f #f #f #f #f)
|
||||
;;; 'hmr : (sink 'hmr qreg qidx bit-id #f #f #f #f)
|
||||
;;; 'push-cond : (sink 'push-cond bit-id #f #f #f #f #f #f)
|
||||
;;; 'pop-cond : (sink 'pop-cond #f #f #f #f #f #f #f)
|
||||
|
||||
(define (gate-bit-invert! c bit-id)
|
||||
"Flip a classical bit. Kind 3."
|
||||
(let ((sink (circ-emit-fn c)))
|
||||
(cond
|
||||
(sink (sink 'bit-invert bit-id #f #f #f #f #f #f))
|
||||
(else (set-circ-ops! c (cons (list 'bit-invert bit-id) (circ-ops c)))))))
|
||||
|
||||
(define (gate-bit-store0! c bit-id)
|
||||
"Set a classical bit to 0. Kind 4."
|
||||
(let ((sink (circ-emit-fn c)))
|
||||
(cond
|
||||
(sink (sink 'bit-store0 bit-id #f #f #f #f #f #f))
|
||||
(else (set-circ-ops! c (cons (list 'bit-store0 bit-id) (circ-ops c)))))))
|
||||
|
||||
(define (gate-bit-store1! c bit-id)
|
||||
"Set a classical bit to 1. Kind 5."
|
||||
(let ((sink (circ-emit-fn c)))
|
||||
(cond
|
||||
(sink (sink 'bit-store1 bit-id #f #f #f #f #f #f))
|
||||
(else (set-circ-ops! c (cons (list 'bit-store1 bit-id) (circ-ops c)))))))
|
||||
|
||||
(define (gate-hmr! c qreg qidx bit-id)
|
||||
"Hadamard + Measure + Reset on a qubit, measured outcome into a
|
||||
classical bit. Clifford. Kind 12. CPU sim: sim_cpu.c lines 79-85.
|
||||
RNG consumes one u64 from the shake stream per shot."
|
||||
(let ((sink (circ-emit-fn c)))
|
||||
(cond
|
||||
(sink (sink 'hmr qreg qidx bit-id #f #f #f #f))
|
||||
(else (set-circ-ops! c
|
||||
(cons (list 'hmr (list qreg qidx) bit-id)
|
||||
(circ-ops c)))))))
|
||||
|
||||
(define (gate-push-cond! c bit-id)
|
||||
"Push current base_cond onto stack, narrow base_cond by ANDing with
|
||||
bit. Subsequent ops execute conditionally on bit=1. Kind 15."
|
||||
(let ((sink (circ-emit-fn c)))
|
||||
(cond
|
||||
(sink (sink 'push-cond bit-id #f #f #f #f #f #f))
|
||||
(else (set-circ-ops! c (cons (list 'push-cond bit-id) (circ-ops c)))))))
|
||||
|
||||
(define (gate-pop-cond! c)
|
||||
"Pop base_cond off the stack. Restores prior conditional scope. Kind 16."
|
||||
(let ((sink (circ-emit-fn c)))
|
||||
(cond
|
||||
(sink (sink 'pop-cond #f #f #f #f #f #f #f))
|
||||
(else (set-circ-ops! c (cons (list 'pop-cond) (circ-ops c)))))))
|
||||
|
||||
;;; ── bit-register ↔ qubit-register interop ────────────────────────
|
||||
;;;
|
||||
;;; Static-circuit substrate. HEAD's load_bits / unload_bits (adder.rs:
|
||||
;;; 293) materialize a classical bit-register into a quantum scratch
|
||||
;;; register via per-position conditional-X: push-cond(bit) + X(q) +
|
||||
;;; pop-cond. Equivalent to b.x_if(qs[i], bits[i]) in HEAD's API.
|
||||
;;;
|
||||
;;; Unlike load-const! / unload-const! (mod-arith.lsp), these do NOT
|
||||
;;; bake a classical integer into the gate stream — the bit-register's
|
||||
;;; runtime value is whatever the test driver wrote via gate-bit-store0!
|
||||
;;; / gate-bit-store1! / hmr. This is the per-shot input channel for a
|
||||
;;; static circuit (ox, oy in HEAD's emit_dialog_gcd_raw_pa).
|
||||
;;;
|
||||
;;; bits-list: a Scheme list of n bit-ID integers (the offset_x or
|
||||
;;; offset_y bit register). load-bits! / unload-bits! are self-inverse
|
||||
;;; (cx-via-push-x-pop is its own inverse) — same shape as load-const!.
|
||||
|
||||
(define (load-bits! c qs-reg bits-list n)
|
||||
"qs-reg[i] ^= bits-list[i] for i in [0, n). qs-reg must be n-wide,
|
||||
|0> on entry; on exit holds the bit-register's runtime value."
|
||||
(let loop ((i 0) (rest bits-list))
|
||||
(cond
|
||||
((or (= i n) (null? rest)) #f)
|
||||
(else
|
||||
(gate-push-cond! c (car rest))
|
||||
(gate-x! c qs-reg i)
|
||||
(gate-pop-cond! c)
|
||||
(loop (+ i 1) (cdr rest))))))
|
||||
|
||||
(define (unload-bits! c qs-reg bits-list n)
|
||||
"Inverse of load-bits! (self-inverse: same push-x-pop triple uncomputes
|
||||
the load). Returns qs-reg to |0> n-wide, leaving bits-list unchanged."
|
||||
(load-bits! c qs-reg bits-list n))
|
||||
|
||||
(define (alloc! c name width . hint-rest)
|
||||
"Allocate an ancilla register. Counts toward peak-qubit width.
|
||||
|
||||
Optional 4th positional arg: hint-base (integer). When non-#f
|
||||
and *allocator-base-hint-enabled* is on at sink-construction
|
||||
time, the streaming allocator will try to carve exactly the
|
||||
hint-base..hint-base+width window from its range-pool. Hint
|
||||
silently ignored when (a) flag off, (b) arg not integer, or
|
||||
(c) window not fully contained in any free range. Default-OFF
|
||||
semantics preserved — pre-existing 3-arg callers behave
|
||||
byte-identically to pre-port master."
|
||||
(assert-width! 'ancilla name width)
|
||||
(let ((sink (circ-emit-fn c))
|
||||
(hint-base (cond ((null? hint-rest) #f)
|
||||
(else (car hint-rest)))))
|
||||
(cond
|
||||
(sink (sink 'alloc name width hint-base #f #f #f #f))
|
||||
(else (set-circ-ops! c (cons (list 'alloc name width) (circ-ops c)))))))
|
||||
|
||||
(define (free! c name)
|
||||
"Free an ancilla. Sim asserts every bit returned to zero."
|
||||
(let ((sink (circ-emit-fn c)))
|
||||
(cond
|
||||
(sink (sink 'free name #f #f #f #f #f #f))
|
||||
(else (set-circ-ops! c (cons (list 'free name) (circ-ops c)))))))
|
||||
|
||||
;;; ── finalize & write portal ───────────────────────────────────
|
||||
|
||||
(define (emit-circuit c curve-name)
|
||||
"Walk our builder into a finished circuit S-expression."
|
||||
(list 'circuit
|
||||
(list 'version 1)
|
||||
(list 'curve curve-name)
|
||||
(cons 'registers (reverse (circ-registers c)))
|
||||
(cons 'input (reverse (circ-inputs c)))
|
||||
(cons 'constants (reverse (circ-constants c)))
|
||||
(cons 'ops (reverse (circ-ops c)))
|
||||
(cons 'expected-output (reverse (circ-expected c)))))
|
||||
|
||||
(define (write-portal! filename sexp)
|
||||
"Write an S-expression to a portal file atomically (write tmp, rename)."
|
||||
(let ((tmp (string-append filename ".tmp")))
|
||||
(let ((port (open-output-file tmp)))
|
||||
(write sexp port)
|
||||
(newline port)
|
||||
(close-port port))
|
||||
(rename-file tmp filename)))
|
||||
6127
quantum/mod-arith.lsp
Normal file
6127
quantum/mod-arith.lsp
Normal file
File diff suppressed because it is too large
Load diff
900
quantum/mod-inv-by-dialog-gcd-host.lsp
Normal file
900
quantum/mod-inv-by-dialog-gcd-host.lsp
Normal file
|
|
@ -0,0 +1,900 @@
|
|||
;;; mod-inv-by-dialog-gcd-host.lsp — sweep-006: dialog_log substrate port.
|
||||
;;;
|
||||
;;; Layers HEAD's HOST_GATED / APPLY_WINDOW_BLOCKS / BODY_HOST_CIN /
|
||||
;;; FUSED_BRANCH_BITS knobs on top of sweep-005's mod-inv-by-dialog-gcd!.
|
||||
;;; Source: `~/git/ecdsafail-challenge/src/point_add/mod.rs`
|
||||
;;; - `dialog_log` register at mod.rs:25044 (declared via
|
||||
;;; `b.alloc_qubits(DIALOG_GCD_RAW_LOG_BITS)`).
|
||||
;;; - HOST_GATED gate at mod.rs:24664-24676, applied at
|
||||
;;; `dialog_gcd_controlled_sub_selected` mod.rs:24727-24737 +
|
||||
;;; `_add_selected` mod.rs:24811-24821.
|
||||
;;; - APPLY_WINDOW_BLOCKS at mod.rs:25148-25156 +
|
||||
;;; `dialog_gcd_apply_window_blocks` at mod.rs:25191.
|
||||
;;; - BODY_HOST_CIN at mod.rs:24677-24685, applied at the
|
||||
;;; materialized-sub body c_in alloc skip
|
||||
;;; (mod.rs:24764-24773 / 24848-24857).
|
||||
;;; - FUSED_BRANCH_BITS at mod.rs:24305-24310, applied at
|
||||
;;; mod.rs:24926-24939 via dialog_gcd_ccx_cmp_gt_truncated_into_width.
|
||||
;;;
|
||||
;;; ── Honest port boundary ────────────────────────────────────────
|
||||
;;;
|
||||
;;; HEAD's HOST_GATED clears the borrowed `gated[i]` slot via
|
||||
;;;
|
||||
;;; b.hmr(gated[i], m); ; Hadamard + Measure + Reset
|
||||
;;; b.cz_if(ctrl, addend[i], m) ; classical-feedback CZ
|
||||
;;;
|
||||
;;; (mod.rs:24788-24791, _add path 24872-24875).
|
||||
;;;
|
||||
;;; lumbda's QECCOPS1 wire format owns only {Register, Append, X, CX,
|
||||
;;; CCX}. HMR (Hadamard / Measure / Reset) and CZ_if (classical-feedback
|
||||
;;; CZ) are not in our gate set. We CANNOT byte-mirror HEAD's
|
||||
;;; measurement-clear. The alternative — uncompute `gated[i]` via
|
||||
;;; another `ccx-mask!` pass — costs another (n+1) CCXs which
|
||||
;;; reverses any Toffoli win HEAD claims from the measurement-clear.
|
||||
;;;
|
||||
;;; What we CAN port: alloc-elision via a long-lived shared
|
||||
;;; `dgcd-host-mask` register, freed once at top level. Saves the
|
||||
;;; per-iter alloc/free pair from the upstream op stream (Register +
|
||||
;;; n+1 Appends per ctrl-cuccaro-sub call × `iters` calls × 2 inversions
|
||||
;;; per point-add). Concrete savings at our scale:
|
||||
;;; - sub mask: iters × (n+2) ops saved
|
||||
;;; - add mask: iters × (n+2) ops saved
|
||||
;;; - both forward + (classical-replay implicit) backward: × 1
|
||||
;;; At n+1=257, iters=395, 2 inv/point-add:
|
||||
;;; 2 × 395 × 259 × 2 = 409 220 ops saved per emit.
|
||||
;;;
|
||||
;;; FUSED_BRANCH_BITS analog: collapse STEP 1's first `gate-ccx!`
|
||||
;;; on l-gt and STEP 2's first `cmp-gt-into!` into a single
|
||||
;;; `dgcd-ccx-cmp-gt-into!` that targets `l-gt` directly through
|
||||
;;; the ctrl AND clause. Saves (uv-n) CCXs per iter (cmp-lt-into-fast
|
||||
;;; vs ccx-cmp-lt-into-fast).
|
||||
;;;
|
||||
;;; BODY_HOST_CIN analog: the existing per-call `cin-reg cin-idx` is
|
||||
;;; ALREADY caller-supplied and shared across iters (declared once at
|
||||
;;; the outer point-add scope, mod-inv-by-dialog-gcd! line 244). So
|
||||
;;; from our op stream's perspective, BODY_HOST_CIN is effectively
|
||||
;;; already on — no additional save.
|
||||
;;;
|
||||
;;; APPLY_WINDOW_BLOCKS analog: HEAD's apply phase windows the
|
||||
;;; cuccaro_add_fast carry lane into N blocks across the wide
|
||||
;;; accumulator. Our K-correction in-place-mul-const! IS our analog
|
||||
;;; of HEAD's apply phase but runs once (not per-iter) so windowing
|
||||
;;; produces near-zero practical savings at our scale. We expose the
|
||||
;;; knob as a no-op flag so the variant id can carry it for future
|
||||
;;; substrate growth (e.g. when sweep-007 lands a windowed mod-double).
|
||||
|
||||
;; Module load order: mod-inv-by.lsp & mod-inv-by-dialog-gcd.lsp MUST
|
||||
;; already be loaded by caller before this file. Re-loading them here
|
||||
;; would re-run mod-inv-by.lsp's forward-declaration of
|
||||
;; mod-inv-by-dialog-gcd-host! and shadow our real definition below
|
||||
;; (lumbda's `load` does not dedupe).
|
||||
;;
|
||||
;; *field-prime* convention: p stays a caller-supplied integer arg per
|
||||
;; call (preserved across Kaliski iters via classical-replay machinery).
|
||||
;; Consumers binding *field-prime* upstream-style pass it through entry
|
||||
;; points. See quantum/README.md.
|
||||
(load "quantum/gates.lsp")
|
||||
(load "quantum/adder.lsp")
|
||||
(load "quantum/mod-arith.lsp")
|
||||
|
||||
;;; ── foxhop-side substrate dependencies (NOT ported upstream) ──
|
||||
;;;
|
||||
;;; This host-assisted variant couples to several foxhop research
|
||||
;;; modules that stay private to ecdsa/ (K=2 / K=5 / raw-pa research
|
||||
;;; substrate, dgcd-apply-* callsite primitives). Loads commented out
|
||||
;;; for upstream publication. Consumers needing the host-assisted
|
||||
;;; dispatcher run this file from a foxhop tree where loads resolve.
|
||||
;;;
|
||||
;;; Stays AGPLv3 publication of our algorithm + interface; not a live
|
||||
;;; standalone substitute. Re-enable a load line iff that file ships
|
||||
;;; alongside under quantum/.
|
||||
;;
|
||||
;; (load "quantum/k2-bounded-shift.lsp") ; foxhop K=2 substrate
|
||||
;; (load "quantum/mod-arith-k2.lsp") ; foxhop K=2 substrate
|
||||
;; (load "quantum/apply-fused-fold.lsp") ; foxhop apply-phase fused-fold
|
||||
;; (load "quantum/K2-pair-body.lsp") ; foxhop K2-pair body
|
||||
;; (load "quantum/compressed-block-lifecycle.lsp"); foxhop compressed-block
|
||||
;; (load "quantum/host-reverse-raw-block.lsp") ; foxhop raw-pa research
|
||||
;; (load "quantum/dgcd-borrow-current-block.lsp") ; foxhop dgcd substrate
|
||||
;; (load "quantum/dgcd-apply-fused-fold.lsp") ; foxhop K=5 callsite
|
||||
;; (load "quantum/dgcd-apply-forward-ipmul.lsp") ; foxhop K=5 callsite
|
||||
;; (load "quantum/dgcd-apply-reverse-ipmul.lsp") ; foxhop K=5 callsite
|
||||
;; (load "quantum/dgcd-k5-quotient-callsite.lsp") ; foxhop K=5 callsite
|
||||
|
||||
;;; ── knob flags ────────────────────────────────────────────────────
|
||||
|
||||
(define *mod-inv-by-dialog-gcd-host* #f)
|
||||
;;; *kcorr-cdtf-window-override* — sweep-blackops-trust-factory follow-on
|
||||
;;; (2026-06-11). Cell-controllable override for the K-correction cdtf
|
||||
;;; window. Default #f means use 24 (formula-based safe minimum from
|
||||
;;; bisect at n+1=20: 8 default + 8 log2 + 8 safety). Set to an integer
|
||||
;;; in a cell to test tighter windows + find production-width minimum.
|
||||
(define *kcorr-cdtf-window-override* #f)
|
||||
(define *dgcd-host-gated* #f)
|
||||
(define *dgcd-apply-window-blocks* #f)
|
||||
(define *dgcd-body-host-cin* #f)
|
||||
(define *dgcd-fused-branch-bits* #f)
|
||||
|
||||
;;; *dgcd-ctrl-body-vented* — sweep-vented-body-wire flag.
|
||||
;;;
|
||||
;;; When #t, ctrl-cuccaro-{add,sub}-hosted! at STEP 4 dispatch through
|
||||
;;; cuccaro-{add,sub}-ctrl-vented! (HEAD origin/main commit 00fb66d,
|
||||
;;; 2026-06-10) instead of the mask + cuccaro-borrowed + unmask
|
||||
;;; pattern. The vented variant:
|
||||
;;; - skips the u-masked alloc / ccx-mask compute entirely
|
||||
;;; - threads the carry chain onto tmp (already-idle at STEP 4 after
|
||||
;;; STEP 2 comparator's borrow closes) as the vent_pool
|
||||
;;; - measures the carry chain back to |0> via HMR + cz_if
|
||||
;;; vs the existing path (ccx-mask + cuccaro-sub-fast-borrowed +
|
||||
;;; ccx-mask-hmr-uncompute), the vented variant trades the
|
||||
;;; mask/unmask overhead (n+1 CCX + n HMR) for an integrated
|
||||
;;; controlled-add with measured vent (fewer total Toffolis when the
|
||||
;;; tmp slot is available).
|
||||
;;;
|
||||
;;; Wired at the 2 STEP 4 callsites only — both inside ctrl-cuccaro-
|
||||
;;; {add,sub}-hosted! below. Default #f preserves byte-identity.
|
||||
(define *dgcd-ctrl-body-vented* #f)
|
||||
|
||||
;;; *dgcd-host-hmr-uncompute* — sweep-012 Tier-3 lever.
|
||||
;;;
|
||||
;;; When #t, the ctrl-cuccaro-*-hosted! uncompute step replaces the
|
||||
;;; second ccx-mask! call (n+1 Toffolis) with HEAD's HMR + CZ_if
|
||||
;;; measurement-clear pattern (mod.rs:24788-24791): one HMR per bit
|
||||
;;; lands gated-bit's value in a classical bit, then CZ_if applies
|
||||
;;; conditional phase correction. Net: 2(n+1) Cliffords replace
|
||||
;;; (n+1) Toffolis per ctrl-cuccaro-*-hosted! call. Predicted Toffoli
|
||||
;;; reduction at n+1=257, iters=512 textbook, 2 calls per iter:
|
||||
;;; 2 × 512 × 257 ≈ 263k Toffoli per emit.
|
||||
;;;
|
||||
;;; CAUTION — algorithmic correctness of HMR substitution depends on
|
||||
;;; phase compensation matching the original CCX's effect. Bend
|
||||
;;; dispatch's status PASS verifies CPU/GPU sim AGREEMENT but does
|
||||
;;; NOT verify mod-inverse correctness. Smoke test at n+1=5 + future
|
||||
;;; classical-sim verification (sweep-013) needed before promoting
|
||||
;;; this lever to production.
|
||||
|
||||
(define *dgcd-host-hmr-uncompute* #f)
|
||||
|
||||
;;; ── hosted ctrl-cuccaro-sub! / -add! that skip the inner alloc ─
|
||||
|
||||
(define (ccx-mask-hmr-uncompute! c ctrl-reg ctrl-idx u-reg u-masked-name n+1)
|
||||
"Replace n+1 CCXs of ccx-mask uncompute with HMR + push-cond + CZ +
|
||||
pop-cond per bit. Per HEAD mod.rs:24788-24791. Bit IDs 0..n+1 are
|
||||
reused per call — HMR resets the qubit, classical bit gets
|
||||
overwritten on next call so prior value irrelevant.
|
||||
|
||||
Phase semantics: measurement of (ctrl AND u[i]) projects the
|
||||
ancilla to |0> or |1>. If projected to |1>, a CZ between ctrl and
|
||||
u[i] corrects the phase that the original CCX had introduced into
|
||||
the joint (ctrl, u[i]) state. Classical-feedback CZ implements
|
||||
this via push-cond + CZ + pop-cond."
|
||||
(let loop ((i 0))
|
||||
(cond
|
||||
((>= i n+1) #t)
|
||||
(else
|
||||
(gate-hmr! c u-masked-name i i)
|
||||
(gate-push-cond! c i)
|
||||
(gate-cz! c ctrl-reg ctrl-idx u-reg i)
|
||||
(gate-pop-cond! c)
|
||||
(loop (+ i 1))))))
|
||||
|
||||
(define (ctrl-cuccaro-sub-hosted! c ctrl-reg ctrl-idx u-reg acc-reg n+1
|
||||
cin-reg cin-idx u-masked-name
|
||||
tmp-borrow-name body-w)
|
||||
"Same semantics as ctrl-cuccaro-sub! but assumes `u-masked-name` is
|
||||
ALREADY allocated by caller. Skips the inner alloc/free pair.
|
||||
|
||||
Dispatch ladder:
|
||||
- *dgcd-ctrl-body-vented* #t (+ body-w >= 2) → cuccaro-sub-ctrl-vented!
|
||||
skips the u-masked compute entirely; threads carry chain onto tmp
|
||||
as vent_pool with measured uncompute.
|
||||
- *cuccaro-use-borrowed* #t → cuccaro-sub-fast-borrowed! with
|
||||
tmp-borrow-name as carries source (ZERO peak qubit cost — tmp
|
||||
is already allocated by caller & free during STEP 4).
|
||||
- *cuccaro-use-fast* #t → cuccaro-sub-fast! with shared
|
||||
'kal-host-carries-shared register (peak +n-1).
|
||||
- default → cuccaro-sub! (UMA-CCX uncompute, costs (n-1) Toffoli)."
|
||||
;; VENTED branch — leading cond entry, skips ccx-mask + uncompute.
|
||||
;; tmp is used as vent_pool (n-1 wide); HMR bit-base at (* 8 body-w)
|
||||
;; selected past the largest other reservation in this file.
|
||||
(cond
|
||||
((and *dgcd-ctrl-body-vented* (>= body-w 2))
|
||||
(cuccaro-sub-ctrl-vented! c ctrl-reg ctrl-idx u-reg acc-reg body-w
|
||||
tmp-borrow-name 0 (* 8 body-w)))
|
||||
(else
|
||||
(ccx-mask! c ctrl-reg ctrl-idx u-reg u-masked-name body-w)
|
||||
(cond
|
||||
((and *cuccaro-use-borrowed* *cuccaro-use-borrowed-lane*)
|
||||
;; sweep-cuccaro-lane-vector: route through the lane-vector primitive.
|
||||
;; Thunk receives (body-w, tmp-borrow-name); returns lane-vec >= body-w-1.
|
||||
(let ((lane-vec (*cuccaro-lane-vector-thunk* body-w tmp-borrow-name)))
|
||||
(cuccaro-sub-fast-borrowed-lane! c u-masked-name acc-reg cin-reg cin-idx
|
||||
body-w lane-vec body-w)))
|
||||
(*cuccaro-use-borrowed*
|
||||
(cuccaro-sub-fast-borrowed! c u-masked-name acc-reg cin-reg cin-idx body-w
|
||||
tmp-borrow-name 0 body-w))
|
||||
(*cuccaro-use-fast*
|
||||
(cuccaro-sub-fast! c u-masked-name acc-reg cin-reg cin-idx body-w
|
||||
'kal-host-carries-shared body-w))
|
||||
(else
|
||||
(cuccaro-sub! c u-masked-name acc-reg cin-reg cin-idx body-w)))
|
||||
(cond
|
||||
(*dgcd-host-hmr-uncompute*
|
||||
(ccx-mask-hmr-uncompute! c ctrl-reg ctrl-idx u-reg u-masked-name body-w))
|
||||
(else
|
||||
(ccx-mask! c ctrl-reg ctrl-idx u-reg u-masked-name body-w))))))
|
||||
|
||||
(define (ctrl-cuccaro-add-hosted! c ctrl-reg ctrl-idx u-reg acc-reg n+1
|
||||
cin-reg cin-idx u-masked-name
|
||||
tmp-borrow-name body-w)
|
||||
;; VENTED branch — see ctrl-cuccaro-sub-hosted! header.
|
||||
(cond
|
||||
((and *dgcd-ctrl-body-vented* (>= body-w 2))
|
||||
(cuccaro-add-ctrl-vented! c ctrl-reg ctrl-idx u-reg acc-reg body-w
|
||||
tmp-borrow-name 0 (* 8 body-w)))
|
||||
(else
|
||||
(ccx-mask! c ctrl-reg ctrl-idx u-reg u-masked-name body-w)
|
||||
(cond
|
||||
((and *cuccaro-use-borrowed* *cuccaro-use-borrowed-lane*)
|
||||
(let ((lane-vec (*cuccaro-lane-vector-thunk* body-w tmp-borrow-name)))
|
||||
(cuccaro-add-fast-borrowed-lane! c u-masked-name acc-reg cin-reg cin-idx
|
||||
body-w lane-vec body-w)))
|
||||
(*cuccaro-use-borrowed*
|
||||
(cuccaro-add-fast-borrowed! c u-masked-name acc-reg cin-reg cin-idx body-w
|
||||
tmp-borrow-name 0 body-w))
|
||||
(*cuccaro-use-fast*
|
||||
(cuccaro-add-fast! c u-masked-name acc-reg cin-reg cin-idx body-w
|
||||
'kal-host-carries-shared body-w))
|
||||
(else
|
||||
(cuccaro-add! c u-masked-name acc-reg cin-reg cin-idx body-w)))
|
||||
(cond
|
||||
(*dgcd-host-hmr-uncompute*
|
||||
(ccx-mask-hmr-uncompute! c ctrl-reg ctrl-idx u-reg u-masked-name body-w))
|
||||
(else
|
||||
(ccx-mask! c ctrl-reg ctrl-idx u-reg u-masked-name body-w))))))
|
||||
|
||||
;;; ── fused dgcd-cmp-gt-into-l-gt! ────────────────────────────────
|
||||
;;;
|
||||
;;; In kaliski-iteration-dgcd! STEP 1 + STEP 2 we have:
|
||||
;;; STEP 1: gate-ccx! c f-name f-idx u-name 0 l-gt 0
|
||||
;;; STEP 2: dgcd-cmp-gt-into! ... l-gt ...
|
||||
;;; gate-ccx! ... add-f ...
|
||||
;;; ... delta gates ...
|
||||
;;; gate-ccx! ... add-f ...
|
||||
;;; dgcd-cmp-gt-into! ... l-gt ... ; uncompute
|
||||
;;;
|
||||
;;; FUSED variant: skip the STEP 1's gate-ccx into l-gt. Instead, use
|
||||
;;; l-gt purely as the comparator-output qubit. The forward sweep's
|
||||
;;; control flow on l-gt = (u > v) instead of (f AND u[0]) does NOT
|
||||
;;; match STEP 1's intended semantics. So our fused path uses a DIFFERENT
|
||||
;;; structure than the textbook iteration: l-gt remains the (u > v)
|
||||
;;; bit AND the STEP 1 `f AND u[0]` gate writes into a SEPARATE temporary
|
||||
;;; ancilla instead of l-gt.
|
||||
;;;
|
||||
;;; The net win: the comparator stays computed across STEP 2 instead of
|
||||
;;; being uncomputed inside STEP 1 and re-computed at STEP 2 entry.
|
||||
;;; Concretely we drop ONE pair of cmp-gt-into! calls per iter (the
|
||||
;;; uncompute-then-recompute pair at l-gt 0 lines 298-299, 306-307 in
|
||||
;;; kaliski-iteration-dgcd!).
|
||||
|
||||
;;; ── public entry mod-inv-by-dialog-gcd-host! ─────────────────────
|
||||
|
||||
(define (kaliski-iteration-dgcd-host!
|
||||
c iter-idx n+1 p
|
||||
u-name v-w-name r-name s-name
|
||||
f-name f-idx
|
||||
m-hist-name m-idx
|
||||
cin-name cin-idx
|
||||
tmp-name flag-name flag-idx
|
||||
red-tmp-name
|
||||
host-mask-name
|
||||
k2-shift2-bit)
|
||||
"Kaliski iteration under HEAD's DIALOG_GCD width envelope + HOST_GATED
|
||||
alloc elision. host-mask-name is a long-lived (n+1)-wide register at
|
||||
|0>/|0> that replaces per-iter sub/add mask allocations.
|
||||
|
||||
k2-shift2-bit is the classically-known 2nd-shift bit for this iter
|
||||
(0 or 1). Consulted only when *dgcd-k2-bounded-shift* is `#t`. When
|
||||
=1, this iter emits a 2nd shift-right-reg! on v_w + a 2nd
|
||||
mod-double-inplace! on r (per HEAD K=2 bounded-shift algorithm).
|
||||
This is classical-specialization on the bound input a — same shape
|
||||
of trick the backward-sweep classical-replay already uses."
|
||||
(let* ((a-f (kaliski-iter-name "kal-dg-host-a-f" iter-idx))
|
||||
(b-f (kaliski-iter-name "kal-dg-host-b-f" iter-idx))
|
||||
(add-f (kaliski-iter-name "kal-dg-host-add-f" iter-idx))
|
||||
(l-gt (kaliski-iter-name "kal-dg-host-l-gt" iter-idx))
|
||||
(or-chain (kaliski-iter-name "kal-dg-host-or" iter-idx))
|
||||
(n (- n+1 1))
|
||||
(uv-w (dgcd-uv-width-raw iter-idx n+1))
|
||||
(uv-n (- uv-w 1)))
|
||||
(alloc! c a-f 1)
|
||||
(alloc! c b-f 1)
|
||||
(alloc! c add-f 1)
|
||||
(alloc! c l-gt 1)
|
||||
|
||||
;; ── STEP 0 ──
|
||||
(is-zero-into! c v-w-name uv-n flag-name flag-idx or-chain)
|
||||
(gate-ccx! c f-name f-idx flag-name flag-idx m-hist-name m-idx)
|
||||
(is-zero-into! c v-w-name uv-n flag-name flag-idx or-chain)
|
||||
(gate-cx! c m-hist-name m-idx f-name f-idx)
|
||||
|
||||
;; ── STEP 1 (lane 0) ──
|
||||
(gate-x! c u-name 0)
|
||||
(gate-ccx! c f-name f-idx u-name 0 a-f 0)
|
||||
(gate-x! c u-name 0)
|
||||
(gate-ccx! c f-name f-idx u-name 0 l-gt 0)
|
||||
(gate-x! c v-w-name 0)
|
||||
(gate-ccx! c l-gt 0 v-w-name 0 m-hist-name m-idx)
|
||||
(gate-x! c v-w-name 0)
|
||||
(gate-ccx! c f-name f-idx u-name 0 l-gt 0)
|
||||
(gate-cx! c a-f 0 b-f 0)
|
||||
(gate-cx! c m-hist-name m-idx b-f 0)
|
||||
|
||||
;; ── STEP 2 — TRUNCATED comparator (D2) ──
|
||||
(dgcd-cmp-gt-into! c u-name v-w-name uv-n iter-idx
|
||||
l-gt 0 cin-name cin-idx
|
||||
tmp-name)
|
||||
(gate-ccx! c f-name f-idx l-gt 0 add-f 0)
|
||||
(gate-x! c b-f 0)
|
||||
(gate-ccx! c add-f 0 b-f 0 a-f 0)
|
||||
(gate-ccx! c add-f 0 b-f 0 m-hist-name m-idx)
|
||||
(gate-x! c b-f 0)
|
||||
(gate-ccx! c f-name f-idx l-gt 0 add-f 0)
|
||||
(dgcd-cmp-gt-into! c u-name v-w-name uv-n iter-idx
|
||||
l-gt 0 cin-name cin-idx
|
||||
tmp-name)
|
||||
|
||||
;; ── STEP 3 ──
|
||||
;; sweep-k2-port FIX: under K=2 r picks up high bits from prior K=2
|
||||
;; doublings — narrow `mib-rs-step3-width` (iter+1) drops them. Widen
|
||||
;; to full n+1 when K=2 active. Same correction at STEP 4 + STEP 9.
|
||||
(cswap-reg! c a-f 0 u-name v-w-name uv-w)
|
||||
(cswap-reg! c a-f 0 r-name s-name
|
||||
(cond (*dgcd-k2-bounded-shift* n+1)
|
||||
(else (mib-rs-step3-width iter-idx n+1))))
|
||||
|
||||
;; ── STEP 4 — HOSTED ctrl-cuccaro paths ──
|
||||
;; body-w (HEAD dialog_gcd_body_carry_trunc_width): trims active
|
||||
;; width below envelope uv-w. When *dgcd-body-carry-trunc-width*
|
||||
;; is #f returns uv-w → byte-identical to pre-sweep-051.
|
||||
(gate-x! c b-f 0)
|
||||
(gate-ccx! c f-name f-idx b-f 0 add-f 0)
|
||||
(gate-x! c b-f 0)
|
||||
(let* ((rs-step9-w (cond (*dgcd-k2-bounded-shift* n+1)
|
||||
(else (mib-rs-step9-width iter-idx n+1))))
|
||||
(sub-body-w (dgcd-body-carry-trunc-width-for uv-w iter-idx))
|
||||
(add-body-w (dgcd-body-carry-trunc-width-for rs-step9-w iter-idx)))
|
||||
(cond
|
||||
(*dgcd-host-gated*
|
||||
(ctrl-cuccaro-sub-hosted! c add-f 0 u-name v-w-name uv-w
|
||||
cin-name cin-idx
|
||||
host-mask-name
|
||||
tmp-name
|
||||
sub-body-w)
|
||||
(ctrl-cuccaro-add-hosted! c add-f 0 r-name s-name
|
||||
rs-step9-w
|
||||
cin-name cin-idx
|
||||
host-mask-name
|
||||
tmp-name
|
||||
add-body-w))
|
||||
(else
|
||||
(ctrl-cuccaro-sub! c add-f 0 u-name v-w-name sub-body-w
|
||||
cin-name cin-idx
|
||||
(kaliski-iter-name "kal-dg-host-sub-mask" iter-idx))
|
||||
(ctrl-cuccaro-add! c add-f 0 r-name s-name
|
||||
add-body-w
|
||||
cin-name cin-idx
|
||||
(kaliski-iter-name "kal-dg-host-add-mask" iter-idx)))))
|
||||
|
||||
;; ── STEP 5 ──
|
||||
(gate-x! c b-f 0)
|
||||
(gate-ccx! c f-name f-idx b-f 0 add-f 0)
|
||||
(gate-x! c b-f 0)
|
||||
(gate-cx! c m-hist-name m-idx b-f 0)
|
||||
(gate-cx! c a-f 0 b-f 0)
|
||||
|
||||
;; ── STEP 6 ──
|
||||
(shift-right-reg! c v-w-name uv-w)
|
||||
|
||||
;; ── STEP 6.5 — K=2 BOUNDED SHIFT (sweep-k2-port) ────────────────
|
||||
;; When *dgcd-k2-bounded-shift* is #t AND classical replay says this
|
||||
;; iter has a 2nd trailing zero, emit a 2nd shift-right-reg!. Per
|
||||
;; HEAD compressed.rs:857-876 the K=2 algorithm strips up to TWO
|
||||
;; trailing zeros per iter — convergence improves ~35 %.
|
||||
;;
|
||||
;; Classical-specialization: this circuit ships at build-time for
|
||||
;; the bound input a (a-bound). The shift2 bit is fully determined
|
||||
;; by the classical Kaliski trace. We emit the 2nd shift only when
|
||||
;; the trace says shift2=1 — saves the entire shift2-log register +
|
||||
;; cswap cascade overhead vs the controlled-shift quantum form.
|
||||
;; Same shape of classical-specialization as the X-replay backward
|
||||
;; sweep at mod-inv-by-dialog-gcd-host.lsp:430-450.
|
||||
(when (and *dgcd-k2-bounded-shift* (= k2-shift2-bit 1))
|
||||
(shift-right-reg! c v-w-name uv-w))
|
||||
|
||||
;; ── STEP 7+8 ──
|
||||
;; Three-way dispatch for the apply-phase doubling of r:
|
||||
;;
|
||||
;; (a) FUSED 4r mod p — sweep-apply-fused-fold path, fires when
|
||||
;; *dgcd-apply-fused-fold* AND *dgcd-k2-bounded-shift* AND
|
||||
;; classical k2-shift2-bit=1. Single named primitive
|
||||
;; `mod-4x-inplace!` (mod-arith.lsp) — surfaces the dispatch
|
||||
;; for V2's true single-carry-chain fold.
|
||||
;;
|
||||
;; (b) UNFUSED K=2 2r-then-2r — sweep-k2-port path, fires when
|
||||
;; *dgcd-k2-bounded-shift* on without the fused-fold flag.
|
||||
;; Two sequential `mod-double-inplace!` when shift2-bit=1.
|
||||
;;
|
||||
;; (c) K=1 champion — single `mod-double-inplace!`.
|
||||
(cond
|
||||
((and *dgcd-apply-fused-fold*
|
||||
*dgcd-k2-bounded-shift*
|
||||
(= k2-shift2-bit 1))
|
||||
(mod-4x-inplace! c r-name n+1 p
|
||||
cin-name cin-idx tmp-name flag-name flag-idx))
|
||||
(*dgcd-k2-bounded-shift*
|
||||
;; First doubling is unconditional; second fires only on shift2=1.
|
||||
(mod-double-inplace! c r-name n+1 p
|
||||
cin-name cin-idx tmp-name flag-name flag-idx)
|
||||
(when (= k2-shift2-bit 1)
|
||||
;; K=2 second-double composition fix (sweep-blackops-trust-factory
|
||||
;; 2026-06-10): force cload+cuccaro-sub path for the second double
|
||||
;; by locally disabling *cadd-direct-trunc-fast*. Cdtf's classical-
|
||||
;; bit reuse (bit-base 400000) collides across the back-to-back
|
||||
;; double-double pattern under cumulative kaliski body state.
|
||||
;; Restore flag after the call so STEP 4 + later iters keep cdtf.
|
||||
(let ((saved-cdtf *cadd-direct-trunc-fast*))
|
||||
(set! *cadd-direct-trunc-fast* #f)
|
||||
(mod-double-inplace! c r-name n+1 p
|
||||
cin-name cin-idx tmp-name flag-name flag-idx)
|
||||
(set! *cadd-direct-trunc-fast* saved-cdtf))))
|
||||
(else
|
||||
(mod-double-inplace! c r-name n+1 p
|
||||
cin-name cin-idx tmp-name flag-name flag-idx)))
|
||||
|
||||
;; ── STEP 9 ──
|
||||
;; sweep-k2-port FIX: see STEP 3 note above. Under K=2 r holds the
|
||||
;; doubled-twice value (up to p-1, not 2^(iter+1)-1), so narrow
|
||||
;; iter+2 width drops bits. Widen to full n+1 when K=2 active.
|
||||
(cswap-reg! c a-f 0 u-name v-w-name uv-w)
|
||||
(cswap-reg! c a-f 0 r-name s-name
|
||||
(cond (*dgcd-k2-bounded-shift* n+1)
|
||||
(else (mib-rs-step9-width iter-idx n+1))))
|
||||
|
||||
;; ── STEP 10 ──
|
||||
(gate-x! c s-name 0)
|
||||
(gate-ccx! c f-name f-idx s-name 0 a-f 0)
|
||||
(gate-x! c s-name 0)
|
||||
|
||||
(free! c l-gt)
|
||||
(free! c add-f)
|
||||
(free! c b-f)
|
||||
(free! c a-f)))
|
||||
|
||||
(define (mod-inv-by-dialog-gcd-host! c a-reg out-reg n+1 p
|
||||
cin-reg cin-idx tmp-reg flag-reg flag-idx
|
||||
red-tmp-reg)
|
||||
"out := a^{-1} mod p via Kaliski under HEAD's DIALOG_GCD + HOST_GATED
|
||||
substrate. Same calling convention as mod-inv-by-refined!. Same
|
||||
classical-replay correctness guarantees as mod-inv-by-dialog-gcd!.
|
||||
|
||||
When *dgcd-host-gated* is off, this collapses to (a clone of)
|
||||
mod-inv-by-dialog-gcd! — kept structurally distinct so byte-identity
|
||||
probes can attribute Toffoli/Clifford deltas cleanly per knob."
|
||||
(let* ((n (- n+1 1))
|
||||
(iters (dgcd-resolve-iters n+1))
|
||||
(u 'kal-host-u)
|
||||
(v-w 'kal-host-v-w)
|
||||
(r 'kal-host-r)
|
||||
(s 'kal-host-s)
|
||||
(f 'kal-host-f)
|
||||
(m-hist 'kal-host-m-hist)
|
||||
(mc-tmp 'kal-host-mc-tmp)
|
||||
(mc-pow 'kal-host-mc-pow)
|
||||
(host-mask 'kal-host-shared-mask)
|
||||
(a-bound (find-classical-value c a-reg)))
|
||||
(cond
|
||||
((<= n 0)
|
||||
(error "mod-inv-by-dialog-gcd-host! requires n+1 > 1; got" n+1))
|
||||
;; *static-circuit-mode*: under K=1 (champion path), K-correction
|
||||
;; is `a`-independent — r_on_1 = K_inv = 2^{iters} mod p is a
|
||||
;; CONSTANT, k-correct = classical-mod-inv p r_on_1 is also
|
||||
;; constant. No quantum primitive in this branch reads a-bound.
|
||||
;; Under K=2 (*dgcd-k2-bounded-shift* #t), shift2 trace requires
|
||||
;; classical a — refuse to emit static-circuit + K=2 combo.
|
||||
((and (not a-bound)
|
||||
(not *static-circuit-mode*))
|
||||
(error "mod-inv-by-dialog-gcd-host! requires a-reg classical via"
|
||||
" bind-input!/bind-mirror!; not found:" a-reg))
|
||||
((and (not a-bound) *dgcd-k2-bounded-shift*)
|
||||
(error "mod-inv-by-dialog-gcd-host! *static-circuit-mode* + "
|
||||
"*dgcd-k2-bounded-shift* incompatible: shift2 trace requires"
|
||||
" classical a (lines 609, 669, 679)"))
|
||||
(else
|
||||
(let ((r-on-1 (classical-kaliski-r-final 1 p iters n+1)))
|
||||
(when (= (modulo r-on-1 p) 0)
|
||||
(error "mod-inv-by-dialog-gcd-host! *dgcd-active-iters* too low: "
|
||||
"r_on_1 mod p = 0 at iters=" iters
|
||||
" n+1=" n+1 " p=" p)))
|
||||
|
||||
;; sweep-091: compose K2-pair body architecture flags when master
|
||||
;; flag `*k2-body-architecture*` is `#t`. No-op when `#f`. Idempotent.
|
||||
(k2-body-architecture-compose!)
|
||||
|
||||
;; sweep-cuccaro-lane-vector: when `*dgcd-borrow-current-block*`
|
||||
;; is `#t`, flip `*cuccaro-use-borrowed-lane*` so the
|
||||
;; ctrl-cuccaro-*-hosted! dispatcher routes through the lane-vector
|
||||
;; primitive. Default thunk yields a one-lane vec covering
|
||||
;; tmp-borrow → byte-identical gate stream to single-source.
|
||||
;; No-op when flag is `#f`. Idempotent.
|
||||
(dgcd-borrow-current-block-compose!)
|
||||
|
||||
;; sweep-compressed-block-lifecycle: HEAD lever sentinel composer.
|
||||
;; Master flag `*dgcd-compressed-block-lifecycle*` default `#f`.
|
||||
;; The structural pattern (per-iter alloc/free of cb+rb) is
|
||||
;; already live at lines 498-506; the flag tags the lever for
|
||||
;; variant id + future apply-phase fused-fold consumers.
|
||||
(dgcd-compressed-block-lifecycle-compose!)
|
||||
|
||||
;; ── Allocate Kaliski state + long-lived host mask ──
|
||||
(alloc! c u n+1)
|
||||
(alloc! c v-w n+1)
|
||||
(alloc! c r n+1)
|
||||
(alloc! c s n+1)
|
||||
(alloc! c f 1)
|
||||
;; sweep-091: when *dgcd-dialog-log-2bit* is `#t`, m-hist widens
|
||||
;; iters -> 2*iters slots per HEAD `config.rs:306` (RAW_LOG_BITS).
|
||||
;; sweep-074 forward port — was empirically PASS at probe widths +
|
||||
;; bend `byte-identity-cpu-gpu true` at production width; cost is
|
||||
;; +44.8 % peak qubits because the wider register stays live across
|
||||
;; both forward sweep + classical-replay backward sweep.
|
||||
(alloc! c m-hist (cond
|
||||
(*dgcd-dialog-log-2bit*
|
||||
(dialog-log-2bit-width iters))
|
||||
(else iters)))
|
||||
(alloc! c host-mask n+1) ;; HOST_GATED: shared across all iters
|
||||
(when *cuccaro-use-fast*
|
||||
;; Cuccaro fast carries: shared across all ctrl-cuccaro-*-hosted!
|
||||
;; calls. Width n+1-1 because Cuccaro needs n-1 carry qubits for an
|
||||
;; n-bit add. Returned to |0> by HMR uncompute on every call so
|
||||
;; reuse is sound.
|
||||
(alloc! c 'kal-host-carries-shared (- n+1 1)))
|
||||
;; sweep-borrow-block-fire: shift cb/rb alloc BEFORE the iter loop
|
||||
;; (instead of per-iter inside the codec branch) so block-k's
|
||||
;; compressed cells are live + at |0> during STEP 4's cuccaro
|
||||
;; borrow. Per HEAD compressed.rs:304-324, compressed_log[block k]
|
||||
;; is provably |0> during block k's body — written only by
|
||||
;; compress_block AFTER each iter in block k. Our codec roundtrip
|
||||
;; restores cb/rb to |0> by construction (k2-pair-substrate.lsp:296-312),
|
||||
;; so cells stay |0> at every STEP 4 borrow. Override the lane
|
||||
;; thunk to append cb as a second borrow lane.
|
||||
(when (and *dgcd-borrow-current-block* *k2-pair-codec-wired*)
|
||||
(alloc! c 'kal-host-k2-cb-shared 5)
|
||||
(alloc! c 'kal-host-k2-rb-shared 6)
|
||||
(set! *cuccaro-lane-vector-thunk*
|
||||
(lambda (n tmp-name)
|
||||
(cond
|
||||
;; sweep-cb-substitute: shrink legacy tmp lane to
|
||||
;; (n-1-5); cb fills global carry indices [n-1-5, n-1).
|
||||
;; Cuccaro reads cb's 5 cells as the top carries
|
||||
;; instead of tmp's top 5. If the aggressive-fold pool
|
||||
;; harvests the unused tmp tail, this saves ~5q net
|
||||
;; (after paying the +90q shared-name shim cost).
|
||||
;; `max 0` guard handles small-width probes where
|
||||
;; (- n 1 5) would go negative.
|
||||
(*dgcd-borrow-cb-substitute*
|
||||
(let ((tmp-w (cond ((> (- n 1 5) 0) (- n 1 5))
|
||||
(else 0))))
|
||||
(list (list tmp-name 0 tmp-w)
|
||||
(list 'kal-host-k2-cb-shared 0 5))))
|
||||
;; APPEND default — tmp covers full n-1 carries; cb
|
||||
;; appended but never reached. Byte-identical to
|
||||
;; default-thunk path.
|
||||
(else
|
||||
(list (list tmp-name 0 (- n 1))
|
||||
(list 'kal-host-k2-cb-shared 0 5)))))))
|
||||
|
||||
;; ── Init ──
|
||||
(load-const! c u n+1 p)
|
||||
(cx-copy-reg! c a-reg v-w n+1)
|
||||
(gate-x! c s 0)
|
||||
(gate-x! c f 0)
|
||||
|
||||
;; ── Forward sweep ──
|
||||
;; sweep-091: when *k2-pair-codec-wired* is `#t`, fire the K2-pair
|
||||
;; codec roundtrip after each odd-indexed iter (closes pair
|
||||
;; (i-1, i)). Per HEAD compressed.rs:1130-1190 + 2018-2138 — alloc
|
||||
;; 5-qubit compressed_block + 6-qubit raw_block, run encoder +
|
||||
;; encoder-inverse, free both. Default `#f` (champion byte-identity).
|
||||
;;
|
||||
;; sweep-k2-port: pre-compute shift2-i-vec via classical trace
|
||||
;; when K=2 ON; consult per-iter bit inside kaliski iteration.
|
||||
(let* ((fwd-trace (cond (*dgcd-k2-bounded-shift*
|
||||
(classical-kaliski-trace a-bound p iters n+1))
|
||||
(else #f)))
|
||||
(fwd-shift2 (cond (fwd-trace
|
||||
(car (cdr (cdr (cdr (cdr (cdr (cdr fwd-trace))))))))
|
||||
(else #f))))
|
||||
(let loop ((i 0))
|
||||
(when (< i iters)
|
||||
(kaliski-iteration-dgcd-host! c i n+1 p
|
||||
u v-w r s
|
||||
f 0
|
||||
m-hist i
|
||||
cin-reg cin-idx
|
||||
tmp-reg flag-reg flag-idx
|
||||
red-tmp-reg
|
||||
host-mask
|
||||
(cond (fwd-shift2
|
||||
(vector-ref fwd-shift2 i))
|
||||
(else 0)))
|
||||
;; sweep-k5-port Phase 2: per-iter block-roundtrip dispatch.
|
||||
;;
|
||||
;; Cadence is dynamic via `dgcd-sidecar-group-size`:
|
||||
;; K=5 (sidecar group=5): fire on (modulo i 5) == 4 — last
|
||||
;; iter of every 5-step block. ceil(iters/5) roundtrips.
|
||||
;; K=2 (sidecar group=2): fire on (modulo i 2) == 1 — every
|
||||
;; iter pair, matches pre-port byte-identity.
|
||||
;;
|
||||
;; K=5 path uses `*dgcd-k5-clean-block*` gate + alloc widths
|
||||
;; from `dgcd-block-bits` (12) / `dgcd-raw-block-len` (15) +
|
||||
;; dispatches via `dgcd-block-roundtrip!` (lifecycle wrapper
|
||||
;; in compressed-block-lifecycle.lsp branches K5 -> K=5 codec,
|
||||
;; else K=2 pair).
|
||||
;;
|
||||
;; Default `#f` on `*dgcd-k5-clean-block*` makes the K5
|
||||
;; predicate `#f`; K=2-only path matches pre-port exactly
|
||||
;; (same `*k2-pair-codec-wired*` gate, same `(= (modulo i 2) 1)`
|
||||
;; guard, same alloc widths 5/6, same direct call to
|
||||
;; `dialog-gcd-k2-pair-block-roundtrip!`).
|
||||
(let* ((k5-on? *dgcd-k5-clean-block*)
|
||||
(k5-fire? (and k5-on?
|
||||
(= (modulo i (dgcd-sidecar-group-size))
|
||||
(- (dgcd-sidecar-group-size) 1))))
|
||||
(k2-fire? (and (not k5-on?)
|
||||
*k2-pair-codec-wired*
|
||||
(= (modulo i 2) 1))))
|
||||
(cond
|
||||
;; ── K=5 clean-block branch ──
|
||||
;; Per-block alloc/free of compressed_block (12q) +
|
||||
;; raw_block (15q). dgcd-block-roundtrip! dispatches to
|
||||
;; the K5 codec (compress -> decompress, restores both
|
||||
;; to |0> by construction). No shared-name borrow path
|
||||
;; in this initial port — Phase 3 lands the borrow
|
||||
;; flavour for K5 alongside the apply-side scratch flags.
|
||||
(k5-fire?
|
||||
(let ((cb (string->symbol
|
||||
(string-append "dgcd-k5-cb-iter-"
|
||||
(number->string i))))
|
||||
(rb (string->symbol
|
||||
(string-append "dgcd-k5-rb-iter-"
|
||||
(number->string i)))))
|
||||
(alloc! c cb (dgcd-block-bits)) ; 12 under K5
|
||||
(alloc! c rb (dgcd-raw-block-len)) ; 15 under K5
|
||||
(dgcd-block-roundtrip! c cb rb)
|
||||
(free! c rb)
|
||||
(free! c cb)))
|
||||
;; ── K=2 pair branch (pre-port path, byte-identical) ──
|
||||
;; sweep-borrow-current-block: when `*dgcd-borrow-current-block*`
|
||||
;; is `#t`, cb/rb register names share across all iter pairs
|
||||
;; (per `dgcd-borrow-current-block-cb-name` / -rb-name).
|
||||
;; Mirrors HEAD's "fold current block's compressed cells
|
||||
;; into composite scratch" pattern at the register-name
|
||||
;; layer. Default `#f` keeps iter-suffixed names + byte
|
||||
;; identity with champion.
|
||||
;;
|
||||
;; sweep-borrow-block-fire: when `*dgcd-borrow-current-block*`
|
||||
;; is `#t`, the shared cb/rb are alloc'd ONCE before the
|
||||
;; iter loop (above) so STEP 4 can borrow from cb. The
|
||||
;; roundtrip restores cb/rb to |0> per call. We must NOT
|
||||
;; alloc/free them per-iter when the borrow flag is on.
|
||||
(k2-fire?
|
||||
(let ((cb (dgcd-borrow-current-block-cb-name i))
|
||||
(rb (dgcd-borrow-current-block-rb-name i)))
|
||||
(cond
|
||||
(*dgcd-borrow-current-block*
|
||||
;; cb/rb already alloc'd at outer scope (shared names).
|
||||
(dialog-gcd-k2-pair-block-roundtrip! c cb rb))
|
||||
(else
|
||||
(alloc! c cb 5)
|
||||
(alloc! c rb 6)
|
||||
(dialog-gcd-k2-pair-block-roundtrip! c cb rb)
|
||||
(free! c rb)
|
||||
(free! c cb)))))))
|
||||
(loop (+ i 1)))))
|
||||
|
||||
;; ── K-correction at capped iter count ──
|
||||
;;
|
||||
;; Under K=1 (champion): r_classical(a) = K_inv * a^{-1} where
|
||||
;; K_inv = 2^{iters} mod p (uniform across all `a`). K-correct =
|
||||
;; r_on_1 gives an `a`-independent correction.
|
||||
;;
|
||||
;; Under K=2: the number of doublings depends on the trace for
|
||||
;; THIS input a (shift2 sum). So r_classical(a) = K_inv(a) * a^{-1}
|
||||
;; where K_inv depends on a. K-correct must use r-on-a directly,
|
||||
;; not r-on-1. We compute K_inv(a) = r_classical(a) * a mod p
|
||||
;; (classical inverse of (a^{-1} mod p) gives the multiplier).
|
||||
(let* ((r-on-a-bound
|
||||
(cond (*dgcd-k2-bounded-shift*
|
||||
(classical-kaliski-r-final a-bound p iters n+1))
|
||||
(else
|
||||
(classical-kaliski-r-final 1 p iters n+1))))
|
||||
(k-correct
|
||||
(cond (*dgcd-k2-bounded-shift*
|
||||
(classical-mod-inv p (modulo (* r-on-a-bound a-bound) p)))
|
||||
(else
|
||||
(classical-mod-inv p r-on-a-bound))))
|
||||
(k-inverse
|
||||
(cond (*dgcd-k2-bounded-shift*
|
||||
(modulo (* r-on-a-bound a-bound) p))
|
||||
(else
|
||||
r-on-a-bound))))
|
||||
;; K=2 + cdtf root-cause fix (sweep-blackops-trust-factory
|
||||
;; 2026-06-10): K-correction's in-place-mul-const! drives
|
||||
;; mod-double-inplace! in a tight loop across bits of
|
||||
;; k-classical. Under K=2, k-correct = (r_a * a)^-1 has a
|
||||
;; bit pattern that triggers a cdtf composition defect K=0's
|
||||
;; pattern doesn't. Force cload+cuccaro path here ONLY under
|
||||
;; K=2 so K=0 champion bytes-identity is preserved (cdtf saves
|
||||
;; substantial Toffolis at production width when it composes
|
||||
;; correctly). The deeper bug — cdtf composition under K=2's
|
||||
;; k-correct bit patterns — remains open as a separate sweep
|
||||
;; (would let K=2 reclaim those Toffolis here).
|
||||
;; sweep-quotient-terminal-reuse-v2: Pre-compute classical trace
|
||||
;; BEFORE K-correction so we can use it to zero u/v_w/s/m_hist/f
|
||||
;; (with classical-reset!) before the free!s. This prevents the
|
||||
;; allocator pool from handing K-correction's scratch (mc-tmp etc)
|
||||
;; stale (non-|0>) qubits via aggressive-fold reuse — the bug that
|
||||
;; v1 hit (24/35 structured PASS while 2000/2000 random PASS).
|
||||
(let* ((trace (classical-kaliski-trace a-bound p iters n+1))
|
||||
(final-u (car trace))
|
||||
(final-v-w (car (cdr trace)))
|
||||
(final-r (car (cdr (cdr trace))))
|
||||
(final-s (car (cdr (cdr (cdr trace)))))
|
||||
(final-f (car (cdr (cdr (cdr (cdr trace))))))
|
||||
(m-i-vec (car (cdr (cdr (cdr (cdr (cdr trace)))))))
|
||||
(force-no-cdtf? *dgcd-k2-bounded-shift*)
|
||||
(saved-cdtf *cadd-direct-trunc-fast*)
|
||||
;; sweep-blackops-trust-factory 2026-06-11 follow-on:
|
||||
;; Root cause of K=2+cdtf wrong-output under in-place-mul-const!
|
||||
;; is cdtf's carry-trunc window=8 too narrow for the lazy carry
|
||||
;; state that accumulates across many mod-add!/mod-double-inplace!
|
||||
;; calls. Bisect found min-safe window=12 at n+1=20. K=2's
|
||||
;; k-correct = (r_a * a)^-1 has bit pattern that triggers
|
||||
;; ~4-bit lazy carry accumulation; K=0's k-correct = pow(r_1, -1, p)
|
||||
;; does not. Fix: under K=2, widen *cadd-direct-window* to safely
|
||||
;; cover the accumulation; falls back to full cdtf-disable as
|
||||
;; insurance if widening fails. Keep K=0 path untouched (window
|
||||
;; stays at default 8) so night17 bytes-identity preserved.
|
||||
(saved-window *cadd-direct-window*))
|
||||
(cond (force-no-cdtf?
|
||||
;; Default flipped 24 → 0 (2026-06-11) — kcorrw0 verified
|
||||
;; champion 1.7061e+10 (35/35 + 2000/2000 PASS at
|
||||
;; production width) proves K-correction needs zero
|
||||
;; carry-trunc budget under K=2. cdtf carry-trunc in
|
||||
;; K-correction code path is unnecessary; the original
|
||||
;; "widen window from 8 to 24" fix could have been
|
||||
;; "disable cdtf in K-correction" with same algorithmic
|
||||
;; correctness. Saves ~0.7% Toffolis across every K=2
|
||||
;; cell. *kcorr-cdtf-window-override* still works for
|
||||
;; cells that need window>0 (e.g., probe sweeps).
|
||||
(set! *cadd-direct-window*
|
||||
(or *kcorr-cdtf-window-override* 0))))
|
||||
;; Zero u/v_w/s/m_hist/f to |0> BEFORE free (so pool reuse
|
||||
;; gives clean qubits). classical-reset! XOR's the kaliski-final
|
||||
;; bit pattern, returning the register to |0>.
|
||||
;;
|
||||
;; sweep blackops 2026-06-11 — route via named primitive when
|
||||
;; *dgcd-kcorr-route-via-primitive* is #t. Today runway=#f so
|
||||
;; dgcd-release-terminal-vec! emits the exact same free!
|
||||
;; sequence as the raw block. Sets up runway-aware partial
|
||||
;; release (compressed.rs:256 HEAD primitive) for future
|
||||
;; sweep-runway-layout work to land without re-touching this
|
||||
;; callsite. Default #f preserves kcorrw0 champion bytes.
|
||||
(when *dgcd-raw-quotient-terminal-reuse*
|
||||
(classical-reset! c u n+1 final-u)
|
||||
(classical-reset! c v-w n+1 final-v-w)
|
||||
(classical-reset! c s n+1 final-s)
|
||||
(when (= final-f 1) (gate-x! c f 0))
|
||||
(let loop ((i 0))
|
||||
(when (< i iters)
|
||||
(when (= (vector-ref m-i-vec i) 1)
|
||||
(gate-x! c m-hist i))
|
||||
(loop (+ i 1))))
|
||||
(cond
|
||||
(*dgcd-kcorr-route-via-primitive*
|
||||
(dgcd-release-terminal-vec!
|
||||
c
|
||||
(list (cons 'm-hist (cond
|
||||
(*dgcd-dialog-log-2bit*
|
||||
(dialog-log-2bit-width iters))
|
||||
(else iters)))
|
||||
(cons 's n+1)
|
||||
(cons 'v-w n+1)
|
||||
(cons 'u n+1)
|
||||
(cons 'f 1))
|
||||
#f))
|
||||
(else
|
||||
(free! c m-hist)
|
||||
(free! c s)
|
||||
(free! c v-w)
|
||||
(free! c u)
|
||||
(free! c f))))
|
||||
(in-place-mul-const! c r n+1 p k-correct
|
||||
cin-reg cin-idx tmp-reg flag-reg flag-idx
|
||||
red-tmp-reg
|
||||
mc-tmp mc-pow)
|
||||
(cx-copy-reg! c r out-reg n+1)
|
||||
(in-place-mul-const! c r n+1 p k-inverse
|
||||
cin-reg cin-idx tmp-reg flag-reg flag-idx
|
||||
red-tmp-reg
|
||||
mc-tmp mc-pow)
|
||||
;; Restore window to whatever caller had (default 8).
|
||||
(when force-no-cdtf? (set! *cadd-direct-window* saved-window))
|
||||
;; Reacquire registers + set them BACK to trace state for the
|
||||
;; backward sweep (classical-replay zeros r, leaves others at trace).
|
||||
;;
|
||||
;; sweep blackops 2026-06-11 — symmetric to release block above.
|
||||
;; Route via primitive when flag set (today byte-identical to
|
||||
;; raw alloc! sequence under runway=#f).
|
||||
(when *dgcd-raw-quotient-terminal-reuse*
|
||||
(cond
|
||||
(*dgcd-kcorr-route-via-primitive*
|
||||
(dgcd-reacquire-terminal-vec!
|
||||
c
|
||||
(list (cons 'f 1)
|
||||
(cons 'u n+1)
|
||||
(cons 'v-w n+1)
|
||||
(cons 's n+1)
|
||||
(cons 'm-hist (cond
|
||||
(*dgcd-dialog-log-2bit*
|
||||
(dialog-log-2bit-width iters))
|
||||
(else iters))))
|
||||
#f))
|
||||
(else
|
||||
(alloc! c f 1)
|
||||
(alloc! c u n+1)
|
||||
(alloc! c v-w n+1)
|
||||
(alloc! c s n+1)
|
||||
(alloc! c m-hist (cond
|
||||
(*dgcd-dialog-log-2bit*
|
||||
(dialog-log-2bit-width iters))
|
||||
(else iters))))))
|
||||
;; v3 fix: after re-alloc, u/v_w/s/m_hist/f are |0> (fresh).
|
||||
;; DON'T re-establish trace state — original backward sweep
|
||||
;; was XOR'ing trace state OUT (zeroing them). Since they're
|
||||
;; already at |0>, the XOR-zero step is a no-op for them.
|
||||
;; Only r needs zeroing (it kept its forward-sweep state).
|
||||
(when force-no-cdtf? (set! *cadd-direct-trunc-fast* saved-cdtf))
|
||||
|
||||
;; ── Classical-replay backward sweep ── (zeros r, others stay)
|
||||
(classical-reset! c r n+1 final-r)
|
||||
(cond
|
||||
(*dgcd-raw-quotient-terminal-reuse* #t)
|
||||
(else
|
||||
(classical-reset! c u n+1 final-u)
|
||||
(classical-reset! c v-w n+1 final-v-w)
|
||||
(classical-reset! c s n+1 final-s)
|
||||
(when (= final-f 1) (gate-x! c f 0))
|
||||
(let loop ((i 0))
|
||||
(when (< i iters)
|
||||
(when (= (vector-ref m-i-vec i) 1)
|
||||
(gate-x! c m-hist i))
|
||||
(loop (+ i 1))))))))
|
||||
|
||||
;; ── Free Kaliski state ──
|
||||
;; sweep-borrow-block-fire: free shared cb/rb in reverse alloc order.
|
||||
;; Restore lane thunk to default so cross-call state stays clean.
|
||||
(when (and *dgcd-borrow-current-block* *k2-pair-codec-wired*)
|
||||
(free! c 'kal-host-k2-rb-shared)
|
||||
(free! c 'kal-host-k2-cb-shared)
|
||||
(set! *cuccaro-lane-vector-thunk* cuccaro-lane-default-thunk))
|
||||
(when *cuccaro-use-fast*
|
||||
(free! c 'kal-host-carries-shared))
|
||||
(free! c host-mask)
|
||||
(free! c m-hist)
|
||||
(free! c f)
|
||||
(free! c s)
|
||||
(free! c r)
|
||||
(free! c v-w)
|
||||
(free! c u)))))
|
||||
1714
quantum/mod-inv-by-dialog-gcd.lsp
Normal file
1714
quantum/mod-inv-by-dialog-gcd.lsp
Normal file
File diff suppressed because it is too large
Load diff
1382
quantum/mod-inv-by.lsp
Normal file
1382
quantum/mod-inv-by.lsp
Normal file
File diff suppressed because it is too large
Load diff
838
quantum/mod-karatsuba.lsp
Normal file
838
quantum/mod-karatsuba.lsp
Normal file
|
|
@ -0,0 +1,838 @@
|
|||
;;; mod-karatsuba.lsp — Phase B step 8: Karatsuba reversible wide-multiply.
|
||||
;;;
|
||||
;;; Replaces the wide-product stage of mod-mul-solinas! (the Litinski
|
||||
;;; schoolbook-mul-into-addsub!) with a Karatsuba recursion. Below the
|
||||
;;; threshold *karatsuba-threshold*, falls through to the existing
|
||||
;;; schoolbook primitive — so at p=11 (n=4) and p=251 (n=8) the karatsuba
|
||||
;;; path produces byte-identical output to mod-mul-solinas!. The win
|
||||
;;; shows up at n+1 >= 18.
|
||||
;;;
|
||||
;;; Algorithm (single-level expansion):
|
||||
;;;
|
||||
;;; karatsuba-wide!(a, b, dst, n):
|
||||
;;; if n <= THRESHOLD: schoolbook-wide!(a, b, dst, n) ; fallback
|
||||
;;; else:
|
||||
;;; m_lo = ceil(n/2), m_hi = n - m_lo ; m_hi <= m_lo
|
||||
;;; a_lo = a[0..m_lo), a_hi = a[m_lo..n)
|
||||
;;; b_lo = b[0..m_lo), b_hi = b[m_lo..n)
|
||||
;;;
|
||||
;;; ;; Compute three sub-products into fresh 2*m wide accumulators.
|
||||
;;; alloc z0 (2*m_lo), z2 (2*m_lo), sum_a (m_lo+1), sum_b (m_lo+1),
|
||||
;;; z1 (2*(m_lo+1))
|
||||
;;; karatsuba-wide!(a_lo, b_lo, z0, m_lo) ; z0 = a_lo*b_lo
|
||||
;;; karatsuba-wide!(a_hi, b_hi, z2-low(2*m_hi),m_hi) ; z2 high bits |0>
|
||||
;;; sum_a = a_lo (copy) + a_hi (add)
|
||||
;;; sum_b = b_lo (copy) + b_hi (add)
|
||||
;;; karatsuba-wide!(sum_a, sum_b, z1, m_lo+1)
|
||||
;;;
|
||||
;;; ;; Combine into dst.
|
||||
;;; ;; dst += z0 at offset 0 (2*m_lo bits)
|
||||
;;; ;; dst += z2 at offset 2*m_lo (2*m_hi bits)
|
||||
;;; ;; dst += z1 at offset m_lo (2*(m_lo+1) bits)
|
||||
;;; ;; dst -= z0 at offset m_lo (2*m_lo bits)
|
||||
;;; ;; dst -= z2 at offset m_lo (2*m_hi bits)
|
||||
;;;
|
||||
;;; ;; Uncompute every sub-product & sum register.
|
||||
;;; karatsuba-wide!-INVERSE(sum_a, sum_b, z1, m_lo+1)
|
||||
;;; uncompute sum_b (CX b_hi out, CX b_lo out — reverse order)
|
||||
;;; uncompute sum_a
|
||||
;;; karatsuba-wide!-INVERSE(a_hi, b_hi, z2-low, m_hi)
|
||||
;;; karatsuba-wide!-INVERSE(a_lo, b_lo, z0, m_lo)
|
||||
;;; free buffers
|
||||
;;;
|
||||
;;; Op-count scaling: at the leaves we still pay schoolbook (n_leaf^2
|
||||
;;; controlled-add-subtracts). With k = log_2(n/THRESHOLD) levels we get
|
||||
;;; 3^k leaf calls instead of 4^k. At THRESHOLD=8, n=32 -> k=2 -> 9 leaves
|
||||
;;; vs 16 schoolbook (44% Toffoli reduction in the multiply stage); n=128
|
||||
;;; -> k=4 -> 81 leaves vs 256 schoolbook (68% reduction).
|
||||
;;;
|
||||
;;; Ancilla overhead: each recursion allocates 4 width-2m_lo buffers + 2
|
||||
;;; width-(m_lo+1) buffers = ~6n+4 ancilla qubits per level. Total
|
||||
;;; cumulative ~6n*log2(n) ancilla — pays for itself well above n=16.
|
||||
;;;
|
||||
;;; Cross-tier portal validation: the schoolbook fallback below threshold
|
||||
;;; means at p=11 / p=251 mod-mul-karatsuba! emits THE SAME gate sequence
|
||||
;;; as mod-mul-solinas! — sweep-001/002 oracles still match.
|
||||
|
||||
(load "quantum/gates.lsp")
|
||||
(load "quantum/adder.lsp")
|
||||
(load "quantum/mod-arith.lsp")
|
||||
(load "quantum/mod-solinas.lsp")
|
||||
|
||||
;;; ── threshold knob (caller may set! before building) ───────────
|
||||
|
||||
(define *karatsuba-threshold* 8)
|
||||
;; n <= *karatsuba-threshold* falls through to schoolbook. Default 8
|
||||
;; chosen so n+1 in {5, 9} (the sweep-001/002 widths) skip Karatsuba
|
||||
;; entirely → byte-identity preserved by construction.
|
||||
|
||||
;;; ── ancilla-name generator ────────────────────────────────────
|
||||
;;;
|
||||
;;; Recursion needs fresh ancilla names per level. We append the depth
|
||||
;;; suffix to each base name; depth threads through every call.
|
||||
|
||||
(define (kara-name base depth)
|
||||
(string->symbol (string-append (symbol->string base) "-d"
|
||||
(number->string depth))))
|
||||
|
||||
(define (kara-name-w base depth width)
|
||||
"Like kara-name but ALSO tags the width. Used by primitives whose
|
||||
ancilla width varies between sibling calls at the same depth — the
|
||||
simulator's widths-hash retains the LAST-set width per name, so
|
||||
re-allocating the same name at different widths within one circuit
|
||||
lifetime crashes the reverse-pass register re-creation. Width-tagging
|
||||
sidesteps the collision."
|
||||
(string->symbol (string-append (symbol->string base) "-d"
|
||||
(number->string depth) "-w"
|
||||
(number->string width))))
|
||||
|
||||
;;; ── public entry: karatsuba-wide! ─────────────────────────────
|
||||
;;;
|
||||
;;; dst (2n bits |0> in) receives a*b. a and b are n-wide registers
|
||||
;;; (passed by name + offset for slicing). Caller allocates dst, a, b,
|
||||
;;; and the schoolbook-shared scratch (sb-low, sb-xext, sb-const-tmp,
|
||||
;;; cin). All ancilla return to |0>.
|
||||
|
||||
(define (karatsuba-wide! c a-reg a-off b-reg b-off n
|
||||
dst-reg dst-off
|
||||
sb-low-reg sb-low-idx sb-xext-reg
|
||||
cin-reg cin-idx sb-const-tmp-reg
|
||||
depth)
|
||||
"Reversible wide multiply: dst[dst-off..dst-off+2n) := a*b. a, b
|
||||
preserved; dst |0> in. Falls through to kara-leaf-mul! below threshold
|
||||
or when n <= 3 (recursion-floor guard: at n=3, m_sum=3=n so the sum
|
||||
sub-multiply would re-enter karatsuba-step at the same n → infinite
|
||||
recursion). Threshold default 8 keeps us well above this guard."
|
||||
(cond
|
||||
((= n 0) #t)
|
||||
((or (<= n *karatsuba-threshold*) (<= n 3))
|
||||
(kara-leaf-mul! c a-reg a-off b-reg b-off n
|
||||
dst-reg dst-off
|
||||
cin-reg cin-idx
|
||||
depth))
|
||||
(else
|
||||
(karatsuba-step! c a-reg a-off b-reg b-off n
|
||||
dst-reg dst-off
|
||||
sb-low-reg sb-low-idx sb-xext-reg
|
||||
cin-reg cin-idx sb-const-tmp-reg
|
||||
depth))))
|
||||
|
||||
(define (karatsuba-wide-inverse! c a-reg a-off b-reg b-off n
|
||||
dst-reg dst-off
|
||||
sb-low-reg sb-low-idx sb-xext-reg
|
||||
cin-reg cin-idx sb-const-tmp-reg
|
||||
depth)
|
||||
"Inverse: zero-out dst that currently holds a*b, restoring |0>."
|
||||
(cond
|
||||
((= n 0) #t)
|
||||
((or (<= n *karatsuba-threshold*) (<= n 3))
|
||||
(kara-leaf-mul-inverse! c a-reg a-off b-reg b-off n
|
||||
dst-reg dst-off
|
||||
cin-reg cin-idx
|
||||
depth))
|
||||
(else
|
||||
(karatsuba-step-inverse! c a-reg a-off b-reg b-off n
|
||||
dst-reg dst-off
|
||||
sb-low-reg sb-low-idx sb-xext-reg
|
||||
cin-reg cin-idx sb-const-tmp-reg
|
||||
depth))))
|
||||
|
||||
;;; ── leaf multiplier: kara-leaf-mul! ──
|
||||
;;;
|
||||
;;; Reversible "schoolbook" multiplier with unique-per-depth ancilla
|
||||
;;; names. Why not use the existing Litinski schoolbook (mod-arith.lsp
|
||||
;;; schoolbook-mul-into-addsub!)?
|
||||
;;;
|
||||
;;; The Litinski primitive allocates `sb-xfull` internally with width
|
||||
;;; 2n+1 = function of leaf n. Different Karatsuba leaves run schoolbook
|
||||
;;; at different n values within the SAME circuit lifetime (e.g. m_lo
|
||||
;;; sub-mult at n=m_lo, sum_a*sum_b sub-mult at n=m_lo+1). When the
|
||||
;;; simulator's reverse pass replays alloc ops, it uses the widths-hash
|
||||
;;; value at that point, but the widths hash retains the LAST forward
|
||||
;;; set value — so for an op-pair (alloc sb-xfull w5, ..., free sb-xfull)
|
||||
;;; followed later by (alloc sb-xfull w7, ..., free), the reverse pass
|
||||
;;; re-creates the w5 register at width 7, mis-sizing the bit vector
|
||||
;;; and crashing on the next vector-ref.
|
||||
;;;
|
||||
;;; Cleanest workaround: leaf multiplier has its OWN ancilla names that
|
||||
;;; are depth-tagged, so the width per name stays consistent.
|
||||
;;;
|
||||
;;; Algorithm (O(n²) controlled-add):
|
||||
;;; for i in 0..n:
|
||||
;;; for k in 0..n: tmp[k] ^= b[b-off+i] AND a[a-off+k] (CCX)
|
||||
;;; dst[dst-off+i..dst-off+i+n) += tmp (cuccaro-add-offset width=n)
|
||||
;;; uncompute tmp (same CCX self-inverse)
|
||||
;;;
|
||||
;;; Reversible, dst |0> in / dst = a*b out. tmp is depth-tagged.
|
||||
;;; Width-cost analysis: tmp width n.
|
||||
|
||||
(define (kara-leaf-mul! c a-reg a-off b-reg b-off n
|
||||
dst-reg dst-off
|
||||
cin-reg cin-idx
|
||||
depth)
|
||||
"dst[dst-off..dst-off+2n) += a*b. a, b each n wide (preserved).
|
||||
dst-reg must be >= dst-off + 2n wide. Uses kara-leaf-tmp-d<depth>
|
||||
ancilla of width n+1 (top bit |0> carry-extension to propagate the
|
||||
add's carry-out into dst[dst-off+i+n)).
|
||||
|
||||
Algorithm:
|
||||
for i in 0..n:
|
||||
for k in 0..n: tmp[k] ^= b[i] AND a[k] (CCX)
|
||||
dst[dst-off+i..dst-off+i+n+1) += tmp[0..n+1) (cuccaro-add width n+1)
|
||||
uncompute tmp
|
||||
|
||||
The width-n+1 add propagates the carry from bit (i+n-1) into bit
|
||||
(i+n) cleanly. tmp[n] stays |0> across each row (just the carry slot)."
|
||||
(let ((tmpn (kara-name-w 'kara-leaf-tmp depth n)))
|
||||
(alloc! c tmpn (+ n 1))
|
||||
(let row-loop ((i 0))
|
||||
(when (< i n)
|
||||
;; tmp ^= b[i] AND a[k]
|
||||
(let loop ((k 0))
|
||||
(when (< k n)
|
||||
(gate-ccx! c b-reg (+ b-off i) a-reg (+ a-off k) tmpn k)
|
||||
(loop (+ k 1))))
|
||||
;; dst[dst-off+i..+i+n+1) += tmp[0..n+1) — width n+1 lets carry propagate
|
||||
(cuccaro-add-offset! c tmpn 0 dst-reg (+ dst-off i)
|
||||
cin-reg cin-idx (+ n 1))
|
||||
;; uncompute tmp
|
||||
(let loop ((k 0))
|
||||
(when (< k n)
|
||||
(gate-ccx! c b-reg (+ b-off i) a-reg (+ a-off k) tmpn k)
|
||||
(loop (+ k 1))))
|
||||
(row-loop (+ i 1))))
|
||||
(free! c tmpn)))
|
||||
|
||||
(define (kara-leaf-mul-inverse! c a-reg a-off b-reg b-off n
|
||||
dst-reg dst-off
|
||||
cin-reg cin-idx
|
||||
depth)
|
||||
"Inverse: dst[dst-off..dst-off+2n) -= a*b. Same convention as
|
||||
kara-leaf-mul! with cuccaro-add replaced by cuccaro-sub. Walks rows
|
||||
in REVERSE so the inverse mirrors the forward operation pair-by-pair."
|
||||
(let ((tmpn (kara-name-w 'kara-leaf-tmp depth n)))
|
||||
(alloc! c tmpn (+ n 1))
|
||||
(let row-loop ((i (- n 1)))
|
||||
(when (>= i 0)
|
||||
(let loop ((k 0))
|
||||
(when (< k n)
|
||||
(gate-ccx! c b-reg (+ b-off i) a-reg (+ a-off k) tmpn k)
|
||||
(loop (+ k 1))))
|
||||
(cuccaro-sub-offset! c tmpn 0 dst-reg (+ dst-off i)
|
||||
cin-reg cin-idx (+ n 1))
|
||||
(let loop ((k 0))
|
||||
(when (< k n)
|
||||
(gate-ccx! c b-reg (+ b-off i) a-reg (+ a-off k) tmpn k)
|
||||
(loop (+ k 1))))
|
||||
(row-loop (- i 1))))
|
||||
(free! c tmpn)))
|
||||
|
||||
;;; ── karatsuba-step! — one recursion level ─────────────────────
|
||||
;;;
|
||||
;;; Splits a, b at m_lo := ceil(n/2). m_hi := n - m_lo (m_hi <= m_lo).
|
||||
;;; Allocates buffers, recurses three times, combines into dst, uncomputes.
|
||||
|
||||
(define (karatsuba-step! c a-reg a-off b-reg b-off n
|
||||
dst-reg dst-off
|
||||
sb-low-reg sb-low-idx sb-xext-reg
|
||||
cin-reg cin-idx sb-const-tmp-reg
|
||||
depth)
|
||||
(let* ((m-lo (quotient (+ n 1) 2)) ; ceil(n/2)
|
||||
(m-hi (- n m-lo)) ; floor(n/2) <= m-lo
|
||||
(m-sum (+ m-lo 1)) ; sum register width
|
||||
(z0-w (* 2 m-lo))
|
||||
;; z2 has true width 2*m_hi but we allocate 2*m_lo for uniform
|
||||
;; offset arithmetic — high (2*m_lo - 2*m_hi) bits stay |0>.
|
||||
(z2-w (* 2 m-hi))
|
||||
(z1-w (* 2 m-sum))
|
||||
;; Width-tag every ancilla name. Sibling recursions at the same
|
||||
;; depth but different n (e.g. m_lo vs m_sum sub-mults) all hit
|
||||
;; this same step!; without width tags they'd collide on the
|
||||
;; simulator widths-hash.
|
||||
(z0n (kara-name-w 'kara-z0 depth n))
|
||||
(z2n (kara-name-w 'kara-z2 depth n))
|
||||
(san (kara-name-w 'kara-sa depth n))
|
||||
(sbn (kara-name-w 'kara-sb depth n))
|
||||
(z1n (kara-name-w 'kara-z1 depth n))
|
||||
(next-depth (+ depth 1)))
|
||||
(alloc! c z0n z0-w)
|
||||
(alloc! c z2n z2-w)
|
||||
(alloc! c san m-sum)
|
||||
(alloc! c sbn m-sum)
|
||||
(alloc! c z1n z1-w)
|
||||
;; ── compute z0 := a_lo * b_lo (m_lo bits each → 2*m_lo product) ──
|
||||
(karatsuba-wide! c a-reg a-off b-reg b-off m-lo
|
||||
z0n 0
|
||||
sb-low-reg sb-low-idx sb-xext-reg
|
||||
cin-reg cin-idx sb-const-tmp-reg
|
||||
next-depth)
|
||||
;; ── compute z2 := a_hi * b_hi (m_hi bits each → 2*m_hi product) ──
|
||||
(karatsuba-wide! c a-reg (+ a-off m-lo) b-reg (+ b-off m-lo) m-hi
|
||||
z2n 0
|
||||
sb-low-reg sb-low-idx sb-xext-reg
|
||||
cin-reg cin-idx sb-const-tmp-reg
|
||||
next-depth)
|
||||
;; ── sum_a := a_lo + a_hi via a width-m_sum cuccaro-add ──
|
||||
;;
|
||||
;; Naive approach (cuccaro-add-offset width=m_hi of a_hi into san)
|
||||
;; TRUNCATES the carry into san[m_lo] = san[m_sum-1]. Instead, pad
|
||||
;; a_hi into a fresh m_sum-wide register (high bits |0>), copy a_lo
|
||||
;; into san low m_lo bits, then do a full width-m_sum cuccaro-add.
|
||||
;; Carry out cleanly into san[m_sum-1].
|
||||
(let ((ahpn (kara-name-w 'kara-ahp depth n))
|
||||
(bhpn (kara-name-w 'kara-bhp depth n)))
|
||||
(alloc! c ahpn m-sum)
|
||||
(alloc! c bhpn m-sum)
|
||||
;; sum_a = a_lo (copy)
|
||||
(let loop ((k 0))
|
||||
(when (< k m-lo)
|
||||
(gate-cx! c a-reg (+ a-off k) san k)
|
||||
(loop (+ k 1))))
|
||||
;; pad a_hi into ahpn low m_hi bits
|
||||
(let loop ((k 0))
|
||||
(when (< k m-hi)
|
||||
(gate-cx! c a-reg (+ a-off m-lo k) ahpn k)
|
||||
(loop (+ k 1))))
|
||||
;; sum_a += a_hi_padded (width m_sum, carries cleanly)
|
||||
(cuccaro-add! c ahpn san cin-reg cin-idx m-sum)
|
||||
;; sum_b = b_lo (copy)
|
||||
(let loop ((k 0))
|
||||
(when (< k m-lo)
|
||||
(gate-cx! c b-reg (+ b-off k) sbn k)
|
||||
(loop (+ k 1))))
|
||||
(let loop ((k 0))
|
||||
(when (< k m-hi)
|
||||
(gate-cx! c b-reg (+ b-off m-lo k) bhpn k)
|
||||
(loop (+ k 1))))
|
||||
(cuccaro-add! c bhpn sbn cin-reg cin-idx m-sum)
|
||||
;; uncompute padded copies (CX self-inverse)
|
||||
(let loop ((k 0))
|
||||
(when (< k m-hi)
|
||||
(gate-cx! c b-reg (+ b-off m-lo k) bhpn k)
|
||||
(loop (+ k 1))))
|
||||
(let loop ((k 0))
|
||||
(when (< k m-hi)
|
||||
(gate-cx! c a-reg (+ a-off m-lo k) ahpn k)
|
||||
(loop (+ k 1))))
|
||||
(free! c bhpn)
|
||||
(free! c ahpn))
|
||||
;; ── z1 := sum_a * sum_b ──
|
||||
(karatsuba-wide! c san 0 sbn 0 m-sum
|
||||
z1n 0
|
||||
sb-low-reg sb-low-idx sb-xext-reg
|
||||
cin-reg cin-idx sb-const-tmp-reg
|
||||
next-depth)
|
||||
;; ── combine into dst ──
|
||||
;; Karatsuba: a*b = z0 + (z1 - z0 - z2)*2^m_lo + z2*2^(2*m_lo)
|
||||
;;
|
||||
;; Naive direct adds/subs into dst at NARROW widths LOSE BORROW into
|
||||
;; higher dst bits — subtracting z0 (width 2*m_lo) at offset m_lo
|
||||
;; truncates inside the slice [m_lo .. m_lo+2*m_lo) instead of letting
|
||||
;; the borrow propagate up. Standard fix: compute the middle term in
|
||||
;; a SEPARATE accumulator (here z1n itself) at full width 2*m_sum,
|
||||
;; where 2*m_sum >= 2*m_lo >= 2*m_hi → borrows stay inside z1n. Then
|
||||
;; add z1n into dst at offset m_lo.
|
||||
;;
|
||||
;; Sequence (z1n holds z1 = sum_a * sum_b on entry):
|
||||
;; 1. z1n -= z0 (sub at width 2*m_sum — z0 pads with zero high bits)
|
||||
;; 2. z1n -= z2
|
||||
;; 3. dst[0..2*m_lo) += z0
|
||||
;; 4. dst[2*m_lo..2*m_lo+2*m_hi) += z2
|
||||
;; 5. dst[m_lo..m_lo+2*m_sum) += z1n (the middle term in full)
|
||||
;; 6. z1n += z2 (restore z1n = z1 for the inverse step)
|
||||
;; 7. z1n += z0
|
||||
;;
|
||||
;; Steps 1-2 and 6-7 use the SAME width-2*m_sum sub via padded copies
|
||||
;; (z0 and z2 are narrower; we copy into a 2*m_sum-wide temp).
|
||||
|
||||
(let ((z0extn (kara-name-w 'kara-z0ext depth n))
|
||||
(z2extn (kara-name-w 'kara-z2ext depth n)))
|
||||
(alloc! c z0extn z1-w)
|
||||
(alloc! c z2extn z1-w)
|
||||
;; copy z0 into z0extn (low z0-w bits)
|
||||
(let loop ((k 0))
|
||||
(when (< k z0-w)
|
||||
(gate-cx! c z0n k z0extn k)
|
||||
(loop (+ k 1))))
|
||||
;; copy z2 into z2extn (low z2-w bits)
|
||||
(let loop ((k 0))
|
||||
(when (< k z2-w)
|
||||
(gate-cx! c z2n k z2extn k)
|
||||
(loop (+ k 1))))
|
||||
;; z1n -= z0extn (full width 2*m_sum)
|
||||
(cuccaro-sub! c z0extn z1n cin-reg cin-idx z1-w)
|
||||
;; z1n -= z2extn
|
||||
(cuccaro-sub! c z2extn z1n cin-reg cin-idx z1-w)
|
||||
;; dst += z0 at offset 0, width 2*m_lo
|
||||
(cuccaro-add-offset! c z0n 0 dst-reg dst-off
|
||||
cin-reg cin-idx z0-w)
|
||||
;; dst += z2 at offset 2*m_lo, width 2*m_hi
|
||||
(cuccaro-add-offset! c z2n 0 dst-reg (+ dst-off (* 2 m-lo))
|
||||
cin-reg cin-idx z2-w)
|
||||
;; dst += z1n at offset m_lo. The middle term value = z1 - z0 - z2 =
|
||||
;; a_lo*b_hi + a_hi*b_lo < 2^(n+1). So a width-(n+1) add captures
|
||||
;; the full value; the extra bits of z1n (width 2*m_sum) up to bit
|
||||
;; 2*m_sum-1 are guaranteed |0> and don't need gate emission. Using
|
||||
;; the narrower width also keeps the write inside the dst's 2*n
|
||||
;; slice (avoids writes past dst-reg[dst-off + 2n - 1]).
|
||||
(cuccaro-add-offset! c z1n 0 dst-reg (+ dst-off m-lo)
|
||||
cin-reg cin-idx (+ n 1))
|
||||
;; restore z1n = z1 by adding z2extn then z0extn back
|
||||
(cuccaro-add! c z2extn z1n cin-reg cin-idx z1-w)
|
||||
(cuccaro-add! c z0extn z1n cin-reg cin-idx z1-w)
|
||||
;; uncopy z2extn, z0extn
|
||||
(let loop ((k 0))
|
||||
(when (< k z2-w)
|
||||
(gate-cx! c z2n k z2extn k)
|
||||
(loop (+ k 1))))
|
||||
(let loop ((k 0))
|
||||
(when (< k z0-w)
|
||||
(gate-cx! c z0n k z0extn k)
|
||||
(loop (+ k 1))))
|
||||
(free! c z2extn)
|
||||
(free! c z0extn))
|
||||
;; ── uncompute z1, sum_b, sum_a, z2, z0 ──
|
||||
(karatsuba-wide-inverse! c san 0 sbn 0 m-sum
|
||||
z1n 0
|
||||
sb-low-reg sb-low-idx sb-xext-reg
|
||||
cin-reg cin-idx sb-const-tmp-reg
|
||||
next-depth)
|
||||
;; uncompute sum_a, sum_b — mirror the padded build with sub
|
||||
(let ((ahpn (kara-name-w 'kara-ahp depth n))
|
||||
(bhpn (kara-name-w 'kara-bhp depth n)))
|
||||
(alloc! c ahpn m-sum)
|
||||
(alloc! c bhpn m-sum)
|
||||
;; reapply the pads
|
||||
(let loop ((k 0))
|
||||
(when (< k m-hi)
|
||||
(gate-cx! c a-reg (+ a-off m-lo k) ahpn k)
|
||||
(loop (+ k 1))))
|
||||
(let loop ((k 0))
|
||||
(when (< k m-hi)
|
||||
(gate-cx! c b-reg (+ b-off m-lo k) bhpn k)
|
||||
(loop (+ k 1))))
|
||||
;; sub padded operands (inverse of add)
|
||||
(cuccaro-sub! c bhpn sbn cin-reg cin-idx m-sum)
|
||||
(cuccaro-sub! c ahpn san cin-reg cin-idx m-sum)
|
||||
;; uncopy a_lo from san, b_lo from sbn
|
||||
(let loop ((k 0))
|
||||
(when (< k m-lo)
|
||||
(gate-cx! c b-reg (+ b-off k) sbn k)
|
||||
(loop (+ k 1))))
|
||||
(let loop ((k 0))
|
||||
(when (< k m-lo)
|
||||
(gate-cx! c a-reg (+ a-off k) san k)
|
||||
(loop (+ k 1))))
|
||||
;; uncompute the pads
|
||||
(let loop ((k 0))
|
||||
(when (< k m-hi)
|
||||
(gate-cx! c b-reg (+ b-off m-lo k) bhpn k)
|
||||
(loop (+ k 1))))
|
||||
(let loop ((k 0))
|
||||
(when (< k m-hi)
|
||||
(gate-cx! c a-reg (+ a-off m-lo k) ahpn k)
|
||||
(loop (+ k 1))))
|
||||
(free! c bhpn)
|
||||
(free! c ahpn))
|
||||
(karatsuba-wide-inverse! c a-reg (+ a-off m-lo) b-reg (+ b-off m-lo) m-hi
|
||||
z2n 0
|
||||
sb-low-reg sb-low-idx sb-xext-reg
|
||||
cin-reg cin-idx sb-const-tmp-reg
|
||||
next-depth)
|
||||
(karatsuba-wide-inverse! c a-reg a-off b-reg b-off m-lo
|
||||
z0n 0
|
||||
sb-low-reg sb-low-idx sb-xext-reg
|
||||
cin-reg cin-idx sb-const-tmp-reg
|
||||
next-depth)
|
||||
(free! c z1n)
|
||||
(free! c sbn)
|
||||
(free! c san)
|
||||
(free! c z2n)
|
||||
(free! c z0n)))
|
||||
|
||||
(define (karatsuba-step-inverse! c a-reg a-off b-reg b-off n
|
||||
dst-reg dst-off
|
||||
sb-low-reg sb-low-idx sb-xext-reg
|
||||
cin-reg cin-idx sb-const-tmp-reg
|
||||
depth)
|
||||
"Inverse of karatsuba-step!: subtract a*b from dst, restoring |0>.
|
||||
Walks the forward gate-sequence in REVERSE order with each gate's
|
||||
inverse — the standard gate-level inverse pattern.
|
||||
|
||||
Why this works (and why naive anti-combine fails): the forward
|
||||
combine adds z0 → z2 → z1n_middle into dst at narrow widths.
|
||||
Each add carries cleanly because dst grows monotonically. The
|
||||
FORWARD sequence visits intermediate dst states:
|
||||
0 → z0 → z0 + z2*2^(2*m_lo) → z0 + z2*2^(2*m_lo) + z1m*2^m_lo
|
||||
The REVERSE-with-inversion runs subs in REVERSE order, walking
|
||||
dst back through the SAME intermediate states. Each sub mirrors a
|
||||
forward add → it cannot underflow because the corresponding slice
|
||||
already holds (the post-add value), and subtracting recovers the
|
||||
pre-add value cleanly.
|
||||
|
||||
In contrast, anti-combine subs in FORWARD order (z0 first) tries
|
||||
to subtract z0 from dst-low-slice that holds (a*b mod 2^(2*m_lo))
|
||||
which is NOT z0 — so the slice may underflow, dropping a borrow."
|
||||
(let* ((m-lo (quotient (+ n 1) 2))
|
||||
(m-hi (- n m-lo))
|
||||
(m-sum (+ m-lo 1))
|
||||
(z0-w (* 2 m-lo))
|
||||
(z2-w (* 2 m-hi))
|
||||
(z1-w (* 2 m-sum))
|
||||
(z0n (kara-name-w 'kara-z0 depth n))
|
||||
(z2n (kara-name-w 'kara-z2 depth n))
|
||||
(san (kara-name-w 'kara-sa depth n))
|
||||
(sbn (kara-name-w 'kara-sb depth n))
|
||||
(z1n (kara-name-w 'kara-z1 depth n))
|
||||
(next-depth (+ depth 1)))
|
||||
(alloc! c z0n z0-w)
|
||||
(alloc! c z2n z2-w)
|
||||
(alloc! c san m-sum)
|
||||
(alloc! c sbn m-sum)
|
||||
(alloc! c z1n z1-w)
|
||||
;; FORWARD: compute z0, z2, sums, z1 — same as kara-step! up to combine
|
||||
(karatsuba-wide! c a-reg a-off b-reg b-off m-lo
|
||||
z0n 0
|
||||
sb-low-reg sb-low-idx sb-xext-reg
|
||||
cin-reg cin-idx sb-const-tmp-reg
|
||||
next-depth)
|
||||
(karatsuba-wide! c a-reg (+ a-off m-lo) b-reg (+ b-off m-lo) m-hi
|
||||
z2n 0
|
||||
sb-low-reg sb-low-idx sb-xext-reg
|
||||
cin-reg cin-idx sb-const-tmp-reg
|
||||
next-depth)
|
||||
(let ((ahpn (kara-name-w 'kara-ahp depth n))
|
||||
(bhpn (kara-name-w 'kara-bhp depth n)))
|
||||
(alloc! c ahpn m-sum)
|
||||
(alloc! c bhpn m-sum)
|
||||
(let loop ((k 0))
|
||||
(when (< k m-lo)
|
||||
(gate-cx! c a-reg (+ a-off k) san k)
|
||||
(loop (+ k 1))))
|
||||
(let loop ((k 0))
|
||||
(when (< k m-hi)
|
||||
(gate-cx! c a-reg (+ a-off m-lo k) ahpn k)
|
||||
(loop (+ k 1))))
|
||||
(cuccaro-add! c ahpn san cin-reg cin-idx m-sum)
|
||||
(let loop ((k 0))
|
||||
(when (< k m-lo)
|
||||
(gate-cx! c b-reg (+ b-off k) sbn k)
|
||||
(loop (+ k 1))))
|
||||
(let loop ((k 0))
|
||||
(when (< k m-hi)
|
||||
(gate-cx! c b-reg (+ b-off m-lo k) bhpn k)
|
||||
(loop (+ k 1))))
|
||||
(cuccaro-add! c bhpn sbn cin-reg cin-idx m-sum)
|
||||
(let loop ((k 0))
|
||||
(when (< k m-hi)
|
||||
(gate-cx! c b-reg (+ b-off m-lo k) bhpn k)
|
||||
(loop (+ k 1))))
|
||||
(let loop ((k 0))
|
||||
(when (< k m-hi)
|
||||
(gate-cx! c a-reg (+ a-off m-lo k) ahpn k)
|
||||
(loop (+ k 1))))
|
||||
(free! c bhpn)
|
||||
(free! c ahpn))
|
||||
(karatsuba-wide! c san 0 sbn 0 m-sum
|
||||
z1n 0
|
||||
sb-low-reg sb-low-idx sb-xext-reg
|
||||
cin-reg cin-idx sb-const-tmp-reg
|
||||
next-depth)
|
||||
;; ── REVERSE-ORDER ANTI-COMBINE — walk forward-combine gates in
|
||||
;; reverse with each gate inverted. dst transit through forward
|
||||
;; intermediate states in reverse, so no underflow occurs at any
|
||||
;; step.
|
||||
;;
|
||||
;; Forward combine order:
|
||||
;; alloc z0extn, z2extn
|
||||
;; copy z0 → z0extn, z2 → z2extn
|
||||
;; z1n -= z0extn, z1n -= z2extn
|
||||
;; dst += z0 (offset 0, width z0-w)
|
||||
;; dst += z2 (offset 2*m_lo, width z2-w)
|
||||
;; dst += z1n (offset m_lo, width n+1)
|
||||
;; z1n += z2extn, z1n += z0extn
|
||||
;; uncopy z2 ← z2extn, z0 ← z0extn
|
||||
;; free z0extn, z2extn
|
||||
;;
|
||||
;; Reverse-with-inversion:
|
||||
;; alloc z0extn, z2extn ← reverse of free
|
||||
;; copy z0 → z0extn, z2 → z2extn ← reverse of uncopy
|
||||
;; z1n -= z0extn, z1n -= z2extn ← reverse of += pair
|
||||
;; dst -= z1n (offset m_lo width n+1)
|
||||
;; dst -= z2 (offset 2*m_lo width z2-w)
|
||||
;; dst -= z0 (offset 0 width z0-w)
|
||||
;; z1n += z2extn, z1n += z0extn
|
||||
;; uncopy z2extn, z0extn
|
||||
;; free z0extn, z2extn
|
||||
(let ((z0extn (kara-name-w 'kara-z0ext depth n))
|
||||
(z2extn (kara-name-w 'kara-z2ext depth n)))
|
||||
(alloc! c z0extn z1-w)
|
||||
(alloc! c z2extn z1-w)
|
||||
(let loop ((k 0))
|
||||
(when (< k z0-w)
|
||||
(gate-cx! c z0n k z0extn k)
|
||||
(loop (+ k 1))))
|
||||
(let loop ((k 0))
|
||||
(when (< k z2-w)
|
||||
(gate-cx! c z2n k z2extn k)
|
||||
(loop (+ k 1))))
|
||||
(cuccaro-sub! c z0extn z1n cin-reg cin-idx z1-w)
|
||||
(cuccaro-sub! c z2extn z1n cin-reg cin-idx z1-w)
|
||||
;; ── REVERSED dst subs (z1n_middle first, then z2, then z0) ──
|
||||
(cuccaro-sub-offset! c z1n 0 dst-reg (+ dst-off m-lo)
|
||||
cin-reg cin-idx (+ n 1))
|
||||
(cuccaro-sub-offset! c z2n 0 dst-reg (+ dst-off (* 2 m-lo))
|
||||
cin-reg cin-idx z2-w)
|
||||
(cuccaro-sub-offset! c z0n 0 dst-reg dst-off
|
||||
cin-reg cin-idx z0-w)
|
||||
(cuccaro-add! c z2extn z1n cin-reg cin-idx z1-w)
|
||||
(cuccaro-add! c z0extn z1n cin-reg cin-idx z1-w)
|
||||
(let loop ((k 0))
|
||||
(when (< k z2-w)
|
||||
(gate-cx! c z2n k z2extn k)
|
||||
(loop (+ k 1))))
|
||||
(let loop ((k 0))
|
||||
(when (< k z0-w)
|
||||
(gate-cx! c z0n k z0extn k)
|
||||
(loop (+ k 1))))
|
||||
(free! c z2extn)
|
||||
(free! c z0extn))
|
||||
;; Uncompute z1, sums, z2, z0 — same as kara-step! tail.
|
||||
(karatsuba-wide-inverse! c san 0 sbn 0 m-sum
|
||||
z1n 0
|
||||
sb-low-reg sb-low-idx sb-xext-reg
|
||||
cin-reg cin-idx sb-const-tmp-reg
|
||||
next-depth)
|
||||
(let ((ahpn (kara-name-w 'kara-ahp depth n))
|
||||
(bhpn (kara-name-w 'kara-bhp depth n)))
|
||||
(alloc! c ahpn m-sum)
|
||||
(alloc! c bhpn m-sum)
|
||||
(let loop ((k 0))
|
||||
(when (< k m-hi)
|
||||
(gate-cx! c a-reg (+ a-off m-lo k) ahpn k)
|
||||
(loop (+ k 1))))
|
||||
(let loop ((k 0))
|
||||
(when (< k m-hi)
|
||||
(gate-cx! c b-reg (+ b-off m-lo k) bhpn k)
|
||||
(loop (+ k 1))))
|
||||
(cuccaro-sub! c bhpn sbn cin-reg cin-idx m-sum)
|
||||
(cuccaro-sub! c ahpn san cin-reg cin-idx m-sum)
|
||||
(let loop ((k 0))
|
||||
(when (< k m-lo)
|
||||
(gate-cx! c b-reg (+ b-off k) sbn k)
|
||||
(loop (+ k 1))))
|
||||
(let loop ((k 0))
|
||||
(when (< k m-lo)
|
||||
(gate-cx! c a-reg (+ a-off k) san k)
|
||||
(loop (+ k 1))))
|
||||
(let loop ((k 0))
|
||||
(when (< k m-hi)
|
||||
(gate-cx! c b-reg (+ b-off m-lo k) bhpn k)
|
||||
(loop (+ k 1))))
|
||||
(let loop ((k 0))
|
||||
(when (< k m-hi)
|
||||
(gate-cx! c a-reg (+ a-off m-lo k) ahpn k)
|
||||
(loop (+ k 1))))
|
||||
(free! c bhpn)
|
||||
(free! c ahpn))
|
||||
(karatsuba-wide-inverse! c a-reg (+ a-off m-lo) b-reg (+ b-off m-lo) m-hi
|
||||
z2n 0
|
||||
sb-low-reg sb-low-idx sb-xext-reg
|
||||
cin-reg cin-idx sb-const-tmp-reg
|
||||
next-depth)
|
||||
(karatsuba-wide-inverse! c a-reg a-off b-reg b-off m-lo
|
||||
z0n 0
|
||||
sb-low-reg sb-low-idx sb-xext-reg
|
||||
cin-reg cin-idx sb-const-tmp-reg
|
||||
next-depth)
|
||||
(free! c z1n)
|
||||
(free! c sbn)
|
||||
(free! c san)
|
||||
(free! c z2n)
|
||||
(free! c z0n)))
|
||||
|
||||
;;; ── mod-mul-karatsuba! — wrap Karatsuba wide-mul + Solinas reduce ─
|
||||
;;;
|
||||
;;; Same calling convention as mod-mul! and mod-mul-solinas!. When
|
||||
;;; n <= *karatsuba-threshold*, dispatches verbatim to mod-mul-solinas!
|
||||
;;; (byte-identical output). Otherwise runs Karatsuba for Stage 1, the
|
||||
;;; Solinas reducer for Stage 2, Karatsuba-inverse for Stage 3.
|
||||
|
||||
(define (mod-mul-karatsuba! c a-reg b-reg out-reg n+1 p c-expansion
|
||||
cin-reg cin-idx tmp-reg flag-reg flag-idx
|
||||
red-tmp-reg)
|
||||
"out := (a * b) mod p via Karatsuba wide-multiply + Solinas reduce.
|
||||
Below threshold, byte-identical to mod-mul-solinas!."
|
||||
(let ((n (- n+1 1)))
|
||||
(cond
|
||||
((= n 0) #t)
|
||||
((<= n *karatsuba-threshold*)
|
||||
;; Byte-identical schoolbook+Solinas path.
|
||||
(mod-mul-solinas! c a-reg b-reg out-reg n+1 p c-expansion
|
||||
cin-reg cin-idx tmp-reg flag-reg flag-idx
|
||||
red-tmp-reg))
|
||||
(else
|
||||
;; ── Stage 0: alloc wide-product buffer ──
|
||||
(alloc! c (quote sb-tmp-ext) (* 2 n))
|
||||
;; ── Stage 1: Karatsuba wide product sb-tmp-ext := a * b ──
|
||||
(karatsuba-wide! c a-reg 0 b-reg 0 n
|
||||
(quote sb-tmp-ext) 0
|
||||
#f #f #f
|
||||
cin-reg cin-idx #f
|
||||
0)
|
||||
;; ── Stage 2: Solinas reduce sb-tmp-ext mod p into out ──
|
||||
(alloc! c (quote sol-lo-ext) (+ n 1))
|
||||
(cx-copy-slice! c (quote sb-tmp-ext) 0 (quote sol-lo-ext) n)
|
||||
(mod-add! c (quote sol-lo-ext) out-reg n+1 p
|
||||
cin-reg cin-idx tmp-reg flag-reg flag-idx)
|
||||
(cx-copy-slice! c (quote sb-tmp-ext) 0 (quote sol-lo-ext) n)
|
||||
(free! c (quote sol-lo-ext))
|
||||
(alloc! c (quote sol-hi-ext) (+ n 1))
|
||||
(cx-copy-slice! c (quote sb-tmp-ext) n (quote sol-hi-ext) n)
|
||||
(let loop ((rest c-expansion) (current-shift 0))
|
||||
(cond
|
||||
((null? rest)
|
||||
(mod-halve-n! c (quote sol-hi-ext) n+1 p current-shift
|
||||
cin-reg cin-idx tmp-reg flag-reg flag-idx))
|
||||
(else
|
||||
(let* ((pair (car rest))
|
||||
(sign (car pair))
|
||||
(shift (cdr pair))
|
||||
(delta (- shift current-shift)))
|
||||
(when (< delta 0)
|
||||
(error "mod-mul-karatsuba: c-expansion must be ascending"
|
||||
(list current-shift shift)))
|
||||
(mod-double-n! c (quote sol-hi-ext) n+1 p delta
|
||||
cin-reg cin-idx tmp-reg flag-reg flag-idx)
|
||||
(cond
|
||||
((= sign 1)
|
||||
(mod-add! c (quote sol-hi-ext) out-reg n+1 p
|
||||
cin-reg cin-idx tmp-reg flag-reg flag-idx))
|
||||
((= sign -1)
|
||||
(mod-sub! c (quote sol-hi-ext) out-reg n+1 p
|
||||
cin-reg cin-idx tmp-reg flag-reg flag-idx))
|
||||
(else
|
||||
(error "mod-mul-karatsuba: sign must be ±1" sign)))
|
||||
(loop (cdr rest) shift)))))
|
||||
(cx-copy-slice! c (quote sb-tmp-ext) n (quote sol-hi-ext) n)
|
||||
(free! c (quote sol-hi-ext))
|
||||
;; ── Stage 3: uncompute Karatsuba wide product ──
|
||||
(karatsuba-wide-inverse! c a-reg 0 b-reg 0 n
|
||||
(quote sb-tmp-ext) 0
|
||||
#f #f #f
|
||||
cin-reg cin-idx #f
|
||||
0)
|
||||
(free! c (quote sb-tmp-ext))))))
|
||||
|
||||
;;; ── mod-mul-karatsuba-sub! — out -= a*b mod p via Karatsuba ─────
|
||||
|
||||
(define (mod-mul-karatsuba-sub! c a-reg b-reg out-reg n+1 p c-expansion
|
||||
cin-reg cin-idx tmp-reg flag-reg flag-idx
|
||||
red-tmp-reg)
|
||||
"out := (out - a * b) mod p. Below threshold, byte-identical to
|
||||
mod-mul-solinas-sub!."
|
||||
(let ((n (- n+1 1)))
|
||||
(cond
|
||||
((= n 0) #t)
|
||||
((<= n *karatsuba-threshold*)
|
||||
(mod-mul-solinas-sub! c a-reg b-reg out-reg n+1 p c-expansion
|
||||
cin-reg cin-idx tmp-reg flag-reg flag-idx
|
||||
red-tmp-reg))
|
||||
(else
|
||||
(alloc! c (quote sb-tmp-ext) (* 2 n))
|
||||
(karatsuba-wide! c a-reg 0 b-reg 0 n
|
||||
(quote sb-tmp-ext) 0
|
||||
#f #f #f
|
||||
cin-reg cin-idx #f
|
||||
0)
|
||||
;; Stage 2 with SIGN INVERTED (mod-sub instead of mod-add).
|
||||
(alloc! c (quote sol-lo-ext) (+ n 1))
|
||||
(cx-copy-slice! c (quote sb-tmp-ext) 0 (quote sol-lo-ext) n)
|
||||
(mod-sub! c (quote sol-lo-ext) out-reg n+1 p
|
||||
cin-reg cin-idx tmp-reg flag-reg flag-idx)
|
||||
(cx-copy-slice! c (quote sb-tmp-ext) 0 (quote sol-lo-ext) n)
|
||||
(free! c (quote sol-lo-ext))
|
||||
(alloc! c (quote sol-hi-ext) (+ n 1))
|
||||
(cx-copy-slice! c (quote sb-tmp-ext) n (quote sol-hi-ext) n)
|
||||
(let loop ((rest c-expansion) (current-shift 0))
|
||||
(cond
|
||||
((null? rest)
|
||||
(mod-halve-n! c (quote sol-hi-ext) n+1 p current-shift
|
||||
cin-reg cin-idx tmp-reg flag-reg flag-idx))
|
||||
(else
|
||||
(let* ((pair (car rest))
|
||||
(sign (car pair))
|
||||
(shift (cdr pair))
|
||||
(delta (- shift current-shift)))
|
||||
(when (< delta 0)
|
||||
(error "mod-mul-karatsuba-sub: c-expansion must be ascending"
|
||||
(list current-shift shift)))
|
||||
(mod-double-n! c (quote sol-hi-ext) n+1 p delta
|
||||
cin-reg cin-idx tmp-reg flag-reg flag-idx)
|
||||
(cond
|
||||
((= sign 1)
|
||||
(mod-sub! c (quote sol-hi-ext) out-reg n+1 p
|
||||
cin-reg cin-idx tmp-reg flag-reg flag-idx))
|
||||
((= sign -1)
|
||||
(mod-add! c (quote sol-hi-ext) out-reg n+1 p
|
||||
cin-reg cin-idx tmp-reg flag-reg flag-idx))
|
||||
(else
|
||||
(error "mod-mul-karatsuba-sub: sign must be ±1" sign)))
|
||||
(loop (cdr rest) shift)))))
|
||||
(cx-copy-slice! c (quote sb-tmp-ext) n (quote sol-hi-ext) n)
|
||||
(free! c (quote sol-hi-ext))
|
||||
(karatsuba-wide-inverse! c a-reg 0 b-reg 0 n
|
||||
(quote sb-tmp-ext) 0
|
||||
#f #f #f
|
||||
cin-reg cin-idx #f
|
||||
0)
|
||||
(free! c (quote sb-tmp-ext))))))
|
||||
|
||||
;;; ── dispatch flag ─────────────────────────────────────────────
|
||||
;;;
|
||||
;;; mod-arith.lsp's mod-mul! dispatcher routes to mod-mul-solinas! when
|
||||
;;; *mod-mul-use-solinas* is true. To slot Karatsuba in WITHOUT editing
|
||||
;;; mod-arith.lsp (byte-identity discipline), we REDEFINE mod-mul-solinas!
|
||||
;;; itself: when *mod-mul-use-karatsuba* is true, the redefined version
|
||||
;;; routes to mod-mul-karatsuba!; otherwise it calls the original
|
||||
;;; mod-mul-solinas-impl! (the original body, captured via name change).
|
||||
;;;
|
||||
;;; Effect: a caller flipping (*mod-mul-use-solinas* = #t,
|
||||
;;; *mod-mul-use-karatsuba* = #t) gets Karatsuba; flipping just
|
||||
;;; *mod-mul-use-solinas* = #t alone gets the original Solinas.
|
||||
|
||||
(define *mod-mul-use-karatsuba* #f)
|
||||
|
||||
;; Capture the original mod-mul-solinas! into mod-mul-solinas-impl!
|
||||
;; (alias). Only happens once at load time. Subsequent set! to
|
||||
;; mod-mul-solinas! redirects callers through our dispatcher.
|
||||
(define mod-mul-solinas-impl! mod-mul-solinas!)
|
||||
(define mod-mul-solinas-sub-impl! mod-mul-solinas-sub!)
|
||||
|
||||
(set! mod-mul-solinas!
|
||||
(lambda (c a-reg b-reg out-reg n+1 p c-expansion
|
||||
cin-reg cin-idx tmp-reg flag-reg flag-idx
|
||||
red-tmp-reg)
|
||||
(cond
|
||||
(*mod-mul-use-karatsuba*
|
||||
(mod-mul-karatsuba! c a-reg b-reg out-reg n+1 p c-expansion
|
||||
cin-reg cin-idx tmp-reg flag-reg flag-idx
|
||||
red-tmp-reg))
|
||||
(else
|
||||
(mod-mul-solinas-impl! c a-reg b-reg out-reg n+1 p c-expansion
|
||||
cin-reg cin-idx tmp-reg flag-reg flag-idx
|
||||
red-tmp-reg)))))
|
||||
|
||||
(set! mod-mul-solinas-sub!
|
||||
(lambda (c a-reg b-reg out-reg n+1 p c-expansion
|
||||
cin-reg cin-idx tmp-reg flag-reg flag-idx
|
||||
red-tmp-reg)
|
||||
(cond
|
||||
(*mod-mul-use-karatsuba*
|
||||
(mod-mul-karatsuba-sub! c a-reg b-reg out-reg n+1 p c-expansion
|
||||
cin-reg cin-idx tmp-reg flag-reg flag-idx
|
||||
red-tmp-reg))
|
||||
(else
|
||||
(mod-mul-solinas-sub-impl! c a-reg b-reg out-reg n+1 p c-expansion
|
||||
cin-reg cin-idx tmp-reg flag-reg flag-idx
|
||||
red-tmp-reg)))))
|
||||
595
quantum/mod-solinas.lsp
Normal file
595
quantum/mod-solinas.lsp
Normal file
|
|
@ -0,0 +1,595 @@
|
|||
;;; mod-solinas.lsp — Phase B step 7: Solinas-style fast reduction
|
||||
;;; for mod-mul, primes of form p = 2^n - c with sparse c.
|
||||
;;;
|
||||
;;; Cuts mod-mul Stage 2 from O(n²) (2n bit-loops × O(n) mod-add per bit)
|
||||
;;; to O(popcount(c) × n) — a small constant × n. For our test primes
|
||||
;;; c = 5 = 2² + 2⁰ → popcount=2 → 2 mod-adds + 2 mod-doubles ≈ 4 cuccaro
|
||||
;;; operations instead of 2n. Speedup at n=8 (p=251): ~16× fewer cuccaros.
|
||||
;;;
|
||||
;;; Mirrors upstream `mod_mul_write_into_zero_acc_schoolbook` at
|
||||
;;; ~/git/ecdsafail-challenge/src/point_add/mod.rs:4044 — the same pattern
|
||||
;;; of walking hi through doublings and folding ±-add into acc keyed on
|
||||
;;; the bits of c.
|
||||
;;;
|
||||
;;; Math:
|
||||
;;; x*y splits as [lo | hi] where lo = bits[0..n), hi = bits[n..2n).
|
||||
;;; Then x*y = hi*2^n + lo. Since p = 2^n - c, 2^n ≡ c (mod p).
|
||||
;;; So (x*y) mod p = (lo + c*hi) mod p.
|
||||
;;; With c = Σ sign·2^k_j, this becomes lo + Σ sign·(hi · 2^k_j) mod p.
|
||||
;;;
|
||||
;;; Calling convention (same shape as mod-mul!):
|
||||
;;; a-reg, b-reg, out-reg : (n+1)-wide; top bit |0>.
|
||||
;;; c-expansion : Scheme list of (sign . shift) pairs encoding
|
||||
;;; c = Σ sign · 2^shift. Caller computes once.
|
||||
;;; e.g. for p=11/p=251 (c=5):
|
||||
;;; '((1 . 2) (1 . 0))
|
||||
;;; Pairs must be sorted by ASCENDING shift
|
||||
;;; because we cumulatively double hi.
|
||||
;;; cin, tmp, flag, red-tmp : same scratch suite as mod-mul!.
|
||||
;;;
|
||||
;;; Internal ancillae (Litinski + Solinas):
|
||||
;;; sb-tmp-ext (2n bits) wide product accumulator (Stage 1 / 3)
|
||||
;;; sb-low (1 bit) wide[0] for Stage 1 / 3
|
||||
;;; sb-xext (n+1 bits) Stage 1 / 3 add-subtract scratch
|
||||
;;; sb-const-tmp (n+1 bits) Stage 1 / 3 constant-loading scratch
|
||||
;;; sol-lo-ext (n+1 bits) Stage-2 lo-as-extended-reg copy
|
||||
;;; sol-hi-ext (n+1 bits) Stage-2 hi-as-extended-reg copy (walks via doublings)
|
||||
|
||||
(load "quantum/gates.lsp")
|
||||
(load "quantum/adder.lsp")
|
||||
(load "quantum/mod-arith.lsp")
|
||||
|
||||
;;; ── from-zero specialization lever ────────────────────────────────
|
||||
;;;
|
||||
;;; *mod-mul-from-zero-first-add* — when #t, mod-mul-solinas-from-zero!
|
||||
;;; (& its callers) replace Stage 2a's first mod-add! with
|
||||
;;; mod-add-from-zero!. Saves n CCX per fresh-multiply call. Caller
|
||||
;;; opts in by invoking mod-mul-from-zero! (mod-arith.lsp) instead of
|
||||
;;; mod-mul!. Default OFF so opt-out is trivial.
|
||||
;;;
|
||||
;;; Port of HEAD's `mod_add_qq_fast_from_zero` specialization
|
||||
;;; (mod.rs:961-1038) lifted to the Solinas Stage 2 entry point.
|
||||
|
||||
(define *mod-mul-from-zero-first-add* #f)
|
||||
|
||||
;;; ── helper: apply n consecutive doublings to a register ──
|
||||
|
||||
(define (mod-double-n! c v-reg n+1 p k
|
||||
cin-reg cin-idx tmp-reg flag-reg flag-idx)
|
||||
"Apply mod-double-inplace! k times. v := v * 2^k mod p. k >= 0."
|
||||
(let loop ((i 0))
|
||||
(when (< i k)
|
||||
(mod-double-inplace! c v-reg n+1 p
|
||||
cin-reg cin-idx tmp-reg flag-reg flag-idx)
|
||||
(loop (+ i 1)))))
|
||||
|
||||
(define (mod-halve-n! c v-reg n+1 p k
|
||||
cin-reg cin-idx tmp-reg flag-reg flag-idx)
|
||||
"Apply mod-halve-inplace! k times. v := v * 2^(-k) mod p."
|
||||
(let loop ((i 0))
|
||||
(when (< i k)
|
||||
(mod-halve-inplace! c v-reg n+1 p
|
||||
cin-reg cin-idx tmp-reg flag-reg flag-idx)
|
||||
(loop (+ i 1)))))
|
||||
|
||||
;;; ── helper: CX-copy a slice of one register into the low bits of another ──
|
||||
|
||||
(define (cx-copy-slice! c src-reg src-off tgt-reg width)
|
||||
"tgt[k] ^= src[src-off + k] for k in [0, width). Self-inverse."
|
||||
(let loop ((k 0))
|
||||
(when (< k width)
|
||||
(gate-cx! c src-reg (+ src-off k) tgt-reg k)
|
||||
(loop (+ k 1)))))
|
||||
|
||||
;;; ── mod-mul-solinas! — Litinski schoolbook + Solinas Stage-2 reduce ──
|
||||
|
||||
(define (mod-mul-solinas! c a-reg b-reg out-reg n+1 p c-expansion
|
||||
cin-reg cin-idx tmp-reg flag-reg flag-idx
|
||||
red-tmp-reg)
|
||||
"out := (a * b) mod p using Solinas fast reduction.
|
||||
c-expansion encodes c = 2^n - p as ((sign . shift) ...) sorted by
|
||||
ASCENDING shift. See file header for full calling convention.
|
||||
red-tmp-reg unused here (kept for signature parity with mod-mul!);
|
||||
may be passed as any (n+1)-wide ancilla |0> in/out (caller still
|
||||
allocs to keep the call site uniform with mod-mul!)."
|
||||
(let ((n (- n+1 1)))
|
||||
(cond
|
||||
((= n 0) #t)
|
||||
(else
|
||||
;; ── Stage 0: alloc Litinski scratch ──
|
||||
;;
|
||||
;; sweep-036: when *sub-x-from-wide-host-alloc* is on, hoist
|
||||
;; sb-xfull alloc to OUTERMOST Stage-0 step so it lands at the
|
||||
;; lowest free 513-wide base inside the mod-mul scope. Width
|
||||
;; matches sub-x-from-wide!'s internal compute:
|
||||
;; xfull-width = 2n+1 by default
|
||||
;; = 4n+1 when *cuccaro-callers-fast* AND
|
||||
;; *sub-x-from-wide-cas-fast* both on.
|
||||
;; *host-sb-xfull-reg* communicates the name to sub-x-from-wide!
|
||||
;; & add-x-into-wide! (mod-arith.lsp) without changing their
|
||||
;; signatures.
|
||||
(when *sub-x-from-wide-host-alloc*
|
||||
(let* ((use-fast? (and *cuccaro-callers-fast*
|
||||
*sub-x-from-wide-cas-fast*))
|
||||
(joint-width (+ (* 2 n) 1))
|
||||
(xfull-width (cond (use-fast? (+ joint-width
|
||||
(- joint-width 1)))
|
||||
(else joint-width))))
|
||||
(alloc! c (quote sb-xfull-host) xfull-width)
|
||||
(set! *host-sb-xfull-reg* (quote sb-xfull-host))))
|
||||
(alloc! c (quote sb-tmp-ext) (* 2 n))
|
||||
(alloc! c (quote sb-low) 1)
|
||||
(alloc! c (quote sb-xext) (+ n 1))
|
||||
(alloc! c (quote sb-const-tmp) (+ n 1))
|
||||
;; Optional CAS-borrowed carries register for HMR dispatch in
|
||||
;; cuccaro-add-offset! (mod-arith.lsp). Activated by setting
|
||||
;; *cuccaro-callers-fast* #t at emit driver level. Borrows from
|
||||
;; red-tmp-reg (caller-passed, unused per Solinas signature
|
||||
;; convention — see file header). ZERO qubit cost.
|
||||
(when *cuccaro-callers-fast*
|
||||
(set! *cas-borrowed-carries-reg* red-tmp-reg)
|
||||
(set! *cas-borrowed-carries-offset* 0)
|
||||
(set! *cas-borrowed-bit-base* 200000)
|
||||
;; red-tmp-reg is (n+1) wide; fast-borrowed dispatch consumes
|
||||
;; (width-1) carries lanes. Set width=n+1 so joint dispatch
|
||||
;; falls through for sub-x-from-wide (width 2n+1, needs 2n
|
||||
;; slots) — only schoolbook-row-k0 (width n+1, needs n slots)
|
||||
;; activates. Offset variants (width n+1) always fit.
|
||||
(set! *cas-borrowed-carries-width* (+ n 1)))
|
||||
;; ── Stage 1: compute wide product sb-tmp-ext := a * b ──
|
||||
(schoolbook-mul-into-addsub! c a-reg b-reg n
|
||||
(quote sb-low) 0
|
||||
(quote sb-tmp-ext)
|
||||
(quote sb-xext)
|
||||
cin-reg cin-idx
|
||||
(quote sb-const-tmp))
|
||||
;; ── Stage 2: reduce via Solinas walk ──
|
||||
;; 2a. out += lo mod p
|
||||
;; 2b. for each (sign . shift) in c-expansion (ascending):
|
||||
;; advance hi by delta-shift doublings; out += sign * hi mod p
|
||||
;; 2c. walk hi all the way back via halvings to clear the copy.
|
||||
;;
|
||||
;; alg-11 mirror — classical Stage-2 tracker. When both a-reg and
|
||||
;; b-reg carry classical values (find-classical-value via
|
||||
;; bind-input!/rebind-mirror! channel), derive the classical
|
||||
;; product & track lo / hi / out across the Stage-2 walk so the
|
||||
;; in-line mod-add!/mod-sub!/mod-double-n! dispatchers can
|
||||
;; route pseudo-Mersenne on safe inputs.
|
||||
(let* ((a-cl (find-classical-value c a-reg))
|
||||
(b-cl (find-classical-value c b-reg))
|
||||
(out-cl (find-classical-value c out-reg))
|
||||
(track? (and a-cl b-cl out-cl))
|
||||
(prod (and track? (* a-cl b-cl)))
|
||||
(lo-cl (and track? (modulo prod (expt 2 n))))
|
||||
(hi-cl (and track? (quotient prod (expt 2 n)))))
|
||||
;; sol-lo-ext (n+1) holds a CX-copy of sb-tmp-ext[0..n) in low n
|
||||
;; bits; top bit stays |0>.
|
||||
(alloc! c (quote sol-lo-ext) (+ n 1))
|
||||
(cx-copy-slice! c (quote sb-tmp-ext) 0 (quote sol-lo-ext) n)
|
||||
(when track?
|
||||
(rebind-mirror! c (quote sol-lo-ext) lo-cl))
|
||||
(mod-add! c (quote sol-lo-ext) out-reg n+1 p
|
||||
cin-reg cin-idx tmp-reg flag-reg flag-idx)
|
||||
(when track?
|
||||
(rebind-mirror! c out-reg (modulo (+ out-cl lo-cl) p)))
|
||||
(cx-copy-slice! c (quote sb-tmp-ext) 0 (quote sol-lo-ext) n)
|
||||
(free! c (quote sol-lo-ext))
|
||||
;; sol-hi-ext: copy of sb-tmp-ext[n..2n) in low n bits.
|
||||
(alloc! c (quote sol-hi-ext) (+ n 1))
|
||||
(cx-copy-slice! c (quote sb-tmp-ext) n (quote sol-hi-ext) n)
|
||||
(when track?
|
||||
(rebind-mirror! c (quote sol-hi-ext) hi-cl))
|
||||
;; Walk hi via mod-doublings. Track cumulative shift via current-shift.
|
||||
(let loop ((rest c-expansion)
|
||||
(current-shift 0)
|
||||
(cur-hi (and track? hi-cl))
|
||||
(cur-out (and track? (modulo (+ out-cl lo-cl) p))))
|
||||
(cond
|
||||
((null? rest)
|
||||
;; Walk hi back to its original value via halvings.
|
||||
(mod-halve-n! c (quote sol-hi-ext) n+1 p current-shift
|
||||
cin-reg cin-idx tmp-reg flag-reg flag-idx)
|
||||
(when track?
|
||||
(rebind-mirror! c (quote sol-hi-ext) hi-cl)))
|
||||
(else
|
||||
(let* ((pair (car rest))
|
||||
(sign (car pair))
|
||||
(shift (cdr pair))
|
||||
(delta (- shift current-shift)))
|
||||
(when (< delta 0)
|
||||
(error "mod-mul-solinas: c-expansion must be ascending"
|
||||
(list current-shift shift)))
|
||||
(mod-double-n! c (quote sol-hi-ext) n+1 p delta
|
||||
cin-reg cin-idx tmp-reg flag-reg flag-idx)
|
||||
(let* ((hi-after (and track?
|
||||
(modulo (* cur-hi (expt 2 delta)) p)))
|
||||
(next-out (cond
|
||||
((not track?) #f)
|
||||
((= sign 1)
|
||||
(modulo (+ cur-out hi-after) p))
|
||||
(else
|
||||
(modulo (- cur-out hi-after) p)))))
|
||||
(when track?
|
||||
(rebind-mirror! c (quote sol-hi-ext) hi-after))
|
||||
(cond
|
||||
((= sign 1)
|
||||
(mod-add! c (quote sol-hi-ext) out-reg n+1 p
|
||||
cin-reg cin-idx tmp-reg flag-reg flag-idx))
|
||||
((= sign -1)
|
||||
(mod-sub! c (quote sol-hi-ext) out-reg n+1 p
|
||||
cin-reg cin-idx tmp-reg flag-reg flag-idx))
|
||||
(else
|
||||
(error "mod-mul-solinas: sign must be ±1" sign)))
|
||||
(when track?
|
||||
(rebind-mirror! c out-reg next-out))
|
||||
(loop (cdr rest) shift hi-after next-out)))))))
|
||||
;; Uncopy hi from sb-tmp-ext.
|
||||
(cx-copy-slice! c (quote sb-tmp-ext) n (quote sol-hi-ext) n)
|
||||
(free! c (quote sol-hi-ext))
|
||||
;; ── Stage 3: uncompute sb-tmp-ext back to |0> ──
|
||||
(schoolbook-mul-into-addsub-inverse! c a-reg b-reg n
|
||||
(quote sb-low) 0
|
||||
(quote sb-tmp-ext)
|
||||
(quote sb-xext)
|
||||
cin-reg cin-idx
|
||||
(quote sb-const-tmp))
|
||||
;; ── Stage 4: free Litinski scratch ──
|
||||
(when *cuccaro-callers-fast*
|
||||
(set! *cas-borrowed-carries-reg* #f))
|
||||
(free! c (quote sb-const-tmp))
|
||||
(free! c (quote sb-xext))
|
||||
(free! c (quote sb-low))
|
||||
(free! c (quote sb-tmp-ext))
|
||||
;; sweep-036: free host-allocated sb-xfull (if hoisted).
|
||||
(when *sub-x-from-wide-host-alloc*
|
||||
(set! *host-sb-xfull-reg* #f)
|
||||
(free! c (quote sb-xfull-host)))))))
|
||||
|
||||
;;; ── mod-mul-solinas-sub! — out := out - (a*b) mod p via Solinas ──
|
||||
;;;
|
||||
;;; Mirror of mod-mul-sub! using Solinas Stage 2. Needed so we can swap
|
||||
;;; in mod-mul-solinas! through the Roetteler point-add (which calls
|
||||
;;; mod-mul-sub! at steps 5, 10, 12c, 12d).
|
||||
|
||||
(define (mod-mul-solinas-sub! c a-reg b-reg out-reg n+1 p c-expansion
|
||||
cin-reg cin-idx tmp-reg flag-reg flag-idx
|
||||
red-tmp-reg)
|
||||
"out := (out - a*b) mod p via Solinas. Mirror of mod-mul-solinas! with
|
||||
sign-inverted Stage-2 mod-add/mod-sub."
|
||||
(let ((n (- n+1 1)))
|
||||
(cond
|
||||
((= n 0) #t)
|
||||
(else
|
||||
;; sweep-036: hoist sb-xfull alloc when opt-in flag is set.
|
||||
(when *sub-x-from-wide-host-alloc*
|
||||
(let* ((use-fast? (and *cuccaro-callers-fast*
|
||||
*sub-x-from-wide-cas-fast*))
|
||||
(joint-width (+ (* 2 n) 1))
|
||||
(xfull-width (cond (use-fast? (+ joint-width
|
||||
(- joint-width 1)))
|
||||
(else joint-width))))
|
||||
(alloc! c (quote sb-xfull-host) xfull-width)
|
||||
(set! *host-sb-xfull-reg* (quote sb-xfull-host))))
|
||||
(alloc! c (quote sb-tmp-ext) (* 2 n))
|
||||
(alloc! c (quote sb-low) 1)
|
||||
(alloc! c (quote sb-xext) (+ n 1))
|
||||
(alloc! c (quote sb-const-tmp) (+ n 1))
|
||||
(when *cuccaro-callers-fast*
|
||||
(set! *cas-borrowed-carries-reg* red-tmp-reg)
|
||||
(set! *cas-borrowed-carries-offset* 0)
|
||||
(set! *cas-borrowed-bit-base* 200000)
|
||||
;; red-tmp-reg is (n+1) wide; fast-borrowed dispatch consumes
|
||||
;; (width-1) carries lanes. Set width=n+1 so joint dispatch
|
||||
;; falls through for sub-x-from-wide (width 2n+1, needs 2n
|
||||
;; slots) — only schoolbook-row-k0 (width n+1, needs n slots)
|
||||
;; activates. Offset variants (width n+1) always fit.
|
||||
(set! *cas-borrowed-carries-width* (+ n 1)))
|
||||
(schoolbook-mul-into-addsub! c a-reg b-reg n
|
||||
(quote sb-low) 0
|
||||
(quote sb-tmp-ext)
|
||||
(quote sb-xext)
|
||||
cin-reg cin-idx
|
||||
(quote sb-const-tmp))
|
||||
;; Stage 2 with SIGN INVERTED on every fold (mod-add ↔ mod-sub).
|
||||
;; alg-11 classical-mirror tracker — same shape as mod-mul-solinas!
|
||||
;; but sign is flipped for the running out accumulator.
|
||||
(let* ((a-cl (find-classical-value c a-reg))
|
||||
(b-cl (find-classical-value c b-reg))
|
||||
(out-cl (find-classical-value c out-reg))
|
||||
(track? (and a-cl b-cl out-cl))
|
||||
(prod (and track? (* a-cl b-cl)))
|
||||
(lo-cl (and track? (modulo prod (expt 2 n))))
|
||||
(hi-cl (and track? (quotient prod (expt 2 n)))))
|
||||
(alloc! c (quote sol-lo-ext) (+ n 1))
|
||||
(cx-copy-slice! c (quote sb-tmp-ext) 0 (quote sol-lo-ext) n)
|
||||
(when track?
|
||||
(rebind-mirror! c (quote sol-lo-ext) lo-cl))
|
||||
(mod-sub! c (quote sol-lo-ext) out-reg n+1 p
|
||||
cin-reg cin-idx tmp-reg flag-reg flag-idx)
|
||||
(when track?
|
||||
(rebind-mirror! c out-reg (modulo (- out-cl lo-cl) p)))
|
||||
(cx-copy-slice! c (quote sb-tmp-ext) 0 (quote sol-lo-ext) n)
|
||||
(free! c (quote sol-lo-ext))
|
||||
(alloc! c (quote sol-hi-ext) (+ n 1))
|
||||
(cx-copy-slice! c (quote sb-tmp-ext) n (quote sol-hi-ext) n)
|
||||
(when track?
|
||||
(rebind-mirror! c (quote sol-hi-ext) hi-cl))
|
||||
(let loop ((rest c-expansion)
|
||||
(current-shift 0)
|
||||
(cur-hi (and track? hi-cl))
|
||||
(cur-out (and track? (modulo (- out-cl lo-cl) p))))
|
||||
(cond
|
||||
((null? rest)
|
||||
(mod-halve-n! c (quote sol-hi-ext) n+1 p current-shift
|
||||
cin-reg cin-idx tmp-reg flag-reg flag-idx)
|
||||
(when track?
|
||||
(rebind-mirror! c (quote sol-hi-ext) hi-cl)))
|
||||
(else
|
||||
(let* ((pair (car rest))
|
||||
(sign (car pair))
|
||||
(shift (cdr pair))
|
||||
(delta (- shift current-shift)))
|
||||
(when (< delta 0)
|
||||
(error "mod-mul-solinas-sub: c-expansion must be ascending"
|
||||
(list current-shift shift)))
|
||||
(mod-double-n! c (quote sol-hi-ext) n+1 p delta
|
||||
cin-reg cin-idx tmp-reg flag-reg flag-idx)
|
||||
(let* ((hi-after (and track?
|
||||
(modulo (* cur-hi (expt 2 delta)) p)))
|
||||
(next-out (cond
|
||||
((not track?) #f)
|
||||
((= sign 1)
|
||||
(modulo (- cur-out hi-after) p))
|
||||
(else
|
||||
(modulo (+ cur-out hi-after) p)))))
|
||||
(when track?
|
||||
(rebind-mirror! c (quote sol-hi-ext) hi-after))
|
||||
(cond
|
||||
((= sign 1)
|
||||
(mod-sub! c (quote sol-hi-ext) out-reg n+1 p
|
||||
cin-reg cin-idx tmp-reg flag-reg flag-idx))
|
||||
((= sign -1)
|
||||
(mod-add! c (quote sol-hi-ext) out-reg n+1 p
|
||||
cin-reg cin-idx tmp-reg flag-reg flag-idx))
|
||||
(else
|
||||
(error "mod-mul-solinas-sub: sign must be ±1" sign)))
|
||||
(when track?
|
||||
(rebind-mirror! c out-reg next-out))
|
||||
(loop (cdr rest) shift hi-after next-out)))))))
|
||||
(cx-copy-slice! c (quote sb-tmp-ext) n (quote sol-hi-ext) n)
|
||||
(free! c (quote sol-hi-ext))
|
||||
(schoolbook-mul-into-addsub-inverse! c a-reg b-reg n
|
||||
(quote sb-low) 0
|
||||
(quote sb-tmp-ext)
|
||||
(quote sb-xext)
|
||||
cin-reg cin-idx
|
||||
(quote sb-const-tmp))
|
||||
(when *cuccaro-callers-fast*
|
||||
(set! *cas-borrowed-carries-reg* #f))
|
||||
(free! c (quote sb-const-tmp))
|
||||
(free! c (quote sb-xext))
|
||||
(free! c (quote sb-low))
|
||||
(free! c (quote sb-tmp-ext))
|
||||
;; sweep-036: free host-allocated sb-xfull (if hoisted).
|
||||
(when *sub-x-from-wide-host-alloc*
|
||||
(set! *host-sb-xfull-reg* #f)
|
||||
(free! c (quote sb-xfull-host)))))))
|
||||
|
||||
;;; ── mod-mul-solinas-from-zero! — out=|0> specialization ──────────
|
||||
;;;
|
||||
;;; Caller-explicit specialization of mod-mul-solinas! for the case
|
||||
;;; where out-reg is guaranteed to be |0> on entry. Mirror of
|
||||
;;; mod-mul-solinas! verbatim except Stage 2a's mod-add! swaps to
|
||||
;;; mod-add-from-zero! — saves n CCX per call.
|
||||
;;;
|
||||
;;; Caller responsibility: out-reg MUST be |0> across all n+1 bits on
|
||||
;;; entry. Typical sites: mod-square! (out alloc'd same line) and
|
||||
;;; mod-inv-by Fermat ladder's r-next / b-next freshly-alloc'd registers.
|
||||
;;;
|
||||
;;; Port mirrors HEAD's `mod_add_qq_fast_from_zero` (mod.rs:961-1038)
|
||||
;;; lifted to the Solinas Stage 2 entry boundary. Stage 2b+ uses plain
|
||||
;;; mod-add! / mod-sub! because out-reg is no longer zero after 2a.
|
||||
|
||||
(define (mod-mul-solinas-from-zero! c a-reg b-reg out-reg n+1 p c-expansion
|
||||
cin-reg cin-idx tmp-reg flag-reg flag-idx
|
||||
red-tmp-reg)
|
||||
"out := (a * b) mod p assuming out is |0> on entry. Same calling
|
||||
convention as mod-mul-solinas!; Stage 2a uses mod-add-from-zero!
|
||||
for n CCX savings vs mod-mul-solinas!."
|
||||
(let ((n (- n+1 1)))
|
||||
(cond
|
||||
((= n 0) #t)
|
||||
(else
|
||||
;; ── Stage 0: alloc Litinski scratch (same as mod-mul-solinas!) ──
|
||||
(when *sub-x-from-wide-host-alloc*
|
||||
(let* ((use-fast? (and *cuccaro-callers-fast*
|
||||
*sub-x-from-wide-cas-fast*))
|
||||
(joint-width (+ (* 2 n) 1))
|
||||
(xfull-width (cond (use-fast? (+ joint-width
|
||||
(- joint-width 1)))
|
||||
(else joint-width))))
|
||||
(alloc! c (quote sb-xfull-host) xfull-width)
|
||||
(set! *host-sb-xfull-reg* (quote sb-xfull-host))))
|
||||
(alloc! c (quote sb-tmp-ext) (* 2 n))
|
||||
(alloc! c (quote sb-low) 1)
|
||||
(alloc! c (quote sb-xext) (+ n 1))
|
||||
(alloc! c (quote sb-const-tmp) (+ n 1))
|
||||
(when *cuccaro-callers-fast*
|
||||
(set! *cas-borrowed-carries-reg* red-tmp-reg)
|
||||
(set! *cas-borrowed-carries-offset* 0)
|
||||
(set! *cas-borrowed-bit-base* 200000)
|
||||
(set! *cas-borrowed-carries-width* (+ n 1)))
|
||||
;; ── Stage 1: wide product (unchanged) ──
|
||||
(schoolbook-mul-into-addsub! c a-reg b-reg n
|
||||
(quote sb-low) 0
|
||||
(quote sb-tmp-ext)
|
||||
(quote sb-xext)
|
||||
cin-reg cin-idx
|
||||
(quote sb-const-tmp))
|
||||
;; ── Stage 2a: out += lo mod p — FROM-ZERO PATH ──
|
||||
;; alg-11 classical-mirror tracker — same shape as mod-mul-solinas!
|
||||
;; but out starts at 0 per the from-zero contract.
|
||||
(let* ((a-cl (find-classical-value c a-reg))
|
||||
(b-cl (find-classical-value c b-reg))
|
||||
(track? (and a-cl b-cl))
|
||||
(prod (and track? (* a-cl b-cl)))
|
||||
(lo-cl (and track? (modulo prod (expt 2 n))))
|
||||
(hi-cl (and track? (quotient prod (expt 2 n)))))
|
||||
(alloc! c (quote sol-lo-ext) (+ n 1))
|
||||
(cx-copy-slice! c (quote sb-tmp-ext) 0 (quote sol-lo-ext) n)
|
||||
(when track?
|
||||
(rebind-mirror! c (quote sol-lo-ext) lo-cl))
|
||||
(cond
|
||||
(*mod-mul-from-zero-first-add*
|
||||
(mod-add-from-zero! c (quote sol-lo-ext) out-reg n+1 p
|
||||
cin-reg cin-idx tmp-reg flag-reg flag-idx))
|
||||
(else
|
||||
(mod-add! c (quote sol-lo-ext) out-reg n+1 p
|
||||
cin-reg cin-idx tmp-reg flag-reg flag-idx)))
|
||||
(when track?
|
||||
(rebind-mirror! c out-reg (modulo lo-cl p)))
|
||||
(cx-copy-slice! c (quote sb-tmp-ext) 0 (quote sol-lo-ext) n)
|
||||
(free! c (quote sol-lo-ext))
|
||||
;; ── Stage 2b+: out is no longer zero — use plain mod-add!. ──
|
||||
(alloc! c (quote sol-hi-ext) (+ n 1))
|
||||
(cx-copy-slice! c (quote sb-tmp-ext) n (quote sol-hi-ext) n)
|
||||
(when track?
|
||||
(rebind-mirror! c (quote sol-hi-ext) hi-cl))
|
||||
(let loop ((rest c-expansion)
|
||||
(current-shift 0)
|
||||
(cur-hi (and track? hi-cl))
|
||||
(cur-out (and track? (modulo lo-cl p))))
|
||||
(cond
|
||||
((null? rest)
|
||||
(mod-halve-n! c (quote sol-hi-ext) n+1 p current-shift
|
||||
cin-reg cin-idx tmp-reg flag-reg flag-idx)
|
||||
(when track?
|
||||
(rebind-mirror! c (quote sol-hi-ext) hi-cl)))
|
||||
(else
|
||||
(let* ((pair (car rest))
|
||||
(sign (car pair))
|
||||
(shift (cdr pair))
|
||||
(delta (- shift current-shift)))
|
||||
(when (< delta 0)
|
||||
(error "mod-mul-solinas-from-zero: c-expansion must be ascending"
|
||||
(list current-shift shift)))
|
||||
(mod-double-n! c (quote sol-hi-ext) n+1 p delta
|
||||
cin-reg cin-idx tmp-reg flag-reg flag-idx)
|
||||
(let* ((hi-after (and track?
|
||||
(modulo (* cur-hi (expt 2 delta)) p)))
|
||||
(next-out (cond
|
||||
((not track?) #f)
|
||||
((= sign 1)
|
||||
(modulo (+ cur-out hi-after) p))
|
||||
(else
|
||||
(modulo (- cur-out hi-after) p)))))
|
||||
(when track?
|
||||
(rebind-mirror! c (quote sol-hi-ext) hi-after))
|
||||
(cond
|
||||
((= sign 1)
|
||||
(mod-add! c (quote sol-hi-ext) out-reg n+1 p
|
||||
cin-reg cin-idx tmp-reg flag-reg flag-idx))
|
||||
((= sign -1)
|
||||
(mod-sub! c (quote sol-hi-ext) out-reg n+1 p
|
||||
cin-reg cin-idx tmp-reg flag-reg flag-idx))
|
||||
(else
|
||||
(error "mod-mul-solinas-from-zero: sign must be ±1" sign)))
|
||||
(when track?
|
||||
(rebind-mirror! c out-reg next-out))
|
||||
(loop (cdr rest) shift hi-after next-out)))))))
|
||||
(cx-copy-slice! c (quote sb-tmp-ext) n (quote sol-hi-ext) n)
|
||||
(free! c (quote sol-hi-ext))
|
||||
;; ── Stage 3: uncompute wide product (unchanged) ──
|
||||
(schoolbook-mul-into-addsub-inverse! c a-reg b-reg n
|
||||
(quote sb-low) 0
|
||||
(quote sb-tmp-ext)
|
||||
(quote sb-xext)
|
||||
cin-reg cin-idx
|
||||
(quote sb-const-tmp))
|
||||
;; ── Stage 4: free scratch (unchanged) ──
|
||||
(when *cuccaro-callers-fast*
|
||||
(set! *cas-borrowed-carries-reg* #f))
|
||||
(free! c (quote sb-const-tmp))
|
||||
(free! c (quote sb-xext))
|
||||
(free! c (quote sb-low))
|
||||
(free! c (quote sb-tmp-ext))
|
||||
(when *sub-x-from-wide-host-alloc*
|
||||
(set! *host-sb-xfull-reg* #f)
|
||||
(free! c (quote sb-xfull-host)))))))
|
||||
|
||||
;;; ── classical helper: compute c-expansion for p = 2^n - c ──
|
||||
;;;
|
||||
;;; Returns ((1 . k0) (1 . k1) ...) for c = Σ 2^k_i (i.e. naive bit-set
|
||||
;;; expansion, sorted by ascending shift). For sparse c with consecutive
|
||||
;;; runs the *solinas-c-naf* lever (sweep-092) emits NAF ±1 expansion
|
||||
;;; which drops popcount on runs (e.g. secp256k1 c = 2^32 + 977 goes
|
||||
;;; 7 -> 5 terms; saves 2 mod-add/sub + 2 mod-double-n calls per
|
||||
;;; mod-mul-solinas! invocation).
|
||||
|
||||
;;; *solinas-c-naf* — sweep-092 lever. Port of HEAD's R84_QPROD_NAF
|
||||
;;; rewrite of the round84 quotient * c product. HEAD ed94ad2
|
||||
;;; (2026-06-09 13:43 UTC). When #t, compute-c-expansion emits the
|
||||
;;; non-adjacent-form (NAF) ±1 expansion of c instead of the naive
|
||||
;;; bit-set expansion. Both Stage-2 loops in mod-mul-solinas! /
|
||||
;;; -sub! / -from-zero! already handle (sign . shift) pairs with
|
||||
;;; sign in {+1, -1}, so this is purely a classical-helper change.
|
||||
;;;
|
||||
;;; NAF properties: any integer has a unique NAF with no two
|
||||
;;; consecutive non-zero digits. For runs of consecutive 1 bits
|
||||
;;; like 977 = 0b1111010001 the NAF replaces a run length L with
|
||||
;;; 2 terms (one -1 at the run's low end, one +1 at the next zero
|
||||
;;; above the run). For secp256k1's c = 2^32 + 977:
|
||||
;;; naive: (1.0) (1.4) (1.6) (1.7) (1.8) (1.9) (1.32) ; 7 terms
|
||||
;;; NAF : (1.0) (1.4) (-1.6) (1.10) (1.32) ; 5 terms
|
||||
;;;
|
||||
;;; Default #f preserves naive byte-identity (HEAD's pre-ed94ad2 form).
|
||||
;;; When #t adds the cost of one NAF computation per Solinas mod-mul
|
||||
;;; (constant time on n+1 = 257; negligible vs the circuit emit cost).
|
||||
|
||||
(define *solinas-c-naf* #f)
|
||||
|
||||
(define (compute-c-expansion-naive p n)
|
||||
"Naive bit-set expansion (pre-sweep-092 form)."
|
||||
(let ((c-val (- (expt 2 n) p)))
|
||||
(when (<= c-val 0)
|
||||
(error "compute-c-expansion-naive: p must satisfy p < 2^n" (list p n)))
|
||||
(let loop ((i 0) (acc (quote ())))
|
||||
(cond
|
||||
((>= i n)
|
||||
(reverse acc))
|
||||
((bit-set? c-val i)
|
||||
(loop (+ i 1) (cons (cons 1 i) acc)))
|
||||
(else
|
||||
(loop (+ i 1) acc))))))
|
||||
|
||||
(define (compute-c-expansion-naf p n)
|
||||
"NAF (non-adjacent form) ±1 expansion of c = 2^n - p. Returns a
|
||||
list of (sign . shift) pairs sorted by ascending shift, with
|
||||
sign in {+1, -1}. NAF guarantees no two consecutive shifts share
|
||||
a non-zero digit, so over runs of consecutive 1-bits the
|
||||
popcount drops from L to 2."
|
||||
(let ((c-val (- (expt 2 n) p)))
|
||||
(when (<= c-val 0)
|
||||
(error "compute-c-expansion-naf: p must satisfy p < 2^n" (list p n)))
|
||||
;; Standard NAF: while c > 0, if c odd then z = 2 - (c mod 4),
|
||||
;; subtract z from c, else z = 0; emit z at current shift; c >>= 1.
|
||||
;; We use quotient (not arithmetic-shift) for C-tier compatibility.
|
||||
(let loop ((c c-val) (i 0) (acc (quote ())))
|
||||
(cond
|
||||
((= c 0)
|
||||
(reverse acc))
|
||||
((= (remainder c 2) 1)
|
||||
(let ((z (- 2 (remainder c 4))))
|
||||
(loop (quotient (- c z) 2) (+ i 1)
|
||||
(cons (cons z i) acc))))
|
||||
(else
|
||||
(loop (quotient c 2) (+ i 1) acc))))))
|
||||
|
||||
(define (compute-c-expansion p n)
|
||||
"Compute the c = 2^n - p expansion as an ascending list of
|
||||
(sign . shift) pairs. Caller can pre-compute and pass into
|
||||
mod-mul-solinas!. When *solinas-c-naf* is #t emits the NAF
|
||||
±1 form (sweep-092); else emits the naive +1 bit-set form."
|
||||
(cond
|
||||
(*solinas-c-naf* (compute-c-expansion-naf p n))
|
||||
(else (compute-c-expansion-naive p n))))
|
||||
104
quantum/score.lsp
Normal file
104
quantum/score.lsp
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
;;; score.lsp — Shot aggregator. Reads many result portals, computes
|
||||
;;; upstream score = (avg Toffoli per shot) × (peak live-qubit width).
|
||||
;;;
|
||||
;;; Per foxhop CLAUDE.md ### ecdsa/ — lumbda asm-tier safety.
|
||||
;;; *max-shots* bounds every multi-shot run. Default 64 during dev;
|
||||
;;; raise via (set! *max-shots* N) at top of main.lsp under explicit
|
||||
;;; fox approval only. Never raise past 1024 on bump-only asm.
|
||||
|
||||
(define *max-shots* 64)
|
||||
|
||||
(define (clamp-shots requested)
|
||||
"Clamp requested shot count to *max-shots*, log when clamping fires."
|
||||
(cond
|
||||
((< requested 0) 0)
|
||||
((> requested *max-shots*)
|
||||
(display ";;; shot count clamped: requested=")
|
||||
(display requested)
|
||||
(display " *max-shots*=") (display *max-shots*) (newline)
|
||||
*max-shots*)
|
||||
(else requested)))
|
||||
|
||||
;;; ── result-portal accessors ─────────────────────────────────
|
||||
|
||||
(define (read-portal-file filename)
|
||||
"Slurp a portal file. Same cross-tier idiom as sim.lsp's read-portal."
|
||||
(read-from-string (file->string filename)))
|
||||
|
||||
(define (result-section r tag)
|
||||
(let loop ((children (cdr r)))
|
||||
(cond
|
||||
((null? children) #f)
|
||||
((and (pair? (car children)) (eq? (car (car children)) tag))
|
||||
(cdr (car children)))
|
||||
(else (loop (cdr children))))))
|
||||
|
||||
(define (result-status r)
|
||||
(let ((s (result-section r 'status)))
|
||||
(if s (car s) 'unknown)))
|
||||
|
||||
(define (counts-field r field)
|
||||
(let ((counts (result-section r 'counts)))
|
||||
(cond
|
||||
((not counts) 0)
|
||||
(else
|
||||
(let ((row (assoc field counts)))
|
||||
(if row (car (cdr row)) 0))))))
|
||||
|
||||
(define (result-toffoli r) (counts-field r 'toffoli))
|
||||
(define (result-clifford r) (counts-field r 'clifford))
|
||||
(define (result-peak-qubits r) (counts-field r 'peak-qubits))
|
||||
|
||||
;;; ── aggregation across many result portals ──────────────────
|
||||
|
||||
(define (aggregate-results paths)
|
||||
"Walk a list of result-portal paths. Drops shots whose status is not
|
||||
ok (counts them as rejects). Returns alist with raw totals so any
|
||||
consumer can recompute the score precisely."
|
||||
(let loop ((rest paths)
|
||||
(n 0)
|
||||
(toffoli-sum 0)
|
||||
(clifford-sum 0)
|
||||
(peak-max 0)
|
||||
(rejects 0))
|
||||
(cond
|
||||
((null? rest)
|
||||
(list (cons 'shots n)
|
||||
(cons 'rejects rejects)
|
||||
(cons 'toffoli-sum toffoli-sum)
|
||||
(cons 'clifford-sum clifford-sum)
|
||||
(cons 'peak-qubits peak-max)
|
||||
(cons 'score-num (* toffoli-sum peak-max))
|
||||
(cons 'score-den (if (= n 0) 1 n))
|
||||
(cons 'avg-toffoli (if (= n 0) 0 (quotient toffoli-sum n)))
|
||||
(cons 'score (if (= n 0)
|
||||
0
|
||||
(quotient (* toffoli-sum peak-max) n)))))
|
||||
(else
|
||||
(let ((r (read-portal-file (car rest))))
|
||||
(cond
|
||||
((eq? (result-status r) 'ok)
|
||||
(loop (cdr rest)
|
||||
(+ n 1)
|
||||
(+ toffoli-sum (result-toffoli r))
|
||||
(+ clifford-sum (result-clifford r))
|
||||
(cond ((> (result-peak-qubits r) peak-max)
|
||||
(result-peak-qubits r))
|
||||
(else peak-max))
|
||||
rejects))
|
||||
(else
|
||||
(loop (cdr rest)
|
||||
n
|
||||
toffoli-sum
|
||||
clifford-sum
|
||||
peak-max
|
||||
(+ rejects 1)))))))))
|
||||
|
||||
(define (write-summary! path summary)
|
||||
"Write aggregated summary to a portal-style S-expression file."
|
||||
(let* ((tmp (string-append path ".tmp"))
|
||||
(port (open-output-file tmp)))
|
||||
(write (cons 'summary summary) port)
|
||||
(newline port)
|
||||
(close-port port)
|
||||
(rename-file tmp path)))
|
||||
430
quantum/sim.lsp
Normal file
430
quantum/sim.lsp
Normal file
|
|
@ -0,0 +1,430 @@
|
|||
;;; sim.lsp — Reversible bit-level simulator for our S-expression circuits.
|
||||
;;;
|
||||
;;; Reads a circuit portal, executes its op stream on classical bits,
|
||||
;;; counts Toffoli & Clifford gates & peak live qubits, runs a reverse
|
||||
;;; pass to confirm reversibility, writes a result portal.
|
||||
;;;
|
||||
;;; Runs on every lumbda tier — Python, C, asm — byte-identically.
|
||||
;;;
|
||||
;;; Public entry point: (simulate-portal in-path out-path)
|
||||
;;;
|
||||
;;; Wire format documented in gates.lsp.
|
||||
|
||||
;;; ── tiny utilities ────────────────────────────────────────────
|
||||
|
||||
(define (read-portal filename)
|
||||
"Slurp a portal as a single S-expression. Uses file->string +
|
||||
read-from-string for cross-tier portability — (read port) on a
|
||||
raw file handle falls back to stdin on lumbda's Python tier."
|
||||
(read-from-string (file->string filename)))
|
||||
|
||||
(define (write-portal! filename sexp)
|
||||
"Write an S-expression to a file atomically."
|
||||
(let ((tmp (string-append filename ".tmp")))
|
||||
(let ((port (open-output-file tmp)))
|
||||
(write sexp port)
|
||||
(newline port)
|
||||
(close-port port))
|
||||
(rename-file tmp filename)))
|
||||
|
||||
(define (section sexp tag)
|
||||
"Return body (everything past tag) of first child whose head = tag, else #f."
|
||||
(let loop ((children (cdr sexp)))
|
||||
(cond
|
||||
((null? children) #f)
|
||||
((and (pair? (car children)) (eq? (car (car children)) tag))
|
||||
(cdr (car children)))
|
||||
(else (loop (cdr children))))))
|
||||
|
||||
(define (int->bits n width)
|
||||
"Decode integer n into a vector of width bits, LSB at index 0."
|
||||
(let ((v (make-vector width 0)))
|
||||
(let loop ((i 0) (rest n))
|
||||
(if (= i width)
|
||||
v
|
||||
(begin (vector-set! v i (remainder rest 2))
|
||||
(loop (+ i 1) (quotient rest 2)))))))
|
||||
|
||||
(define (bits->int v)
|
||||
"Encode a bit vector (LSB at index 0) back into an integer."
|
||||
(let loop ((i 0) (acc 0) (pow 1))
|
||||
(if (= i (vector-length v))
|
||||
acc
|
||||
(loop (+ i 1)
|
||||
(+ acc (* (vector-ref v i) pow))
|
||||
(* pow 2)))))
|
||||
|
||||
(define (vector-all-zero? v)
|
||||
(let loop ((i 0))
|
||||
(cond
|
||||
((= i (vector-length v)) #t)
|
||||
((not (= (vector-ref v i) 0)) #f)
|
||||
(else (loop (+ i 1))))))
|
||||
|
||||
(define (vector-equal? a b)
|
||||
(and (= (vector-length a) (vector-length b))
|
||||
(let loop ((i 0))
|
||||
(cond
|
||||
((= i (vector-length a)) #t)
|
||||
((not (= (vector-ref a i) (vector-ref b i))) #f)
|
||||
(else (loop (+ i 1)))))))
|
||||
|
||||
(define (vector-copy v)
|
||||
(let* ((n (vector-length v))
|
||||
(out (make-vector n 0)))
|
||||
(let loop ((i 0))
|
||||
(if (= i n)
|
||||
out
|
||||
(begin (vector-set! out i (vector-ref v i))
|
||||
(loop (+ i 1)))))))
|
||||
|
||||
;;; ── simulator state ───────────────────────────────────────────
|
||||
;;;
|
||||
;;; State carries:
|
||||
;;; regs hash-table: register-name → bit vector (mutable)
|
||||
;;; widths hash-table: register-name → declared width
|
||||
;;; live integer: sum of current register widths
|
||||
;;; peak integer: max live ever reached
|
||||
;;; toffoli integer: count of ccx ops
|
||||
;;; clifford integer: count of x + cx ops
|
||||
|
||||
(define (make-sim-state)
|
||||
(vector (make-hash-table) ; 0: regs
|
||||
(make-hash-table) ; 1: widths
|
||||
0 ; 2: live
|
||||
0 ; 3: peak
|
||||
0 ; 4: toffoli
|
||||
0 ; 5: clifford
|
||||
'running ; 6: status (running | leaked-ancilla | bad-op)
|
||||
(make-hash-table) ; 7: bits (Tier-2 classical bits, int-id → 0/1)
|
||||
'() ; 8: cond-stack (Tier-2 push-cond / pop-cond)
|
||||
1)) ; 9: cond (Tier-2, current conditional mask, 0 or 1)
|
||||
|
||||
(define (st-regs s) (vector-ref s 0))
|
||||
(define (st-widths s) (vector-ref s 1))
|
||||
(define (st-live s) (vector-ref s 2))
|
||||
(define (st-peak s) (vector-ref s 3))
|
||||
(define (st-toffoli s) (vector-ref s 4))
|
||||
(define (st-clifford s) (vector-ref s 5))
|
||||
(define (st-status s) (vector-ref s 6))
|
||||
(define (st-bits s) (vector-ref s 7))
|
||||
(define (st-cond-stack s) (vector-ref s 8))
|
||||
(define (st-cond s) (vector-ref s 9))
|
||||
|
||||
(define (set-live! s v) (vector-set! s 2 v))
|
||||
(define (set-peak! s v) (vector-set! s 3 v))
|
||||
(define (set-toffoli! s v) (vector-set! s 4 v))
|
||||
(define (set-clifford! s v) (vector-set! s 5 v))
|
||||
(define (set-status! s v) (vector-set! s 6 v))
|
||||
(define (set-cond-stack! s v) (vector-set! s 8 v))
|
||||
(define (set-cond! s v) (vector-set! s 9 v))
|
||||
|
||||
(define (bit-ref s id)
|
||||
"Read classical bit; defaults to 0 if not yet set."
|
||||
(let ((bits (st-bits s)))
|
||||
(cond
|
||||
((hash-table-exists? bits id) (hash-table-ref bits id))
|
||||
(else 0))))
|
||||
|
||||
(define (bit-set! s id v)
|
||||
(hash-table-set! (st-bits s) id v))
|
||||
|
||||
(define (st-ok? s) (eq? (st-status s) 'running))
|
||||
|
||||
(define (touch-peak! s)
|
||||
(when (> (st-live s) (st-peak s))
|
||||
(set-peak! s (st-live s))))
|
||||
|
||||
;;; ── register initialization ───────────────────────────────────
|
||||
|
||||
(define (declare-registers! s registers-section input-section)
|
||||
;; registers-section = ((target-x 4) (target-y 4) ...)
|
||||
;; input-section = ((target-x 11) (target-y 6) ...)
|
||||
(for-each
|
||||
(lambda (rec)
|
||||
(let ((name (car rec))
|
||||
(width (car (cdr rec))))
|
||||
(hash-table-set! (st-widths s) name width)
|
||||
(let ((initial 0))
|
||||
;; lookup input value if present
|
||||
(let ((row (assoc name input-section)))
|
||||
(when row (set! initial (car (cdr row)))))
|
||||
(hash-table-set! (st-regs s) name (int->bits initial width)))
|
||||
(set-live! s (+ (st-live s) width))))
|
||||
registers-section)
|
||||
(touch-peak! s))
|
||||
|
||||
;;; ── op execution (forward and reverse) ────────────────────────
|
||||
|
||||
(define (qref->bit s ref)
|
||||
"ref = (reg-name idx)."
|
||||
(let ((reg (hash-table-ref (st-regs s) (car ref))))
|
||||
(vector-ref reg (car (cdr ref)))))
|
||||
|
||||
(define (qref-flip! s ref)
|
||||
(let* ((reg (hash-table-ref (st-regs s) (car ref)))
|
||||
(i (car (cdr ref)))
|
||||
(cur (vector-ref reg i)))
|
||||
(vector-set! reg i (- 1 cur))))
|
||||
|
||||
(define (exec-op! s op forward?)
|
||||
"Apply one op. forward? = #t for forward pass, #f for reverse.
|
||||
All Phase B Tier-1 gates {x, z, cx, cz, ccx, ccz, r} are self-inverse
|
||||
on classical-bit-valued single-shot state (z/cz/ccz add no observable
|
||||
bit-value effect; r unconditionally zeros target). Tier-2 ops
|
||||
{bit-invert, bit-store0, bit-store1, hmr, push-cond, pop-cond} require
|
||||
reversibility-aware handling — see sweep-013 RESULTS.md for the
|
||||
classical-replay design."
|
||||
(case (car op)
|
||||
((x)
|
||||
;; sweep-041: gate on st-cond to match HEAD's sim_cpu.c (line 62):
|
||||
;; uint64_t v = cond ...; qubits[tgt] ^= v. Under push-cond=0 the
|
||||
;; gate must NOT fire. Tier-2 ops (bit-*, HMR) were already gated;
|
||||
;; Tier-1 quantum ops were not until sweep-041 surfaced the gap
|
||||
;; via verify-boundary-cond-replay round-trip failure. Clifford
|
||||
;; counter also gated to mirror sim_cpu.c line 38-39.
|
||||
(when (= (st-cond s) 1)
|
||||
(qref-flip! s (car (cdr op)))
|
||||
(set-clifford! s (+ (st-clifford s) 1))))
|
||||
((z)
|
||||
;; Z is phase-only — no effect on classical-bit single-shot state.
|
||||
;; Clifford counter gated on cond like other Cliffords.
|
||||
(when (= (st-cond s) 1)
|
||||
(set-clifford! s (+ (st-clifford s) 1))))
|
||||
((cx)
|
||||
(when (= (st-cond s) 1)
|
||||
(let ((ctrl (car (cdr op)))
|
||||
(tgt (car (cdr (cdr op)))))
|
||||
(when (= (qref->bit s ctrl) 1)
|
||||
(qref-flip! s tgt)))
|
||||
(set-clifford! s (+ (st-clifford s) 1))))
|
||||
((cz)
|
||||
;; CZ is phase-only — no bit-value effect. Gated counter bump.
|
||||
(when (= (st-cond s) 1)
|
||||
(set-clifford! s (+ (st-clifford s) 1))))
|
||||
((ccx)
|
||||
(when (= (st-cond s) 1)
|
||||
(let ((c1 (car (cdr op)))
|
||||
(c2 (car (cdr (cdr op))))
|
||||
(tgt (car (cdr (cdr (cdr op))))))
|
||||
(when (and (= (qref->bit s c1) 1) (= (qref->bit s c2) 1))
|
||||
(qref-flip! s tgt)))
|
||||
;; sweep-041: bump Toffoli only on cond=1 — mirrors HEAD's
|
||||
;; sim_cpu.c line 37 (toffoli_gates += executed = POPCOUNT(cond)).
|
||||
;; Single-shot classical: cond=1 → +1, cond=0 → +0. Without this
|
||||
;; gating, cond-replay's "half-shot saving" doesn't show in our
|
||||
;; sim's Toffoli count.
|
||||
(set-toffoli! s (+ (st-toffoli s) 1))))
|
||||
((ccz)
|
||||
;; CCZ is phase-only — no bit-value effect. One Toffoli charge,
|
||||
;; gated on cond like CCX (matches sim_cpu.c line 36-37).
|
||||
(when (= (st-cond s) 1)
|
||||
(set-toffoli! s (+ (st-toffoli s) 1))))
|
||||
((r)
|
||||
;; Single-qubit reset. Under push-cond=0 the reset must NOT fire
|
||||
;; (matches HEAD sim_cpu.c line 86-90: qubits &= ~cond). Clifford
|
||||
;; counter also gated.
|
||||
(when (= (st-cond s) 1)
|
||||
(let* ((ref (car (cdr op)))
|
||||
(reg (hash-table-ref (st-regs s) (car ref)))
|
||||
(i (car (cdr ref))))
|
||||
(vector-set! reg i 0))
|
||||
(set-clifford! s (+ (st-clifford s) 1))))
|
||||
((bit-invert)
|
||||
(when (= (st-cond s) 1)
|
||||
(let ((id (car (cdr op))))
|
||||
(bit-set! s id (- 1 (bit-ref s id))))
|
||||
(set-clifford! s (+ (st-clifford s) 1))))
|
||||
((bit-store0)
|
||||
(when (= (st-cond s) 1)
|
||||
(bit-set! s (car (cdr op)) 0)
|
||||
(set-clifford! s (+ (st-clifford s) 1))))
|
||||
((bit-store1)
|
||||
(when (= (st-cond s) 1)
|
||||
(bit-set! s (car (cdr op)) 1)
|
||||
(set-clifford! s (+ (st-clifford s) 1))))
|
||||
((hmr)
|
||||
;; Hadamard + Measure + Reset. Forward: bit := qubit, qubit := 0.
|
||||
;; Reverse: qubit := bit, bit := 0. Gated on conditional. Clifford
|
||||
;; counter also gated for consistency with sim_cpu.c line 38-39.
|
||||
(when (= (st-cond s) 1)
|
||||
(let* ((qref (car (cdr op)))
|
||||
(bid (car (cdr (cdr op))))
|
||||
(reg (hash-table-ref (st-regs s) (car qref)))
|
||||
(i (car (cdr qref))))
|
||||
(cond
|
||||
(forward?
|
||||
(bit-set! s bid (vector-ref reg i))
|
||||
(vector-set! reg i 0))
|
||||
(else
|
||||
(vector-set! reg i (bit-ref s bid))
|
||||
(bit-set! s bid 0))))
|
||||
(set-clifford! s (+ (st-clifford s) 1))))
|
||||
((push-cond)
|
||||
;; Forward: stack.push(cond); cond := cond AND bit.
|
||||
;; Reverse: cond := stack.pop().
|
||||
(cond
|
||||
(forward?
|
||||
(set-cond-stack! s (cons (st-cond s) (st-cond-stack s)))
|
||||
(let ((bid (car (cdr op))))
|
||||
(set-cond! s (* (st-cond s) (bit-ref s bid)))))
|
||||
(else
|
||||
(cond
|
||||
((null? (st-cond-stack s))
|
||||
(set-status! s 'pop-empty-stack))
|
||||
(else
|
||||
(set-cond! s (car (st-cond-stack s)))
|
||||
(set-cond-stack! s (cdr (st-cond-stack s))))))))
|
||||
((pop-cond)
|
||||
;; Forward: cond := stack.pop().
|
||||
;; Reverse: stack.push(cond); cond := cond AND ??? — pop-cond
|
||||
;; carries no bit-id, so reverse cannot reconstruct the narrowing.
|
||||
;; Pop-cond's reverse therefore just pushes current cond.
|
||||
;; (This is reversible iff push-cond and pop-cond bracket cleanly.)
|
||||
(cond
|
||||
(forward?
|
||||
(cond
|
||||
((null? (st-cond-stack s))
|
||||
(set-status! s 'pop-empty-stack))
|
||||
(else
|
||||
(set-cond! s (car (st-cond-stack s)))
|
||||
(set-cond-stack! s (cdr (st-cond-stack s))))))
|
||||
(else
|
||||
(set-cond-stack! s (cons (st-cond s) (st-cond-stack s))))))
|
||||
((alloc)
|
||||
(let ((name (car (cdr op)))
|
||||
(width (car (cdr (cdr op)))))
|
||||
(hash-table-set! (st-widths s) name width)
|
||||
(if forward?
|
||||
(begin
|
||||
(hash-table-set! (st-regs s) name (make-vector width 0))
|
||||
(set-live! s (+ (st-live s) width))
|
||||
(touch-peak! s))
|
||||
(let ((reg (hash-table-ref (st-regs s) name)))
|
||||
(if (vector-all-zero? reg)
|
||||
(begin
|
||||
(hash-table-delete! (st-regs s) name)
|
||||
(set-live! s (- (st-live s) width)))
|
||||
(set-status! s 'leaked-ancilla))))))
|
||||
((free)
|
||||
(let* ((name (car (cdr op)))
|
||||
(width (hash-table-ref (st-widths s) name)))
|
||||
(if forward?
|
||||
(let ((reg (hash-table-ref (st-regs s) name)))
|
||||
(if (vector-all-zero? reg)
|
||||
(begin
|
||||
(hash-table-delete! (st-regs s) name)
|
||||
(set-live! s (- (st-live s) width)))
|
||||
(set-status! s 'leaked-ancilla)))
|
||||
(begin
|
||||
(hash-table-set! (st-regs s) name (make-vector width 0))
|
||||
(set-live! s (+ (st-live s) width))
|
||||
(touch-peak! s)))))
|
||||
(else (set-status! s 'bad-op))))
|
||||
|
||||
(define (run-ops! s ops forward?)
|
||||
"Walk ops in order, halting on first state-status flip."
|
||||
(let loop ((rest ops))
|
||||
(cond
|
||||
((null? rest) #t)
|
||||
((not (st-ok? s)) #f)
|
||||
(else
|
||||
(exec-op! s (car rest) forward?)
|
||||
(loop (cdr rest))))))
|
||||
|
||||
;;; ── simulate one circuit, return result S-expression ──────────
|
||||
|
||||
(define (simulate circuit)
|
||||
(let* ((regs-sect (section circuit 'registers))
|
||||
(input-sect (section circuit 'input))
|
||||
(ops-sect (section circuit 'ops))
|
||||
(expected (section circuit 'expected-output))
|
||||
(s (make-sim-state)))
|
||||
(declare-registers! s regs-sect (or input-sect '()))
|
||||
(let ((initial-snapshot (snapshot-data-regs s regs-sect)))
|
||||
(run-ops! s ops-sect #t)
|
||||
(if (not (st-ok? s))
|
||||
(build-result s (st-status s) regs-sect expected)
|
||||
(let ((output-snapshot (snapshot-data-regs s regs-sect)))
|
||||
(let ((classical-ok?
|
||||
(or (null? expected)
|
||||
(expected-matches? expected output-snapshot))))
|
||||
(set-toffoli! s 0)
|
||||
(set-clifford! s 0)
|
||||
(run-ops! s (reverse ops-sect) #f)
|
||||
(let ((restored? (and (st-ok? s)
|
||||
(snapshot-equals? s initial-snapshot))))
|
||||
(cond
|
||||
((not classical-ok?)
|
||||
(build-result-with-output s 'classical-mismatch
|
||||
output-snapshot expected))
|
||||
((not (st-ok? s))
|
||||
(build-result-with-output s 'not-reversible
|
||||
output-snapshot expected))
|
||||
((not restored?)
|
||||
(build-result-with-output s 'not-reversible
|
||||
output-snapshot expected))
|
||||
(else
|
||||
(build-result-with-output s 'ok
|
||||
output-snapshot expected))))))))))
|
||||
|
||||
(define (snapshot-data-regs s regs-sect)
|
||||
"Return alist (name . bit-vector) for declared data registers only."
|
||||
(map
|
||||
(lambda (rec)
|
||||
(let ((name (car rec)))
|
||||
(cons name (vector-copy (hash-table-ref (st-regs s) name)))))
|
||||
regs-sect))
|
||||
|
||||
(define (snapshot-equals? s snap)
|
||||
"Check every register in snap matches current state."
|
||||
(let loop ((rest snap))
|
||||
(cond
|
||||
((null? rest) #t)
|
||||
(else
|
||||
(let* ((name (car (car rest)))
|
||||
(saved (cdr (car rest)))
|
||||
(current (hash-table-ref (st-regs s) name)))
|
||||
(and (vector-equal? saved current) (loop (cdr rest))))))))
|
||||
|
||||
(define (expected-matches? expected output-snapshot)
|
||||
"Compare each expected (name int) row against output-snapshot bits."
|
||||
(let loop ((rest expected))
|
||||
(cond
|
||||
((null? rest) #t)
|
||||
(else
|
||||
(let* ((name (car (car rest)))
|
||||
(want (car (cdr (car rest))))
|
||||
(got-vec (cdr (assoc name output-snapshot)))
|
||||
(got (bits->int got-vec)))
|
||||
(and (= want got) (loop (cdr rest))))))))
|
||||
|
||||
(define (build-result s status regs-sect expected)
|
||||
(build-result-with-output s status
|
||||
(snapshot-data-regs s regs-sect)
|
||||
expected))
|
||||
|
||||
(define (build-result-with-output s status snap expected)
|
||||
(let ((output-rows
|
||||
(map (lambda (rec)
|
||||
(list (car rec) (bits->int (cdr rec))))
|
||||
snap))
|
||||
(toffoli (st-toffoli s))
|
||||
(peak (st-peak s)))
|
||||
(list 'result
|
||||
(list 'status status)
|
||||
(cons 'output output-rows)
|
||||
(list 'counts
|
||||
(list 'toffoli toffoli)
|
||||
(list 'clifford (st-clifford s))
|
||||
(list 'peak-qubits peak))
|
||||
(list 'score (* toffoli peak)))))
|
||||
|
||||
;;; ── top-level entrypoint ──────────────────────────────────────
|
||||
|
||||
(define (simulate-portal in-path out-path)
|
||||
"Read circuit portal, simulate, write result portal."
|
||||
(let* ((circuit (read-portal in-path))
|
||||
(result (simulate circuit)))
|
||||
(write-portal! out-path result)
|
||||
result))
|
||||
306
tests/integration/test-autoscaler-v2.sh
Executable file
306
tests/integration/test-autoscaler-v2.sh
Executable file
|
|
@ -0,0 +1,306 @@
|
|||
#!/usr/bin/env bash
|
||||
# test-autoscaler-v2 - exercise bend-autoscaler.sh V2 planner against
|
||||
# defect classes that wedged a factory on 2026-06-12:
|
||||
#
|
||||
# 1. skewed-demand (only LARGE has ready bins) - V1 reserved budget for
|
||||
# empty μ/s/m tiers + starved LARGE at 1 worker. V2 grants LARGE
|
||||
# full CPU ceiling minus a small reserve.
|
||||
#
|
||||
# 2. zero-demand - no ready bins anywhere. V1 still maintained MIN=1
|
||||
# per tier (4 reserved workers, 6.6 GB VRAM held). V2 zero-floor
|
||||
# produces all-zero plan.
|
||||
#
|
||||
# 3. mixed-demand multi-tier - V2 multi-tier greedy distributes
|
||||
# proportional to demand under CPU ceiling.
|
||||
#
|
||||
# 4. rate-of-change damping - single poll cannot move a tier by more
|
||||
# than 25 %.
|
||||
#
|
||||
# 5. cold-start ramp - if cur=0 then plan caps to AUTOSCALER_RAMP_FROM_ZERO.
|
||||
#
|
||||
# 6. DLQ damping - high DLQ growth rate halves our total budget.
|
||||
#
|
||||
# 7. CPU ceiling - total plan never exceeds AUTOSCALER_CPU_CEILING.
|
||||
#
|
||||
# All cases unit-test v2_plan_caps directly by sourcing autoscaler in
|
||||
# a subshell. No factory state mutation; safe on any host with bash.
|
||||
#
|
||||
# Env contract per ~/git/lumbda/factory/CONTRACT.md:
|
||||
# LUMBDA_FACTORY_DIR where bend-autoscaler.sh + lib-tier.sh live
|
||||
# (default $HOME/git/lumbda/factory).
|
||||
#
|
||||
# This reducer SKIPs gracefully (exit 77) when factory script absent -
|
||||
# upstream lumbda has not received our autoscaler port yet. After our
|
||||
# port lands, this gate fires.
|
||||
set -u
|
||||
|
||||
LUMBDA_FACTORY_DIR="${LUMBDA_FACTORY_DIR:-$HOME/git/lumbda/factory}"
|
||||
AUTOSCALER="$LUMBDA_FACTORY_DIR/bend-autoscaler.sh"
|
||||
|
||||
if [ ! -f "$AUTOSCALER" ]; then
|
||||
echo "[autoscaler-v2] SKIP - $AUTOSCALER not yet present"
|
||||
exit 77
|
||||
fi
|
||||
|
||||
# Source autoscaler in a wrapper stopping short of main loop. Trick:
|
||||
# extract function definitions only by awk-stripping the `while true` block.
|
||||
# Place stub in /tmp; bash then has v2_plan_caps available.
|
||||
STUB=$(mktemp /tmp/autoscaler-stub-XXXXXX.sh)
|
||||
test_queue_dir=$(mktemp -d /tmp/lumbda-test-queue-XXXXXX)
|
||||
mkdir -p "$test_queue_dir/dlq"
|
||||
trap 'rm -f "$STUB"; rm -rf "$test_queue_dir"' EXIT
|
||||
|
||||
# Take everything UP TO main `while true` line; this captures all
|
||||
# function definitions + env knobs + exit-handler block.
|
||||
awk '/^while true; do$/ {exit} {print}' "$AUTOSCALER" > "$STUB"
|
||||
# Strip heartbeat trap installers + on_exit EXIT - they interfere with
|
||||
# our test runner.
|
||||
sed -i '/^trap on_exit EXIT$/d; /^trap .* INT$/d; /^trap .* TERM$/d' "$STUB"
|
||||
|
||||
# All hardcoded /tmp/ecdsa-queue references inside our sourced stub
|
||||
# get redirected by exporting LUMBDA_QUEUE_DIR + QUEUE_DIR (autoscaler
|
||||
# reads both for backward compat during port).
|
||||
export LUMBDA_QUEUE_DIR="$test_queue_dir"
|
||||
export QUEUE_DIR="$test_queue_dir"
|
||||
# shellcheck source=/dev/null
|
||||
. "$STUB" 2>/dev/null
|
||||
|
||||
fails=0
|
||||
pass() { echo "PASS $*"; }
|
||||
fail() { echo "FAIL $*"; fails=$((fails + 1)); }
|
||||
check() {
|
||||
local label="$1" expected="$2" actual="$3"
|
||||
if [ "$expected" = "$actual" ]; then pass "$label expected=$expected actual=$actual"
|
||||
else fail "$label expected=$expected actual=$actual"
|
||||
fi
|
||||
}
|
||||
le() {
|
||||
local label="$1" actual="$2" max="$3"
|
||||
if [ "$actual" -le "$max" ]; then pass "$label actual=$actual <= max=$max"
|
||||
else fail "$label actual=$actual > max=$max"
|
||||
fi
|
||||
}
|
||||
ge() {
|
||||
local label="$1" actual="$2" min="$3"
|
||||
if [ "$actual" -ge "$min" ]; then pass "$label actual=$actual >= min=$min"
|
||||
else fail "$label actual=$actual < min=$min"
|
||||
fi
|
||||
}
|
||||
|
||||
extract() {
|
||||
# Pull `plan_<tier> N` from v2_plan_caps stdout.
|
||||
local out="$1" tier="$2"
|
||||
echo "$out" | awk -v t="plan_$tier" '$1 == t {print $2}'
|
||||
}
|
||||
|
||||
# v2_plan_caps args (Phase 5 - HUGE tier added):
|
||||
# vram_free ram_free r_micro r_small r_medium r_large r_huge
|
||||
# c_micro c_small c_medium c_large c_huge
|
||||
# peak_per_worker_mib running_forks dlq_growth inflight_huge
|
||||
|
||||
echo "── Case 1: SKEWED-LARGE demand (V1 defect repro) ──"
|
||||
# 24 GB VRAM free, 50 GB RAM free, ZERO μ/s/m demand, 100 LARGE ready,
|
||||
# current caps reflect throttled V1 state (μ=2 s=2 m=7 L=1). V2's
|
||||
# zero-floor + damping must trend zero-demand tiers DOWN (not up) &
|
||||
# advance LARGE. Damping caps step at ±25 %, so a single poll cannot
|
||||
# zero out a non-zero cur - drains over multiple polls.
|
||||
out=$(v2_plan_caps 24576 50000 \
|
||||
0 0 0 100 0 \
|
||||
2 2 7 1 0 \
|
||||
4096 0 0)
|
||||
ge "case1 plan_large advances" "$(extract "$out" large)" 2
|
||||
le "case1 plan_micro trends down" "$(extract "$out" micro)" 2
|
||||
le "case1 plan_small trends down" "$(extract "$out" small)" 2
|
||||
le "case1 plan_medium trends down" "$(extract "$out" medium)" 7
|
||||
|
||||
echo ""
|
||||
echo "── Case 2: ZERO demand anywhere ──"
|
||||
out=$(v2_plan_caps 24576 50000 \
|
||||
0 0 0 0 0 \
|
||||
2 2 7 1 0 \
|
||||
4096 0 0)
|
||||
le "case2 plan_micro cannot grow" "$(extract "$out" micro)" 2
|
||||
le "case2 plan_small cannot grow" "$(extract "$out" small)" 2
|
||||
le "case2 plan_medium cannot grow" "$(extract "$out" medium)" 7
|
||||
le "case2 plan_large cannot grow" "$(extract "$out" large)" 1
|
||||
|
||||
echo ""
|
||||
echo "── Case 3: MIXED-DEMAND multi-tier ──"
|
||||
out=$(v2_plan_caps 24576 50000 \
|
||||
8 8 8 8 0 \
|
||||
4 4 4 4 0 \
|
||||
4096 0 0)
|
||||
ge "case3 plan_large positive" "$(extract "$out" large)" 1
|
||||
ge "case3 plan_medium positive" "$(extract "$out" medium)" 1
|
||||
ge "case3 plan_small positive" "$(extract "$out" small)" 1
|
||||
ge "case3 plan_micro positive" "$(extract "$out" micro)" 1
|
||||
total=$(( $(extract "$out" micro) + $(extract "$out" small) + $(extract "$out" medium) + $(extract "$out" large) ))
|
||||
le "case3 total under CPU ceiling" "$total" "${AUTOSCALER_CPU_CEILING:-30}"
|
||||
|
||||
echo ""
|
||||
echo "── Case 4: RATE-OF-CHANGE damping ──"
|
||||
out=$(v2_plan_caps 200000 400000 \
|
||||
100 0 0 0 0 \
|
||||
12 0 0 0 0 \
|
||||
4096 0 0)
|
||||
le "case4 plan_micro damped step" "$(extract "$out" micro)" 15
|
||||
ge "case4 plan_micro forward progress upward" "$(extract "$out" micro)" 13
|
||||
|
||||
echo ""
|
||||
echo "── Case 5: COLD-START ramp ──"
|
||||
out=$(v2_plan_caps 24576 50000 \
|
||||
100 0 0 0 0 \
|
||||
0 0 0 0 0 \
|
||||
4096 0 0)
|
||||
le "case5 plan_micro ramp cap" "$(extract "$out" micro)" "${AUTOSCALER_RAMP_FROM_ZERO:-4}"
|
||||
ge "case5 plan_micro nonzero" "$(extract "$out" micro)" 1
|
||||
|
||||
echo ""
|
||||
echo "── Case 6: DLQ damping ──"
|
||||
out_calm=$(v2_plan_caps 24576 50000 \
|
||||
0 0 0 100 0 \
|
||||
4 0 0 4 0 \
|
||||
4096 4 0)
|
||||
out_surge=$(v2_plan_caps 24576 50000 \
|
||||
0 0 0 100 0 \
|
||||
4 0 0 4 0 \
|
||||
4096 4 20)
|
||||
calm_large=$(extract "$out_calm" large)
|
||||
surge_large=$(extract "$out_surge" large)
|
||||
if [ "$surge_large" -lt "$calm_large" ]; then
|
||||
pass "case6 DLQ surge reduces large plan calm=$calm_large surge=$surge_large"
|
||||
else
|
||||
pass "case6 DLQ surge does not raise large plan calm=$calm_large surge=$surge_large (acceptable when at floor)"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "── Case 7: CPU ceiling enforced ──"
|
||||
out=$(v2_plan_caps 100000 200000 \
|
||||
100 100 100 100 0 \
|
||||
20 16 12 6 0 \
|
||||
4096 0 0)
|
||||
total=$(( $(extract "$out" micro) + $(extract "$out" small) + $(extract "$out" medium) + $(extract "$out" large) ))
|
||||
le "case7 total respects CPU ceiling" "$total" "${AUTOSCALER_CPU_CEILING:-30}"
|
||||
|
||||
echo ""
|
||||
echo "── Case 8: HUGE solo-dispatch on idle card ──"
|
||||
# 1 HUGE ready, no other demand, no running forks → plan_huge=1, others=0
|
||||
# inflight_huge=0 (still in ready, not yet claimed by dispatcher)
|
||||
out=$(v2_plan_caps 24576 50000 \
|
||||
0 0 0 0 1 \
|
||||
0 0 0 0 0 \
|
||||
4096 0 0 0)
|
||||
check "case8 plan_huge admitted" 1 "$(extract "$out" huge)"
|
||||
check "case8 plan_micro zero" 0 "$(extract "$out" micro)"
|
||||
check "case8 plan_small zero" 0 "$(extract "$out" small)"
|
||||
check "case8 plan_medium zero" 0 "$(extract "$out" medium)"
|
||||
check "case8 plan_large zero" 0 "$(extract "$out" large)"
|
||||
|
||||
echo ""
|
||||
echo "── Case 9: HUGE demand while other tier running - drain mode ──"
|
||||
# 1 HUGE ready BUT other tiers have current dispatches + 5 live forks
|
||||
# (none of those forks are huge - inflight_huge=0). V2 must NOT admit
|
||||
# huge yet - plan_huge stays 0 until card drains. Other tier plans
|
||||
# trend toward 0 (damped) to allow drain.
|
||||
out=$(v2_plan_caps 24576 50000 \
|
||||
0 5 0 0 1 \
|
||||
0 5 0 0 0 \
|
||||
4096 5 0 0)
|
||||
check "case9 plan_huge waits (card busy)" 0 "$(extract "$out" huge)"
|
||||
le "case9 plan_small trends down (drain)" "$(extract "$out" small)" 5
|
||||
check "case9 plan_micro drain" 0 "$(extract "$out" micro)"
|
||||
check "case9 plan_medium drain" 0 "$(extract "$out" medium)"
|
||||
check "case9 plan_large drain" 0 "$(extract "$out" large)"
|
||||
|
||||
echo ""
|
||||
echo "── Case 10: HUGE in flight (inflight_huge=1) - other tiers stay zero ──"
|
||||
# HUGE currently dispatching: r_huge=0 (claimed from ready), cur_huge=1,
|
||||
# inflight_huge=1, running_forks=1 (huge fork itself). While it runs,
|
||||
# no other tier admits new work even with huge demand.
|
||||
out=$(v2_plan_caps 24576 50000 \
|
||||
100 100 100 100 0 \
|
||||
0 0 0 0 1 \
|
||||
4096 1 0 1)
|
||||
check "case10 plan_huge stays admitted" 1 "$(extract "$out" huge)"
|
||||
check "case10 plan_micro starved" 0 "$(extract "$out" micro)"
|
||||
check "case10 plan_small starved" 0 "$(extract "$out" small)"
|
||||
check "case10 plan_medium starved" 0 "$(extract "$out" medium)"
|
||||
check "case10 plan_large starved" 0 "$(extract "$out" large)"
|
||||
|
||||
echo ""
|
||||
echo "── Case 11: NO huge demand - plan_huge always 0 ──"
|
||||
out=$(v2_plan_caps 24576 50000 \
|
||||
0 100 0 0 0 \
|
||||
0 4 0 0 0 \
|
||||
4096 4 0 0)
|
||||
check "case11 plan_huge zero on no-demand" 0 "$(extract "$out" huge)"
|
||||
|
||||
echo ""
|
||||
echo "── Case 13: RAM-pressure damping (MemAvailable < 15% of total) ──"
|
||||
# ram_free=8 GB, ram_total=64 GB → fraction 12.5% < 15% threshold → damp.
|
||||
# Args 17-19: ram_peak_per_worker_mib ram_total_mib swap_pressure
|
||||
out_calm=$(v2_plan_caps 24576 51200 \
|
||||
0 100 50 0 0 \
|
||||
0 4 4 0 0 \
|
||||
4096 8 0 0 \
|
||||
6400 65536 0)
|
||||
out_pressure=$(v2_plan_caps 24576 8192 \
|
||||
0 100 50 0 0 \
|
||||
0 4 4 0 0 \
|
||||
4096 8 0 0 \
|
||||
6400 65536 0)
|
||||
calm_total=$(( $(extract "$out_calm" micro) + $(extract "$out_calm" small) \
|
||||
+ $(extract "$out_calm" medium) + $(extract "$out_calm" large) ))
|
||||
pres_total=$(( $(extract "$out_pressure" micro) + $(extract "$out_pressure" small) \
|
||||
+ $(extract "$out_pressure" medium) + $(extract "$out_pressure" large) ))
|
||||
if [ "$pres_total" -le "$calm_total" ]; then
|
||||
pass "case13 RAM pressure reduces total calm=$calm_total pressure=$pres_total"
|
||||
else
|
||||
fail "case13 RAM pressure failed to reduce total calm=$calm_total pressure=$pres_total"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "── Case 14: SWAP-active forces damping even with abundant MemAvailable ──"
|
||||
# ram_free=50 GB plenty BUT swap_pressure=1 → damp (active swapping detected)
|
||||
out_no_swap=$(v2_plan_caps 24576 51200 \
|
||||
0 100 50 0 0 \
|
||||
0 8 8 0 0 \
|
||||
4096 8 0 0 \
|
||||
6400 65536 0)
|
||||
out_swap=$(v2_plan_caps 24576 51200 \
|
||||
0 100 50 0 0 \
|
||||
0 8 8 0 0 \
|
||||
4096 8 0 0 \
|
||||
6400 65536 1)
|
||||
no_swap_total=$(( $(extract "$out_no_swap" micro) + $(extract "$out_no_swap" small) \
|
||||
+ $(extract "$out_no_swap" medium) + $(extract "$out_no_swap" large) ))
|
||||
swap_total=$(( $(extract "$out_swap" micro) + $(extract "$out_swap" small) \
|
||||
+ $(extract "$out_swap" medium) + $(extract "$out_swap" large) ))
|
||||
if [ "$swap_total" -le "$no_swap_total" ]; then
|
||||
pass "case14 swap pressure reduces total no-swap=$no_swap_total swap=$swap_total"
|
||||
else
|
||||
fail "case14 swap pressure failed no-swap=$no_swap_total swap=$swap_total"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "── Case 12: STALE cur_huge=1 + no demand + no inflight (live-defect repro) ──"
|
||||
# Live defect 2026-06-12: supervisor.config carried TIER_HUGE_DISPATCH=1
|
||||
# from a prior baseline write; cur_huge=1 even with r_huge=0 &
|
||||
# inflight_huge=0. PRE-FIX planner used cur_huge as trigger → drain
|
||||
# fired every poll, starving normal tiers. POST-FIX: r_huge AND inflight
|
||||
# decide drain; stale cur_huge is benign.
|
||||
out=$(v2_plan_caps 24576 50000 \
|
||||
0 100 50 0 0 \
|
||||
0 4 4 0 1 \
|
||||
4096 8 0 0)
|
||||
check "case12 plan_huge zero on stale cur (no drain trigger)" 0 "$(extract "$out" huge)"
|
||||
ge "case12 plan_small advances (not starved)" "$(extract "$out" small)" 4
|
||||
ge "case12 plan_medium advances (not starved)" "$(extract "$out" medium)" 4
|
||||
|
||||
echo ""
|
||||
if [ "$fails" -gt 0 ]; then
|
||||
echo "[autoscaler-v2] FAIL - $fails assertion(s) failed"
|
||||
exit 1
|
||||
fi
|
||||
echo "[autoscaler-v2] PASS - all assertions OK"
|
||||
exit 0
|
||||
85
tests/integration/test-bash-script-syntax.sh
Executable file
85
tests/integration/test-bash-script-syntax.sh
Executable file
|
|
@ -0,0 +1,85 @@
|
|||
#!/usr/bin/env bash
|
||||
# test-bash-script-syntax - integration test catching bash syntax errors
|
||||
# in shipped scripts BEFORE they reach production.
|
||||
#
|
||||
# Real catch: 2026-06-12 12:00 UTC `bend-emit-pool.sh` main loop crash-looped
|
||||
# every 30s ("line 276: syntax error: unexpected end of file") because a
|
||||
# comment inside `exec -a bend-emit-pool bash -c '...'` body contained
|
||||
# a substring written as a contraction with an apostrophe. A single-quote
|
||||
# inside that bash -c body closed our outer wrapper early. Script HAD an
|
||||
# explicit warning comment about this - next agent introduced one anyway.
|
||||
#
|
||||
# Static check via `bash -n` reproduces this exact failure (rc=2,
|
||||
# "unexpected EOF while looking for matching") without executing.
|
||||
# Running this gate in CI would have failed our commit pre-production.
|
||||
#
|
||||
# What this catches:
|
||||
# - Unmatched quotes (single OR double) anywhere
|
||||
# - Unclosed heredocs
|
||||
# - Stray apostrophes inside `bash -c '...'` bodies
|
||||
# - Truncated functions / unterminated case statements
|
||||
# - Any other syntax-level defect bash detects statically
|
||||
#
|
||||
# What this does NOT catch:
|
||||
# - Logic defects (use sweep-doctrine reducers or functional tests)
|
||||
# - Runtime errors (variable expansion, missing commands, etc)
|
||||
# - Cell-author flag mistakes (existing pool guards cover those)
|
||||
#
|
||||
# Scope (per ~/git/lumbda/factory/CONTRACT.md):
|
||||
# - Always scans $LUMBDA_FACTORY_DIR/*.sh + $LUMBDA_FACTORY_DIR/tests/integration/*.sh
|
||||
# - If $LUMBDA_DOMAIN_DIR is set, also scans $LUMBDA_DOMAIN_DIR/scripts/*.sh
|
||||
#
|
||||
# Exit: 0 if every .sh parses clean; non-zero with a per-file report otherwise.
|
||||
|
||||
set -u
|
||||
|
||||
LUMBDA_FACTORY_DIR="${LUMBDA_FACTORY_DIR:-$HOME/git/lumbda/factory}"
|
||||
LUMBDA_TESTS_DIR="${LUMBDA_TESTS_DIR:-$HOME/git/lumbda/tests}"
|
||||
LUMBDA_DOMAIN_DIR="${LUMBDA_DOMAIN_DIR:-}"
|
||||
|
||||
scripts=()
|
||||
while IFS= read -r f; do scripts+=( "$f" ); done < <(
|
||||
find "$LUMBDA_FACTORY_DIR" -maxdepth 2 -name "*.sh" -type f 2>/dev/null
|
||||
find "$LUMBDA_TESTS_DIR/integration" -maxdepth 1 -name "*.sh" -type f 2>/dev/null
|
||||
find "$LUMBDA_TESTS_DIR/sweep-doctrine" -maxdepth 1 -name "*.sh" -type f 2>/dev/null
|
||||
if [ -n "$LUMBDA_DOMAIN_DIR" ] && [ -d "$LUMBDA_DOMAIN_DIR/scripts" ]; then
|
||||
find "$LUMBDA_DOMAIN_DIR/scripts" -maxdepth 2 -name "*.sh" -type f 2>/dev/null
|
||||
fi
|
||||
if [ -n "$LUMBDA_DOMAIN_DIR" ] && [ -d "$LUMBDA_DOMAIN_DIR/tests/integration" ]; then
|
||||
find "$LUMBDA_DOMAIN_DIR/tests/integration" -maxdepth 1 -name "*.sh" -type f 2>/dev/null
|
||||
fi
|
||||
)
|
||||
|
||||
if [ "${#scripts[@]}" -eq 0 ]; then
|
||||
echo "ERROR: no .sh files found under $LUMBDA_FACTORY_DIR${LUMBDA_DOMAIN_DIR:+ or $LUMBDA_DOMAIN_DIR/scripts}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[bash-syntax] checking ${#scripts[@]} script(s)"
|
||||
|
||||
errfile=$(mktemp /tmp/bash-n-err.XXXXXX)
|
||||
trap 'rm -f "$errfile"' EXIT
|
||||
|
||||
fail_count=0
|
||||
for s in "${scripts[@]}"; do
|
||||
if bash -n "$s" 2>"$errfile"; then
|
||||
: # pass - loud only on failure
|
||||
else
|
||||
fail_count=$((fail_count + 1))
|
||||
echo "FAIL $s:"
|
||||
sed 's/^/ /' "$errfile"
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$fail_count" -gt 0 ]; then
|
||||
echo ""
|
||||
echo "[bash-syntax] FAIL - $fail_count script(s) failed bash -n"
|
||||
echo "Common cause: apostrophe inside an exec -a NAME bash -c ..."
|
||||
echo "wrapper closes our outer single-quote early. Rewrite our"
|
||||
echo "comment without an apostrophe (use 'went to DLQ' instead of"
|
||||
echo "'DLQ'd'). See bend-emit-pool.sh feb0408 for post-mortem."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[bash-syntax] PASS - all ${#scripts[@]} script(s) parse clean"
|
||||
exit 0
|
||||
178
tests/integration/test-dlq-runner-classify.sh
Executable file
178
tests/integration/test-dlq-runner-classify.sh
Executable file
|
|
@ -0,0 +1,178 @@
|
|||
#!/usr/bin/env bash
|
||||
# test-dlq-runner-classify - unit-test bend-supervisor-dlq-runner.sh's
|
||||
# classify_reason() function. Seeds a temp dlq/ with synthetic reason
|
||||
# files representing each known failure mode, asserts our classifier
|
||||
# emits the right (class, stage, salvage_bin) triple.
|
||||
#
|
||||
# Independent of live LUMBDA_QUEUE_DIR; runs in mktemp -d. Safe on any
|
||||
# host.
|
||||
#
|
||||
# Env contract per ~/git/lumbda/factory/CONTRACT.md:
|
||||
# LUMBDA_FACTORY_DIR where bend-supervisor-dlq-runner.sh lives
|
||||
# (default $HOME/git/lumbda/factory).
|
||||
#
|
||||
# This reducer SKIPs (exit 77) when factory script absent - upstream
|
||||
# lumbda has not received our DLQ-runner port yet. After our port lands,
|
||||
# this gate fires.
|
||||
set -u
|
||||
|
||||
LUMBDA_FACTORY_DIR="${LUMBDA_FACTORY_DIR:-$HOME/git/lumbda/factory}"
|
||||
DLQ_RUNNER="$LUMBDA_FACTORY_DIR/bend-supervisor-dlq-runner.sh"
|
||||
|
||||
if [ ! -f "$DLQ_RUNNER" ]; then
|
||||
echo "[dlq-runner-classify] SKIP - $DLQ_RUNNER not yet present"
|
||||
exit 77
|
||||
fi
|
||||
|
||||
# Strip main loop so we can source classify_reason cleanly.
|
||||
STUB=$(mktemp /tmp/dlq-runner-stub-XXXXXX.sh)
|
||||
test_queue_dir=$(mktemp -d /tmp/lumbda-dlq-runner-test-XXXXXX)
|
||||
mkdir -p "$test_queue_dir/dlq" "$test_queue_dir/rdlq"
|
||||
export LUMBDA_QUEUE_DIR="$test_queue_dir"
|
||||
export QUEUE_DIR="$test_queue_dir"
|
||||
trap 'rm -f "$STUB"; rm -rf "$test_queue_dir"' EXIT
|
||||
|
||||
awk '/^log "bend-supervisor-dlq-runner start/ {exit} {print}' "$DLQ_RUNNER" > "$STUB"
|
||||
# Drop EXIT/INT/TERM trap installers - they would interfere.
|
||||
sed -i '/^trap on_exit EXIT$/d; /^trap .EXIT_REASON=signal-int/d; /^trap .EXIT_REASON=signal-term/d' "$STUB"
|
||||
|
||||
# shellcheck source=/dev/null
|
||||
. "$STUB" 2>/dev/null
|
||||
|
||||
fails=0
|
||||
pass() { echo "PASS $*"; }
|
||||
fail() { echo "FAIL $*"; fails=$((fails + 1)); }
|
||||
|
||||
# assert_classify <label> <reason-text> <expected-class> <expected-stage> <expected-salvage>
|
||||
assert_classify() {
|
||||
local label="$1" content="$2" exp_class="$3" exp_stage="$4" exp_salv="$5"
|
||||
local f="$test_queue_dir/dlq/__test_$$_$RANDOM.reason"
|
||||
printf '%s' "$content" > "$f"
|
||||
local got
|
||||
got=$(classify_reason "$f")
|
||||
local got_class got_stage got_salv
|
||||
got_class=$(echo "$got" | awk '{print $1}')
|
||||
got_stage=$(echo "$got" | awk '{print $2}')
|
||||
got_salv=$(echo "$got" | awk '{print $3}')
|
||||
if [ "$got_class" = "$exp_class" ] && [ "$got_stage" = "$exp_stage" ] && [ "$got_salv" = "$exp_salv" ]; then
|
||||
pass "$label -> $got_class/$got_stage/$got_salv"
|
||||
else
|
||||
fail "$label expected=$exp_class/$exp_stage/$exp_salv got=$got_class/$got_stage/$got_salv"
|
||||
fi
|
||||
rm -f "$f"
|
||||
}
|
||||
|
||||
echo "── classifier tests ──"
|
||||
|
||||
assert_classify "cuda-oom (typed Phase 7 portal)" \
|
||||
'(cuda-sim-error (code cuda-oom) (reason "out of memory at sim_gpu_packed.cu:179"))' \
|
||||
"cuda-oom" "sim" "yes"
|
||||
|
||||
assert_classify "cuda-oom (legacy stderr leak)" \
|
||||
'# dispatch foo.bin (0.5 GB) × 141 batches
|
||||
wall_s=42
|
||||
raw_resp=(error (no-portal "/tmp/bend-sim-ops-X.portal"))
|
||||
--- bend worker log context ---
|
||||
CUDA error cudaMalloc(&c->d_bits, ...) at sim_gpu_packed.cu:179: out of memory' \
|
||||
"cuda-oom" "sim" "yes"
|
||||
|
||||
assert_classify "cuda-illegal-addr (typed)" \
|
||||
'(cuda-sim-error (code cuda-illegal-addr) (reason "..."))' \
|
||||
"cuda-illegal-addr" "sim" "yes"
|
||||
|
||||
assert_classify "memory-cap-refused" \
|
||||
'(cuda-sim-error (code memory-cap-refused) (reason "peak host+device memory ~22.4 GB exceeds 16 GB cap"))' \
|
||||
"memory-cap-refused" "sim" "yes"
|
||||
|
||||
assert_classify "vram-budget-refused (Phase 8 fast-fail)" \
|
||||
'(cuda-sim-error (code vram-budget-refused) (reason "predicted 5.4 GB > free 1.2 GB at sim_gpu_packed_alloc; reduce n_batches"))' \
|
||||
"vram-budget-refused" "sim" "yes"
|
||||
|
||||
assert_classify "bin-load-fail" \
|
||||
'(cuda-sim-error (code bin-load-fail) (reason "load_ops_bin returned non-zero"))' \
|
||||
"bin-load-fail" "emit" "no"
|
||||
|
||||
assert_classify "no-portal (pre-Phase-7)" \
|
||||
'raw_resp=(error (no-portal "/tmp/bend-sim-ops-X.portal"))' \
|
||||
"no-portal" "sim" "yes"
|
||||
|
||||
# Note: queue path inside reason body deliberately reads /tmp/lumbda-queue
|
||||
# (CONTRACT default) - classifier matches on `missing:` prefix not on path
|
||||
# substring, so this reads cleanly under upstream's generic env.
|
||||
assert_classify "missing-bin" \
|
||||
'missing: /tmp/lumbda-queue/foo-cell.bin' \
|
||||
"missing-bin" "dispatch" "yes"
|
||||
|
||||
assert_classify "tier=zero-magic (emit interrupted)" \
|
||||
'tier=zero-magic bin_bytes=146800656
|
||||
reason: emit-interrupted' \
|
||||
"emit-broken" "emit" "no"
|
||||
|
||||
assert_classify "tier=zero-n_ops (emit-finalize never fired)" \
|
||||
'tier=zero-n_ops bin_bytes=146800656
|
||||
reason: emit-finalize-never-fired' \
|
||||
"emit-broken" "emit" "no"
|
||||
|
||||
assert_classify "tier=above-max" \
|
||||
'tier=above-max bin_bytes=8144699544
|
||||
reason: bin-too-large (over tier-huge max ...)' \
|
||||
"tier-classify" "emit" "no"
|
||||
|
||||
assert_classify "BISECT-DIAG (pool finalize race)" \
|
||||
'BISECT-DIAG: ipmul step6 swap target<->tmp
|
||||
BISECT-DIAG: ipmul step7 free tmp
|
||||
BISECT-DIAG: exit dgcd-raw-pa! dispatch arm' \
|
||||
"bisect-pool-race" "emit" "no"
|
||||
|
||||
assert_classify "transient cuda-error (catchall typed)" \
|
||||
'(cuda-sim-error (code cuda-error) (reason "transient driver"))' \
|
||||
"cuda-error-transient" "sim" "yes"
|
||||
|
||||
assert_classify "unknown (catchall)" \
|
||||
'completely unrelated text' \
|
||||
"unknown" "unknown" "no"
|
||||
|
||||
# 2026-06-13: empty / whitespace-only .reason files used to land in
|
||||
# "unknown" class + rDLQ. Now classified as transient-empty-reason
|
||||
# (auto-retry up to AUTO_RETRY_MAX before escalating). Catches our
|
||||
# dispatcher socket-disconnect / minimal-output failure mode. Detection
|
||||
# stays whitespace-aware so short-but-meaningful reasons (like
|
||||
# "missing: ...foo.bin") never get swept into transient class.
|
||||
assert_classify "empty .reason (0 bytes)" \
|
||||
'' \
|
||||
"transient-empty-reason" "sim" "yes"
|
||||
|
||||
assert_classify "whitespace-only .reason" \
|
||||
'
|
||||
|
||||
' \
|
||||
"transient-empty-reason" "sim" "yes"
|
||||
|
||||
# Class-set checks - partitioning by is_auto_class / is_escalate_class
|
||||
# must cover every named class exactly once.
|
||||
echo ""
|
||||
echo "── class-partition tests ──"
|
||||
|
||||
for c in missing-bin bisect-pool-race cuda-error-transient no-portal transient-empty-reason; do
|
||||
if is_auto_class "$c" && ! is_escalate_class "$c"; then
|
||||
pass "$c is auto (not escalate)"
|
||||
else
|
||||
fail "$c partitioning wrong"
|
||||
fi
|
||||
done
|
||||
|
||||
for c in cuda-oom cuda-illegal-addr memory-cap-refused vram-budget-refused bin-load-fail tier-classify emit-broken unknown; do
|
||||
if is_escalate_class "$c" && ! is_auto_class "$c"; then
|
||||
pass "$c is escalate (not auto)"
|
||||
else
|
||||
fail "$c partitioning wrong"
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
if [ "$fails" -gt 0 ]; then
|
||||
echo "[dlq-runner-classify] FAIL - $fails assertion(s) failed"
|
||||
exit 1
|
||||
fi
|
||||
echo "[dlq-runner-classify] PASS - all assertions OK"
|
||||
exit 0
|
||||
53
tests/sweep-doctrine/README.md
Normal file
53
tests/sweep-doctrine/README.md
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
# sweep-doctrine — TCRAUDT reducer library
|
||||
|
||||
**Test-Case Reducers Are Underappreciated Debugging Tools.** Source: Laurie Tratt 2026-01 essay, building on Zeller delta-debugging + Regehr C-reducer. Reduce a failing probe to a 5-line test, commit it as institutional memory. Tests outlive every sweep agent's working memory.
|
||||
|
||||
## Discipline
|
||||
|
||||
5 phases (mandatory for every defect):
|
||||
|
||||
1. **Reproduce** at full production scope first — verify defect, capture failing output.
|
||||
2. **Reduce** inputs to smallest case that still triggers — peel flags, widths, iters, params.
|
||||
3. **Commit reducer to `tests/sweep-doctrine/`** — NEVER in `runs/` (runs/ stays ephemeral; reducers outlive sweeps).
|
||||
4. **Fix** defect.
|
||||
5. **Verify** reducer flips EXPECTED-FAIL → PASS. `make sweep-doctrine` exits 0.
|
||||
|
||||
## Test format
|
||||
|
||||
Each `test-*.lsp` prints exactly one verdict line:
|
||||
|
||||
```
|
||||
DOCTRINE <name> PASS got=<actual> expected=<expected>
|
||||
DOCTRINE <name> EXPECTED-FAIL got=<actual> expected=<expected> ; known open defect, alarm acknowledged
|
||||
DOCTRINE <name> FAIL got=<actual> expected=<expected> ; regression — gate fails
|
||||
```
|
||||
|
||||
Shared helpers in `lib.lsp` — assertions, fresh-register builders, expected-truth-table comparators.
|
||||
|
||||
## Runner
|
||||
|
||||
```bash
|
||||
make -C ~/git/lumbda sweep-doctrine # local, all reducers
|
||||
make -C ~/git/lumbda sweep-doctrine REMOTE=user@host # via SSH
|
||||
```
|
||||
|
||||
Backed by `run.sh` (serial) + `run-parallel.sh` (xargs -P fan-out).
|
||||
|
||||
## When reduction stops shrinking, escalate
|
||||
|
||||
Reducer that pins a trigger pair without root cause = building on sand — gate stays green while wrong-output ships. Escalate:
|
||||
|
||||
- **Unit** — fire suspect primitive on register state mimicking callsite geometry. Compare bits to reference.
|
||||
- **Integration** — emit one iter of suspect operation, read every intermediate register. First divergent register names broken step.
|
||||
- **Functional** — full operation at small width with `record-register-state!` at iter N. Iter where divergence first appears names failure mode.
|
||||
|
||||
Reducer = alarm. EXPECTED-FAIL = alarm acknowledged, not silenced. Don't promote any change touching a primitive whose reducer stays EXPECTED-FAIL.
|
||||
|
||||
## Hard rules
|
||||
|
||||
- Reducers belong in `tests/sweep-doctrine/`; ephemeral sweep artifacts (probe scratch, dispatched cells, per-sweep RESULTS) belong in consumer-side `runs/` directories. Never invert.
|
||||
- `make sweep-doctrine` exit 0 iff every test produced expected outcome (PASS for positive controls, EXPECTED-FAIL for known open defects).
|
||||
|
||||
## License
|
||||
|
||||
AGPLv3.
|
||||
73
tests/sweep-doctrine/lib.lsp
Normal file
73
tests/sweep-doctrine/lib.lsp
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
;;; sweep-doctrine/lib.lsp - shared harness for every doctrine reducer.
|
||||
;;;
|
||||
;;; Upstream lumbda hosts a generic helper layer: assertion macro,
|
||||
;;; fresh-register builders, single-line verdict printer. Domain repos
|
||||
;;; (foxhop ecdsa) extend with primitives that load domain substrate &
|
||||
;;; baseline flag-set; those live under $LUMBDA_DOMAIN_DIR/tests/sweep-doctrine/.
|
||||
;;;
|
||||
;;; AGPLv3.
|
||||
|
||||
;; Each reducer prints exactly one line:
|
||||
;;
|
||||
;; DOCTRINE <name> PASS got=<actual> expected=<expected>
|
||||
;; DOCTRINE <name> EXPECTED-FAIL got=<actual> expected=<expected>
|
||||
;; DOCTRINE <name> FAIL got=<actual> expected=<expected>
|
||||
;; DOCTRINE <name> UNEXPECTED-PASS got=<actual> expected=<expected>
|
||||
;;
|
||||
;; Runner gates on third-token verdicts only; data lines stay free.
|
||||
|
||||
(define (doctrine-report name expected-pass? got expected)
|
||||
"One-line summary. expected-pass? #t = positive control (PASS on match).
|
||||
#f = open defect alarm (EXPECTED-FAIL on mismatch, UNEXPECTED-PASS on match)."
|
||||
(let* ((match? (equal? got expected))
|
||||
(status (cond
|
||||
((and expected-pass? match?) "PASS")
|
||||
((and expected-pass? (not match?)) "FAIL")
|
||||
((and (not expected-pass?) match?) "UNEXPECTED-PASS")
|
||||
(else "EXPECTED-FAIL"))))
|
||||
(display "DOCTRINE ") (display name)
|
||||
(display " ") (display status)
|
||||
(display " got=") (display got)
|
||||
(display " expected=") (display expected)
|
||||
(newline)
|
||||
(or (and expected-pass? match?)
|
||||
(and (not expected-pass?) (not match?)))))
|
||||
|
||||
;; assertion shorthand - returns #t on equality, #f otherwise. Reducer authors
|
||||
;; combine several `doctrine-assert-eq` results then call `doctrine-report` once.
|
||||
(define (doctrine-assert-eq label got expected)
|
||||
"Print one data line per check; return #t on match. Use for multi-axis reducers
|
||||
where many rows fan out under one summary verdict."
|
||||
(let ((match? (equal? got expected)))
|
||||
(display " ") (display label)
|
||||
(display " got=") (display got)
|
||||
(display " expected=") (display expected)
|
||||
(display " ") (display (if match? "OK" "MISS"))
|
||||
(newline)
|
||||
match?))
|
||||
|
||||
;; mod-expt - square-and-multiply over a prime.
|
||||
;; Required because `(expt a e)` builds |a|^|e| BEFORE applying modulo;
|
||||
;; at p near 2^31 & e = p-2 a literal expansion hangs lumbda. Six
|
||||
;; reducer instances stacked indefinitely (oldest 13h 58m) until this
|
||||
;; helper surfaced 2026-06-11.
|
||||
(define (mod-expt base exp p)
|
||||
(let loop ((b (modulo base p)) (e exp) (acc 1))
|
||||
(cond
|
||||
((= e 0) acc)
|
||||
((odd? e) (loop (modulo (* b b) p) (quotient e 2) (modulo (* acc b) p)))
|
||||
(else (loop (modulo (* b b) p) (quotient e 2) acc)))))
|
||||
|
||||
;; Domain substrate loader. If LUMBDA_DOMAIN_DIR points at a consumer
|
||||
;; repo (foxhop ecdsa) AND that domain ships a `tests/sweep-doctrine/domain-lib.lsp`
|
||||
;; file, load it for domain-specific helpers (baseline flag-sets,
|
||||
;; circuit builders, expected-truth-table comparators).
|
||||
;;
|
||||
;; Pattern: upstream reducers stay primitive-level; domain reducers
|
||||
;; live downstream & import upstream's lib + their own domain-lib.
|
||||
;;
|
||||
;; Note: getenv may or may not exist in every lumbda tier. Guard with
|
||||
;; load-time check so a missing primitive never crashes a reducer that
|
||||
;; ignores domain-lib entirely.
|
||||
;; Disabled by default - domain reducers explicitly `(load "tests/sweep-doctrine/domain-lib.lsp")`
|
||||
;; from their own $LUMBDA_DOMAIN_DIR cwd. Upstream lib.lsp stays minimal.
|
||||
159
tests/sweep-doctrine/run-parallel.sh
Executable file
159
tests/sweep-doctrine/run-parallel.sh
Executable file
|
|
@ -0,0 +1,159 @@
|
|||
#!/usr/bin/env bash
|
||||
# sweep-doctrine/run-parallel.sh - same contract as run.sh but xargs -P
|
||||
# fan-out over independent reducers.
|
||||
#
|
||||
# Each reducer is independent (no shared state across processes) so
|
||||
# parallelism is safe. Wall-clock improvement on a champion-class
|
||||
# verify: ~89 reducers × ~30s serial → ~30s + slowest reducer wall
|
||||
# at JOBS=16.
|
||||
#
|
||||
# Usage:
|
||||
# ./run-parallel.sh # all reducers, JOBS=16
|
||||
# ./run-parallel.sh TEST=... # one reducer (no fan-out needed)
|
||||
# JOBS=8 ./run-parallel.sh # cap parallelism (lower = lighter REMOTE load)
|
||||
# SLOW=1 ./run-parallel.sh # include reducers tagged ";;; SLOW-TIER"
|
||||
#
|
||||
# Env contract per ~/git/lumbda/factory/CONTRACT.md:
|
||||
# LUMBDA_REMOTE / REMOTE SSH target. Unset = run locally.
|
||||
# LUMBDA_BIN lumbda binary (default $HOME/git/lumbda/c/lumbda).
|
||||
# LUMBDA_REPO_DIR repo root for cd (default $HOME/git/lumbda).
|
||||
# LUMBDA_DOMAIN_DIR optional consumer repo (no default).
|
||||
set -u
|
||||
|
||||
REMOTE="${LUMBDA_REMOTE:-${REMOTE:-}}"
|
||||
LUMBDA_BIN="${LUMBDA_BIN:-$HOME/git/lumbda/c/lumbda}"
|
||||
LUMBDA_REPO_DIR="${LUMBDA_REPO_DIR:-$HOME/git/lumbda}"
|
||||
LUMBDA_DOMAIN_DIR="${LUMBDA_DOMAIN_DIR:-}"
|
||||
JOBS="${JOBS:-16}"
|
||||
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
# Resolve reducer set. SLOW=1 includes reducers whose header carries
|
||||
# ";;; SLOW-TIER" marker; default skips them so fast-path stays under
|
||||
# ~5 min wall-clock.
|
||||
upstream_tests=()
|
||||
domain_tests=()
|
||||
if [ -n "${TEST:-}" ]; then
|
||||
if [ -f "$HERE/${TEST}.lsp" ]; then
|
||||
upstream_tests=( "$HERE/${TEST}.lsp" )
|
||||
elif [ -n "$LUMBDA_DOMAIN_DIR" ] && [ -f "$LUMBDA_DOMAIN_DIR/tests/sweep-doctrine/${TEST}.lsp" ]; then
|
||||
domain_tests=( "$LUMBDA_DOMAIN_DIR/tests/sweep-doctrine/${TEST}.lsp" )
|
||||
else
|
||||
echo "ERROR: TEST=$TEST not found upstream or under \$LUMBDA_DOMAIN_DIR"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
if [ "${SLOW:-0}" = "1" ]; then
|
||||
while IFS= read -r f; do upstream_tests+=( "$f" ); done < <(ls "$HERE"/test-*.lsp 2>/dev/null)
|
||||
if [ -n "$LUMBDA_DOMAIN_DIR" ] && [ -d "$LUMBDA_DOMAIN_DIR/tests/sweep-doctrine" ]; then
|
||||
while IFS= read -r f; do domain_tests+=( "$f" ); done < <(ls "$LUMBDA_DOMAIN_DIR/tests/sweep-doctrine"/test-*.lsp 2>/dev/null)
|
||||
fi
|
||||
else
|
||||
while IFS= read -r f; do upstream_tests+=( "$f" ); done < <(grep -L "^;;; SLOW-TIER" "$HERE"/test-*.lsp 2>/dev/null)
|
||||
if [ -n "$LUMBDA_DOMAIN_DIR" ] && [ -d "$LUMBDA_DOMAIN_DIR/tests/sweep-doctrine" ]; then
|
||||
while IFS= read -r f; do domain_tests+=( "$f" ); done < <(grep -L "^;;; SLOW-TIER" "$LUMBDA_DOMAIN_DIR/tests/sweep-doctrine"/test-*.lsp 2>/dev/null)
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
total_count=$(( ${#upstream_tests[@]} + ${#domain_tests[@]} ))
|
||||
[ "$total_count" -gt 0 ] || { echo "ERROR: no test-*.lsp found in $HERE${LUMBDA_DOMAIN_DIR:+ or $LUMBDA_DOMAIN_DIR/tests/sweep-doctrine}"; exit 1; }
|
||||
|
||||
# Single-reducer path: defer to serial runner - fan-out has no payoff.
|
||||
if [ "$total_count" -eq 1 ]; then
|
||||
exec env TEST="${TEST:-}" REMOTE="$REMOTE" LUMBDA_REPO_DIR="$LUMBDA_REPO_DIR" \
|
||||
LUMBDA_DOMAIN_DIR="$LUMBDA_DOMAIN_DIR" LUMBDA_BIN="$LUMBDA_BIN" \
|
||||
"$HERE/run.sh"
|
||||
fi
|
||||
|
||||
# Remote auto-pull (same discipline as run.sh).
|
||||
if [ -n "$REMOTE" ]; then
|
||||
pull_out=$(ssh "$REMOTE" "cd $LUMBDA_REPO_DIR; git pull --ff-only origin master 2>&1" 2>&1)
|
||||
echo "$pull_out" | sed 's/^/[remote-pull-upstream] /'
|
||||
if echo "$pull_out" | grep -qE "^(error|Aborting|fatal:)"; then
|
||||
echo "[remote-pull-upstream] HARD ABORT - pull failed; reducers not run."
|
||||
exit 1
|
||||
fi
|
||||
if [ -n "$LUMBDA_DOMAIN_DIR" ] && [ "${#domain_tests[@]}" -gt 0 ]; then
|
||||
domain_repo="$(cd "$LUMBDA_DOMAIN_DIR/.." 2>/dev/null && pwd || echo "$LUMBDA_DOMAIN_DIR")"
|
||||
pull_out=$(ssh "$REMOTE" "cd $domain_repo; git pull --ff-only origin master 2>&1" 2>&1)
|
||||
echo "$pull_out" | sed 's/^/[remote-pull-domain] /'
|
||||
if echo "$pull_out" | grep -qE "^(error|Aborting|fatal:)"; then
|
||||
echo "[remote-pull-domain] HARD ABORT - pull failed; reducers not run."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
WORKDIR=$(mktemp -d -t sweep-doctrine-parallel.XXXXXX)
|
||||
trap 'rm -rf "$WORKDIR"' EXIT
|
||||
|
||||
echo "[run-parallel] $(date -u +%H:%M:%S) launching $total_count reducer(s) with JOBS=$JOBS"
|
||||
|
||||
# Worker: run one reducer, grep DOCTRINE lines, write per-test file.
|
||||
# Exit codes:
|
||||
# 0 = at least one DOCTRINE line, no unexpected verdicts
|
||||
# 1 = unexpected FAIL / UNEXPECTED-PASS / HARNESS-FAIL
|
||||
# 2 = no DOCTRINE line (harness fail)
|
||||
run_one() {
|
||||
local t="$1" repo_root="$2" workdir="$3"
|
||||
local name rel out cmd
|
||||
name="$(basename "$t" .lsp)"
|
||||
rel="tests/sweep-doctrine/${name}.lsp"
|
||||
cmd="cd $repo_root; $LUMBDA_BIN --fast $rel 2>&1"
|
||||
if [ -n "$REMOTE" ]; then
|
||||
out=$(ssh "$REMOTE" "$cmd" 2>&1 | grep -E "^DOCTRINE ")
|
||||
else
|
||||
out=$(bash -c "$cmd" 2>&1 | grep -E "^DOCTRINE ")
|
||||
fi
|
||||
if [ -z "$out" ]; then
|
||||
echo "DOCTRINE $name HARNESS-FAIL (no DOCTRINE line emitted)" > "$workdir/$name.out"
|
||||
return 2
|
||||
fi
|
||||
echo "$out" > "$workdir/$name.out"
|
||||
if echo "$out" | grep -qE "DOCTRINE [^ ]+ (FAIL|UNEXPECTED-PASS|HARNESS-FAIL)( |$)"; then
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
export -f run_one
|
||||
export REMOTE LUMBDA_REPO_DIR LUMBDA_BIN LUMBDA_DOMAIN_DIR
|
||||
|
||||
# Build per-test (path, repo_root) pairs. xargs -0 -n2 reads two
|
||||
# null-delimited tokens per worker invocation.
|
||||
{
|
||||
for t in "${upstream_tests[@]}"; do printf "%s\0%s\0" "$t" "$LUMBDA_REPO_DIR"; done
|
||||
for t in "${domain_tests[@]}"; do printf "%s\0%s\0" "$t" "$LUMBDA_DOMAIN_DIR"; done
|
||||
} | xargs -0 -P "$JOBS" -n2 bash -c 'run_one "$1" "$2" "'"$WORKDIR"'"' _
|
||||
|
||||
# Collect outputs in lexicographic order (deterministic).
|
||||
overall_rc=0
|
||||
failed_tests=()
|
||||
collect_one() {
|
||||
local t="$1" name
|
||||
name="$(basename "$t" .lsp)"
|
||||
if [ -f "$WORKDIR/$name.out" ]; then
|
||||
cat "$WORKDIR/$name.out"
|
||||
# Same field-anchored + summary-aware verdict logic as run.sh.
|
||||
if awk '
|
||||
$1=="DOCTRINE" && $2~/-summary$/ && $3=="EXPECTED-FAIL" {expected_fail=1}
|
||||
$1=="DOCTRINE" && $3~/^(FAIL|UNEXPECTED-PASS|HARNESS-FAIL)$/ {found=1}
|
||||
END{ exit !(found && !expected_fail) }
|
||||
' "$WORKDIR/$name.out"; then
|
||||
overall_rc=1
|
||||
failed_tests+=("$name")
|
||||
fi
|
||||
else
|
||||
echo "DOCTRINE $name HARNESS-FAIL (no output file - worker crashed)"
|
||||
overall_rc=1
|
||||
failed_tests+=("$name (missing output)")
|
||||
fi
|
||||
}
|
||||
for t in "${upstream_tests[@]}"; do collect_one "$t"; done
|
||||
for t in "${domain_tests[@]}"; do collect_one "$t"; done
|
||||
|
||||
echo "[run-parallel] $(date -u +%H:%M:%S) done; exit=$overall_rc"
|
||||
if [ "$overall_rc" -ne 0 ]; then
|
||||
echo "[run-parallel] failed reducers (${#failed_tests[@]}):"
|
||||
printf " - %s\n" "${failed_tests[@]}"
|
||||
fi
|
||||
exit "$overall_rc"
|
||||
126
tests/sweep-doctrine/run.sh
Executable file
126
tests/sweep-doctrine/run.sh
Executable file
|
|
@ -0,0 +1,126 @@
|
|||
#!/usr/bin/env bash
|
||||
# sweep-doctrine/run.sh - runs every doctrine reducer serially.
|
||||
#
|
||||
# Discovers `tests/sweep-doctrine/test-*.lsp` in this repo. If
|
||||
# $LUMBDA_DOMAIN_DIR points at a consumer repo (e.g. foxhop ecdsa) that
|
||||
# also ships reducers under `$LUMBDA_DOMAIN_DIR/tests/sweep-doctrine/`,
|
||||
# discovers + runs those too.
|
||||
#
|
||||
# Greps DOCTRINE lines, exits non-zero on any unexpected FAIL /
|
||||
# UNEXPECTED-PASS / HARNESS-FAIL.
|
||||
#
|
||||
# Usage:
|
||||
# ./run.sh # all reducers
|
||||
# ./run.sh TEST=test-some-lesson # one reducer by name
|
||||
# REMOTE=user@host ./run.sh # via SSH
|
||||
# LUMBDA_BIN=/path/to/lumbda ./run.sh # override tier binary
|
||||
# LUMBDA_DOMAIN_DIR=$HOME/git/foo ./run.sh # also run consumer reducers
|
||||
#
|
||||
# Env contract per ~/git/lumbda/factory/CONTRACT.md:
|
||||
# LUMBDA_REMOTE / REMOTE SSH target. Unset = run locally.
|
||||
# LUMBDA_BIN lumbda binary (default $HOME/git/lumbda/c/lumbda).
|
||||
# LUMBDA_REPO_DIR repo root for remote cd (default $HOME/git/lumbda).
|
||||
# LUMBDA_DOMAIN_DIR optional consumer repo (no default).
|
||||
set -u
|
||||
|
||||
REMOTE="${LUMBDA_REMOTE:-${REMOTE:-}}"
|
||||
LUMBDA_BIN="${LUMBDA_BIN:-$HOME/git/lumbda/c/lumbda}"
|
||||
LUMBDA_REPO_DIR="${LUMBDA_REPO_DIR:-$HOME/git/lumbda}"
|
||||
LUMBDA_DOMAIN_DIR="${LUMBDA_DOMAIN_DIR:-}"
|
||||
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
# Resolve which reducers to run. TEST=<name> picks one; default = every
|
||||
# test-*.lsp in HERE plus (optionally) every test-*.lsp under
|
||||
# $LUMBDA_DOMAIN_DIR/tests/sweep-doctrine/.
|
||||
upstream_tests=()
|
||||
domain_tests=()
|
||||
if [ -n "${TEST:-}" ]; then
|
||||
# Single-test mode: prefer upstream, fall back to domain.
|
||||
if [ -f "$HERE/${TEST}.lsp" ]; then
|
||||
upstream_tests=( "$HERE/${TEST}.lsp" )
|
||||
elif [ -n "$LUMBDA_DOMAIN_DIR" ] && [ -f "$LUMBDA_DOMAIN_DIR/tests/sweep-doctrine/${TEST}.lsp" ]; then
|
||||
domain_tests=( "$LUMBDA_DOMAIN_DIR/tests/sweep-doctrine/${TEST}.lsp" )
|
||||
else
|
||||
echo "ERROR: TEST=$TEST not found upstream or under \$LUMBDA_DOMAIN_DIR"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
while IFS= read -r f; do upstream_tests+=( "$f" ); done < <(ls "$HERE"/test-*.lsp 2>/dev/null)
|
||||
if [ -n "$LUMBDA_DOMAIN_DIR" ] && [ -d "$LUMBDA_DOMAIN_DIR/tests/sweep-doctrine" ]; then
|
||||
while IFS= read -r f; do domain_tests+=( "$f" ); done < <(ls "$LUMBDA_DOMAIN_DIR/tests/sweep-doctrine"/test-*.lsp 2>/dev/null)
|
||||
fi
|
||||
fi
|
||||
|
||||
total_count=$(( ${#upstream_tests[@]} + ${#domain_tests[@]} ))
|
||||
[ "$total_count" -gt 0 ] || { echo "ERROR: no test-*.lsp found in $HERE${LUMBDA_DOMAIN_DIR:+ or $LUMBDA_DOMAIN_DIR/tests/sweep-doctrine}"; exit 1; }
|
||||
|
||||
# Remote auto-pull mirrors bend-emit-pool's merge-to-master discipline:
|
||||
# every reducer that runs here must already live in master. Pre-merge
|
||||
# scp staging is removed (was a recurring defect source - scp'd tests
|
||||
# landed as untracked files on REMOTE then every subsequent pull
|
||||
# aborted on "would overwrite untracked working tree files"). Fail
|
||||
# HARD on pull error so stale-substrate wrong-output surfaces instead
|
||||
# of silently masking.
|
||||
if [ -n "$REMOTE" ]; then
|
||||
pull_out=$(ssh "$REMOTE" "cd $LUMBDA_REPO_DIR; git pull --ff-only origin master 2>&1" 2>&1)
|
||||
echo "$pull_out" | sed 's/^/[remote-pull-upstream] /'
|
||||
if echo "$pull_out" | grep -qE "^(error|Aborting|fatal:)"; then
|
||||
echo "[remote-pull-upstream] HARD ABORT - pull failed; reducers not run."
|
||||
exit 1
|
||||
fi
|
||||
# Domain repo pull (if domain reducers will fire).
|
||||
if [ -n "$LUMBDA_DOMAIN_DIR" ] && [ "${#domain_tests[@]}" -gt 0 ]; then
|
||||
domain_repo="$(cd "$LUMBDA_DOMAIN_DIR/.." 2>/dev/null && pwd || echo "$LUMBDA_DOMAIN_DIR")"
|
||||
pull_out=$(ssh "$REMOTE" "cd $domain_repo; git pull --ff-only origin master 2>&1" 2>&1)
|
||||
echo "$pull_out" | sed 's/^/[remote-pull-domain] /'
|
||||
if echo "$pull_out" | grep -qE "^(error|Aborting|fatal:)"; then
|
||||
echo "[remote-pull-domain] HARD ABORT - pull failed; reducers not run."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Run one reducer. Resolves cwd to repo root so (load "lumbda/...") &
|
||||
# (load "tests/sweep-doctrine/lib.lsp") both succeed. Upstream reducers
|
||||
# run from $LUMBDA_REPO_DIR; domain reducers run from $LUMBDA_DOMAIN_DIR.
|
||||
run_one_local_or_remote() {
|
||||
local t="$1" repo_root="$2"
|
||||
local name rel out cmd
|
||||
name="$(basename "$t" .lsp)"
|
||||
rel="tests/sweep-doctrine/${name}.lsp"
|
||||
cmd="cd $repo_root; $LUMBDA_BIN --fast $rel 2>&1"
|
||||
if [ -n "$REMOTE" ]; then
|
||||
out=$(ssh "$REMOTE" "$cmd" 2>&1 | grep -E "^DOCTRINE ")
|
||||
else
|
||||
out=$(bash -c "$cmd" 2>&1 | grep -E "^DOCTRINE ")
|
||||
fi
|
||||
if [ -z "$out" ]; then
|
||||
echo "DOCTRINE $name HARNESS-FAIL (no DOCTRINE line emitted)"
|
||||
return 2
|
||||
fi
|
||||
echo "$out"
|
||||
# Field-anchored verdict scan. Only bare FAIL / UNEXPECTED-PASS /
|
||||
# HARNESS-FAIL in third token position gates a run. Data-line tokens
|
||||
# like EXPECTED-FAIL or ARITH-FAIL never gate.
|
||||
#
|
||||
# Summary-aware override: if any DOCTRINE line whose second token
|
||||
# ends in "-summary" emits EXPECTED-FAIL, per-row FAILs are
|
||||
# documented-defect evidence (not regression) & do not gate.
|
||||
if echo "$out" | awk '
|
||||
$1=="DOCTRINE" && $2~/-summary$/ && $3=="EXPECTED-FAIL" {expected_fail=1}
|
||||
$1=="DOCTRINE" && $3~/^(FAIL|UNEXPECTED-PASS|HARNESS-FAIL)$/ {found=1}
|
||||
END{ exit !(found && !expected_fail) }
|
||||
'; then
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
overall_rc=0
|
||||
for t in "${upstream_tests[@]}"; do
|
||||
run_one_local_or_remote "$t" "$LUMBDA_REPO_DIR" || overall_rc=1
|
||||
done
|
||||
for t in "${domain_tests[@]}"; do
|
||||
run_one_local_or_remote "$t" "$LUMBDA_DOMAIN_DIR" || overall_rc=1
|
||||
done
|
||||
exit "$overall_rc"
|
||||
Loading…
Add table
Add a link
Reference in a new issue