#!/usr/bin/env python3 """exp15 — Q3 cohesion arm-runner (eval-rig; design §B3/§D2). The backend can't do carryover/lookahead (dead knobs), so Q3 arms are eval-rigs that reuse A0's EXACT prompts + validated mem_select injection, adding ONLY the cohesion context. To keep the contrast carryover-ONLY (no backend-vs-rig confound), the rig generates BOTH a rig-A0 baseline (no context) and the Q3 arms; rig-A0 is validated to fall within the backend flat-A0 <-> flat-A0' noise floor. Modes (§B3 / §D2 Q3a/Q3a'/Q3b): a0 : rig baseline, no cohesion context (bridge to backend flat-A0) q3a-src : + carryover = last K=3 SOURCE sentences of prev chunk (parallelism-safe) q3a-draft : + carryover = prev src tail ∥ prev chunk DRAFT tail (what the wave model gives) q3a-final : + carryover = prev src tail ∥ prev chunk FINAL tail (target-side, sequential pipeline) q3a-state : q3a-final + deterministic scene-state (active entities + their dst + gender from seed) q3b-look : + lookahead = first M=2 SOURCE sentences of NEXT chunk (+ seam-dedup guard in scoring) Prompt assembly mirrors render.go: system(tpl.System + "\n\n" + FewShot) [CacheBoundary] -> system(injection) -> [system(cohesion context, read-only)] -> user(filled). Draft deepseek-v4-flash temp0.3 thinking-ON; editor glm-5 temp0.4 thinking-OFF. Per-call gate + usage/cost ledger (D30.10); resumable (skip existing). """ from __future__ import annotations import argparse import json import re import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) import exp15_llm as L from mem_select import (build_bank, render_glossary_block, render_editor_constraint_block, union_sticky) from chunker import greedy_chunks, split_source_sentences BACKEND = Path("/home/ubuntu/projects/textmachine/backend") S2 = Path("/home/ubuntu/books/gu-zhenren/exp15/S2prime_big_flat.txt") # 30-节 flat slice SEED = "/home/ubuntu/books/gu-zhenren/guzhenren-seed-v2.yaml" OUTDIR = Path("/home/ubuntu/books/gu-zhenren/exp15/arms") # Q3 baseline + carryover source = the REAL backend flat-A0-30 (not a rig-a0). Contrast = carryover-only. BASE_DB = "/home/ubuntu/books/gu-zhenren/exp15/anchors/flat_a0_30.db" BUDGET_TOK = 800 K_CARRY = 3 M_LOOK = 2 BRIEF = {"book_id": "exp15", "title": "蛊真人", "source_lang": "zh", "target_lang": "ru", "genre": "вебновелла", "audience": "взрослые читатели вебновелл", "venuti": "0.60", "honorifics": "keep", "transcription": "palladius", "footnotes": "minimal"} CTX_HEADER = ("КОНТЕКСТ ДЛЯ СВЯЗНОСТИ (предыдущий/следующий фрагмент, ТОЛЬКО ДЛЯ ПОНИМАНИЯ переходов " "и разрешения местоимений/времён/рода — его НЕ переводить и НЕ включать в вывод):") STATE_HEADER = "АКТИВНЫЕ СУЩНОСТИ СЦЕНЫ (канонический перевод и род — для согласования местоимений/времён):" def parse_template(path): raw = Path(path).read_text(encoding="utf-8") parts = raw.split("---USER---", 1) head, user = parts[0], parts[1].strip() hp = head.split("---FEWSHOT---", 1) system = hp[0].strip() fewshot = hp[1].strip() if len(hp) == 2 else "" return {"system": system, "fewshot": fewshot, "user": user} def fill(tpl_text, **extra): vals = dict(BRIEF) vals.update(extra) out, rest = [], tpl_text while True: i = rest.find("{{") if i < 0: out.append(rest); break out.append(rest[:i]); rest = rest[i:] e = rest.find("}}") name = rest[2:e] out.append(vals[name]); rest = rest[e + 2:] return "".join(out) def system_msg(tpl): return tpl["system"] + ("\n\n" + tpl["fewshot"] if tpl["fewshot"] else "") def seed_entities(): import yaml d = yaml.safe_load(Path(SEED).read_text(encoding="utf-8")) return d["terms"] def scene_state_block(chunk_src, terms): """Deterministic scene-state: seed entities present in the chunk, with dst + gender (§B3 state slot).""" lines = [] seen = set() for t in terms: src = t.get("src"); dst = (t.get("dst") or "").strip() if not src or not dst: continue variants = [src] + [a.get("alias") for a in (t.get("aliases") or []) if a.get("alias")] if not any(v in chunk_src for v in variants): continue if t.get("type") not in ("name", "nickname"): continue g = t.get("gender") or "" gtag = {"male": "муж.", "female": "жен.", "hidden": "?", "": ""}.get(g, "") key = dst if key in seen: continue seen.add(key) lines.append(f"- {dst}" + (f" ({gtag} род)" if gtag else "")) return (STATE_HEADER + "\n" + "\n".join(lines)) if lines else "" def tail_sentences(text, k): s = split_source_sentences(text) return "".join(s[-k:]).strip() if s else "" def head_sentences(text, m): s = split_source_sentences(text) return "".join(s[:m]).strip() if s else "" def draft_context(mode, i, chunk_srcs, terms): """SOURCE-side context for the DRAFT/translator (§B3-бис wave 1): src-carryover + scene-state + lookahead. The THREE q3a modes SHARE the src-tail into the draft; they differ only in the editor's target-side.""" blocks = [] if mode.startswith("q3a") and i > 0: blocks.append(CTX_HEADER + f"\n[предыдущий фрагмент, источник]:\n{tail_sentences(chunk_srcs[i-1], K_CARRY)}") if mode == "q3a-state": st = scene_state_block(chunk_srcs[i], terms) if st: blocks.append(st) if mode == "q3b-look" and i < len(chunk_srcs) - 1: blocks.append(CTX_HEADER + f"\n[следующий фрагмент, источник]:\n{head_sentences(chunk_srcs[i+1], M_LOOK)}") return "\n\n".join(blocks) def editor_context(mode, i, drafts, finals): """TARGET-side context for the EDITOR (§B3-бис wave 2): prev neighbor's draft (wave) or final (sequential) tail. q3a-src has NONE (source-carryover baseline); q3b-look targets the draft only.""" if i == 0: return "" if mode == "q3a-draft" and drafts and drafts.get(i - 1): return CTX_HEADER + f"\n[предыдущий фрагмент, черновой перевод соседа]:\n{tail_sentences(drafts[i-1], K_CARRY)}" if mode in ("q3a-final", "q3a-state") and finals and finals.get(i - 1): return CTX_HEADER + f"\n[предыдущий фрагмент, итоговый перевод соседа]:\n{tail_sentences(finals[i-1], K_CARRY)}" return "" def _reject(r): txt = (r.get("text") or "").strip() bad = bool(r.get("err")) or not txt or r.get("finish") not in ("stop", None) return txt, bad def run_q3a(sp, bank, terms, chunk_srcs, base_drafts, base_finals, caps): """Q3a carrying block: ONE shared src-carryover DRAFT per chunk (§B3-бис: source-side -> wave-1 draft), then THREE editors differing ONLY in target-side neighbor context: q3a-src : no target context (source-carryover only) q3a-draft : flat-A0 prev-DRAFT tail (wave / draft-neighbor) q3a-final : flat-A0 prev-FINAL tail (sequential / final-neighbor) Sharing the draft removes the draft-noise confound between the 3 modes. Baseline = flat-A0-30 (backend). Resume-safe (skip non-empty existing files). Neighbors come from flat-A0-30 (parallel wave, no seq dep).""" tr = parse_template(BACKEND / "prompts/translator.md") ed = parse_template(BACKEND / "prompts/editor.md") tr_sys, ed_sys = fill(system_msg(tr)), fill(system_msg(ed)) shared_dir = OUTDIR / "q3a-shared-draft" shared_dir.mkdir(parents=True, exist_ok=True) editor_modes = ["q3a-src", "q3a-draft", "q3a-final"] for m in editor_modes: (OUTDIR / m).mkdir(parents=True, exist_ok=True) active_ids = [] limit = caps.get("max_chunks") or len(chunk_srcs) for i, src in enumerate(chunk_srcs): if i >= limit: break sticky = union_sticky(active_ids, len(active_ids)) sel = bank.select(src, chapter=1, sticky_prev=sticky, budget_tokens=BUDGET_TOK) active_ids.append(sel.active_ids) gloss = render_glossary_block(sel.injected) edconstr = render_editor_constraint_block(sel.injected) dctx = draft_context("q3a-src", i, chunk_srcs, terms) # src-carryover (SHARED across the 3 modes) # --- shared src-carryover draft (deepseek, temp 0.3), generated ONCE --- dpath = shared_dir / f"{i:02d}.draft.txt" if dpath.exists() and dpath.read_text(encoding="utf-8").strip(): draft = dpath.read_text(encoding="utf-8") else: msgs = [{"role": "system", "content": tr_sys}] if gloss: msgs.append({"role": "system", "content": gloss}) if dctx: msgs.append({"role": "system", "content": dctx}) msgs.append({"role": "user", "content": fill(tr["user"], text=src)}) ok, pred, why = sp.gate("deepseek-v4-flash", sum(len(m["content"]) for m in msgs) // 2, caps["draft_max"]) if not ok: print(f" [gate BLOCK] shared draft ch{i}: {why}"); return r = L.call("deepseek-v4-flash", msgs, temp=0.3, max_tokens=caps["draft_max"]) sp.record({"mode": "q3a-shared", "stage": "draft", "chunk": i, "usage": r.get("usage"), "cost": r.get("cost", 0.0), "err": r.get("err"), "finish": r.get("finish")}) draft, bad = _reject(r) if bad: print(f" [BAD DRAFT] ch{i}: err={r.get('err')} finish={r.get('finish')}"); return dpath.write_text(draft, encoding="utf-8") # --- three editors on the shared draft (glm-5, temp 0.4), differing only in target context --- for mode in editor_modes: fpath = OUTDIR / mode / f"{i:02d}.final.txt" if fpath.exists() and fpath.read_text(encoding="utf-8").strip(): continue ectx = editor_context(mode, i, base_drafts, base_finals) # target-side (none/prev-draft/prev-final) emsgs = [{"role": "system", "content": ed_sys}] if edconstr: emsgs.append({"role": "system", "content": edconstr}) if ectx: emsgs.append({"role": "system", "content": ectx}) emsgs.append({"role": "user", "content": fill(ed["user"], text=src, draft=draft)}) ok, pred, why = sp.gate("glm-5", sum(len(m["content"]) for m in emsgs) // 2, caps["edit_max"]) if not ok: print(f" [gate BLOCK] {mode} ch{i} edit: {why}"); return r = L.call("glm-5", emsgs, temp=0.4, max_tokens=caps["edit_max"]) sp.record({"mode": mode, "stage": "edit", "chunk": i, "usage": r.get("usage"), "cost": r.get("cost", 0.0), "err": r.get("err"), "finish": r.get("finish")}) final, bad = _reject(r) if bad: print(f" [BAD FINAL] {mode} ch{i}: err={r.get('err')} finish={r.get('finish')}"); return fpath.write_text(final, encoding="utf-8") print(f" ch{i} {mode}: final {len(final)}c cost cum ${sp.total:.4f}") def main(): ap = argparse.ArgumentParser() ap.add_argument("--modes", nargs="+", required=False, help="a0 q3a-src q3a-draft q3a-final q3a-state q3b-look") ap.add_argument("--per-call-cap", type=float, default=0.05) ap.add_argument("--hard-cap", type=float, default=6.0) ap.add_argument("--max-chunks", type=int, default=0, help="sanity-gate: only first N chunks (0=all)") a = ap.parse_args() bank = build_bank(SEED) terms = seed_entities() chunk_srcs = greedy_chunks(S2.read_text(encoding="utf-8")) def load_base(): """Carryover source + Q3 baseline = REAL backend flat-A0-30 (draft + editor final per chunk).""" import sqlite3 con = sqlite3.connect(BASE_DB) d, f = {}, {} for ck, txt in con.execute("SELECT chunk_idx, response_text FROM checkpoints WHERE stage='draft' ORDER BY chunk_idx, attempt"): d[ck] = txt for ck, txt in con.execute("SELECT chunk_idx, response_text FROM checkpoints WHERE stage='edit' ORDER BY chunk_idx, attempt"): f[ck] = txt con.close() return d, f sp = L.Spender("/home/ubuntu/books/gu-zhenren/exp15/arm_costs.jsonl", per_call_cap=a.per_call_cap, hard_cap=a.hard_cap) caps = {"draft_max": 16000, "edit_max": 12000, "max_chunks": a.max_chunks} base_d, base_f = load_base() n_needed = len(chunk_srcs) if len(base_d) < n_needed or len(base_f) < n_needed: print(f" [ABORT] flat-A0-30 INCOMPLETE: draft {len(base_d)}/{n_needed}, final {len(base_f)}/{n_needed} " f"— the Q3 baseline + carryover source must be fully generated (regen must finish) first.") return lim = f", first {a.max_chunks} (sanity-gate)" if a.max_chunks else "" print(f"=== Q3a arms: shared src-carryover draft -> 3 editors (src/draft/final) over {len(chunk_srcs)} chunks{lim} ===") run_q3a(sp, bank, terms, chunk_srcs, base_d, base_f, caps) print(f"\narm spend this run: ${sp.total:.4f}") if __name__ == "__main__": main()