feat(tooling): out-of-worktree sr-voice discovery + distrobox wrap + prompt capture (#833)

Three additions unlocked by the gfx1201 ROCm debug session.

1. _find_sr_voice() resolves the default binary path to
   ~/Projects/settled-reach/binaries/sr-voice-rocm (persistent across
   worktree lifetimes) with a legacy fallback to the main workdir's
   cargo target dir. Matches #850's plan to ship platform binaries
   outside the repo.

2. --distrobox <name> wraps the sr-voice subprocess in
   `distrobox enter <name> --` when the built binary depends on libs
   that only exist inside a dev container (libhipblas.so.2 on a
   Bazzite host). Stdio JSONL protocol flows through unchanged.

3. --dump-prompts PATH captures the attempt-0 prompt for every
   feature as JSONL without calling an LLM. Force --mock and
   short-circuit name_feature to return a unique deterministic
   placeholder. Used to feed the same prompt set to alternate
   backends (Haiku agent, other models) for offline A/B comparison
   of naming quality independent of the sampling backend.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-15 23:11:28 +02:00
co-authored by Claude Opus 4.6
parent cf8a4e37d8
commit c089557d65
+118 -7
View File
@@ -65,11 +65,51 @@ DB_PATH = REPO_ROOT / "server" / "data" / "systems.db"
WIKI_SYSTEMS = REPO_ROOT / "wiki" / "star-systems"
BLOCKLIST_PATH = TOOLING_DIR / "earth_blocklist.txt"
# Default binary + model paths point at the main workdir (sibling worktree
# where the sr-voice binary and gemma2.gguf live). Override via --sr-voice
# / --model if your layout differs.
# When --dump-prompts is set, name_feature short-circuits: it builds the
# attempt-0 prompt for each feature, writes a JSONL line to this file,
# and returns a unique deterministic placeholder so the pipeline runs to
# completion without touching a real LLM. Lets us capture the exact prompt
# set any backend (Gemma CPU, Gemma GPU, Haiku) would receive on first
# attempt — so an offline backend can replay them and the resulting names
# can be A/B compared.
_CAPTURE_FILE = None # set in main() when --dump-prompts is used
# Default binary + model paths. The sr-voice binary is platform-specific
# (GPU backend baked in per-build) and lives OUTSIDE any git worktree so
# it survives sprint-worktree cleanup:
#
# ~/Projects/settled-reach/binaries/sr-voice-rocm (AMD / ROCm)
# ~/Projects/settled-reach/binaries/sr-voice-cuda (NVIDIA, future)
# ~/Projects/settled-reach/binaries/sr-voice-vulkan (cross-vendor, future)
# ~/Projects/settled-reach/binaries/sr-voice-cpu (fallback)
#
# See #850 for the multi-backend release-binary ticket. The container
# used to build these is created via the distrobox recipe documented in
# the sr-voice README.
#
# The Gemma 2 model weights live under main/server/models and are shared
# across worktrees (too large to duplicate).
HOME_PROJECTS = Path.home() / "Projects" / "settled-reach"
BINARIES_DIR = HOME_PROJECTS / "binaries"
MAIN_WORKDIR = Path("/var/mnt/data/projects/settled-reach/main")
DEFAULT_SR_VOICE = MAIN_WORKDIR / "server" / "sr-voice" / "target" / "release" / "sr-voice"
def _find_sr_voice() -> Path:
"""Resolve the default sr-voice binary path.
Preference order:
1. $HOME/Projects/settled-reach/binaries/sr-voice-rocm — persistent
across worktree lifetimes, the canonical dev location.
2. main workdir's target/release/sr-voice — legacy, for
backward-compatibility with older layouts.
"""
rocm_bin = BINARIES_DIR / "sr-voice-rocm"
if rocm_bin.exists():
return rocm_bin
return MAIN_WORKDIR / "server" / "sr-voice" / "target" / "release" / "sr-voice"
DEFAULT_SR_VOICE = _find_sr_voice()
DEFAULT_MODEL = MAIN_WORKDIR / "server" / "models" / "gemma2.gguf"
MOCK_STDIO = REPO_ROOT / "server" / "sr-voice" / "mock-stdio.sh"
@@ -1085,12 +1125,14 @@ class VoiceSubprocess:
mock: bool,
refresh_every: int,
verbose: bool,
distrobox: str | None = None,
):
self.sr_voice_bin = sr_voice_bin
self.model_path = model_path
self.mock = mock
self.refresh_every = max(1, refresh_every)
self.verbose = verbose
self.distrobox = distrobox
self.proc: subprocess.Popen | None = None
self.request_count = 0
self._start()
@@ -1098,10 +1140,19 @@ class VoiceSubprocess:
def _build_cmd(self) -> list[str]:
if self.mock:
return [str(MOCK_STDIO), "serve", "--stdio"]
cmd = [str(self.sr_voice_bin), "serve", "--stdio"]
inner_cmd = [str(self.sr_voice_bin), "serve", "--stdio"]
if self.model_path is not None:
cmd += ["--model", str(self.model_path)]
return cmd
inner_cmd += ["--model", str(self.model_path)]
# If a distrobox container was requested, invoke the binary
# inside the container. Needed when the built binary depends on
# libs (e.g. libhipblas.so.2) that only exist in the container,
# not on the host. `distrobox enter <name> -- <command>`
# forwards stdin/stdout through the container's podman exec
# pipe, which is exactly what VoiceSubprocess needs — the
# stdio JSONL protocol flows through unchanged.
if self.distrobox:
return ["distrobox", "enter", self.distrobox, "--"] + inner_cmd
return inner_cmd
def _start(self) -> None:
cmd = self._build_cmd()
@@ -1294,6 +1345,39 @@ def name_feature(
dedup_key = (corridor, feature_type)
used = corpus.setdefault(dedup_key, set())
# Capture mode: build the attempt-0 prompt, log it, return a unique
# placeholder. No LLM call, no validation gauntlet — we want the raw
# prompt set as Gemma would first see it, before retries kick in.
if _CAPTURE_FILE is not None:
prompt = _build_prompt(
feature_type,
inflection=palette["inflection"],
planet_class=planet_class,
body_id=body_id,
local_id=local_id,
attempt=0,
system_hook=system_hook,
system_name=ctx.get("system_proper_name"),
body_name=ctx.get("body_proper_name"),
)
seed = _seed_for(world_seed, body_id, local_id, 0)
_CAPTURE_FILE.write(json.dumps({
"body_id": body_id,
"local_id": local_id,
"feature_type": feature_type,
"corridor": corridor,
"seed": seed,
"prompt": prompt,
}) + "\n")
_CAPTURE_FILE.flush()
# Unique deterministic placeholder. Embeds the seed so every
# call returns a distinct string — passes dedup. Two letters +
# hex keep it short, no _PLACEHOLDER_TOKENS, no length issues.
placeholder = f"Zq{seed:08x}"
used.add(placeholder)
body_used.add(placeholder)
return placeholder
def _is_duplicate(candidate: str) -> bool:
lc = candidate.lower()
return (
@@ -1726,6 +1810,15 @@ def main():
default=str(DEFAULT_MODEL),
help="Path to the Gemma 2 GGUF model (ignored in --mock mode)",
)
parser.add_argument(
"--distrobox",
default=None,
help="Name of a distrobox container to invoke sr-voice inside. "
"Use this when the built binary depends on libraries (e.g. "
"libhipblas.so.2) that are only installed in the container, "
"not on the host. Example: --distrobox reach-build. "
"Default: run sr-voice directly on the host.",
)
parser.add_argument(
"--refresh",
type=int,
@@ -1760,8 +1853,25 @@ def main():
help="Print per-feature retry detail (noisy) and subprocess "
"lifecycle events",
)
parser.add_argument(
"--dump-prompts",
default=None,
help="Capture mode: write the attempt-0 prompt for every feature "
"to this JSONL path and return unique placeholders, without "
"calling any LLM. Use to feed the same prompt set to a "
"different backend (e.g. a Claude agent) for A/B comparison.",
)
args = parser.parse_args()
global _CAPTURE_FILE
if args.dump_prompts:
Path(args.dump_prompts).parent.mkdir(parents=True, exist_ok=True)
_CAPTURE_FILE = open(args.dump_prompts, "w")
# Capture mode short-circuits inside name_feature and never
# actually calls the voice subprocess. Force --mock so we boot
# the cheap mock-stdio shell instead of loading a real model.
args.mock = True
log_path = None if args.log == "-" else Path(args.log)
db_path = Path(args.db)
@@ -1876,6 +1986,7 @@ def main():
mock=args.mock,
refresh_every=args.refresh,
verbose=args.verbose,
distrobox=args.distrobox,
) as voice:
for i, markers_path in enumerate(markers_paths):
body_id, system_id = _body_id_from_path(markers_path)