diff --git a/tooling/planet-gen/gemma_naming.py b/tooling/planet-gen/gemma_naming.py index 6855e3933..9672bbf5e 100755 --- a/tooling/planet-gen/gemma_naming.py +++ b/tooling/planet-gen/gemma_naming.py @@ -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 -- ` + # 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)