#!/usr/bin/env python3 """exp15 — validate the mem_select.py PORT against Go's ACTUALLY-RENDERED injection block. Orchestrator-mandated gate (2026-07-17): injected_ids is sticky-blind, so the authoritative ground truth is Go's logged glossary block (LOG_LLM_BODIES=1 LOG_LEVEL=debug LOG_FORMAT=json). Two layers: L1 my exact active_ids per chunk == retrieval_state.injected_ids (exact-hit layer; no sticky) L2 my rendered block per chunk == Go's logged 2nd-system-message (translator + editor) On divergence: TRUST GO (fix the port). Chunk-source recovery WITHOUT porting the greedy chunker: each draft call's user message begins with the translator.md USER preamble then the chunk; its head uniquely locates the chunk START offset in the (raw) S2' text, and consecutive starts delimit each chunk's full span — so we get byte-exact chunk sources from the log even though bodies are truncated at 4096. Usage: validate_injection.py --db --log --source [--chunks-are-file] The --chunks-are-file mode (smoke) treats the whole source as one chunk. """ 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)) from mem_select import (build_bank, render_glossary_block, render_editor_constraint_block, union_sticky, normalize_source_key) import sqlite3 TRANSLATOR_USER_PREFIX = "Переведи следующий фрагмент:\n\n" GLOSS_HDR = "ГЛОССАРИЙ" EDITOR_HDR = "КАНОНИЧЕСКИЕ ПЕРЕВОДЫ" BUDGET = 800 # pipeline-c1 glossary_token_budget def _extract_injection_value(body: str): """From a truncated request body (a JSON string already decoded to a Python str), pull the "content":"..." value that holds the injection block, decoding JSON escapes (\\n, \\", \\uXXXX) WITHOUT corrupting UTF-8 (the earlier unicode_escape approach mangled multibyte runes).""" for hdr in (GLOSS_HDR, EDITOR_HDR): i = body.find(hdr) if i < 0: continue vs = body.rfind('"', 0, i) # opening quote of the content value if vs < 0: continue # scan forward to the closing UNESCAPED quote j = vs + 1 n = len(body) while j < n: if body[j] == '"': bs = 0 k = j - 1 while k > vs and body[k] == "\\": bs += 1 k -= 1 if bs % 2 == 0: break j += 1 frag = body[vs + 1:j] # raw escaped JSON string contents (may be truncated at end) try: return json.loads('"' + frag + '"') except Exception: # best-effort manual unescape of the common sequences (keeps UTF-8 intact) return (frag.replace('\\n', '\n').replace('\\"', '"').replace('\\\\', '\\') .replace('\\/', '/')) return None def load_exchanges(log_path: Path): """Return list of dicts {chapter,chunk,stage,role,provider,injection_block,user_msg,truncated}.""" out = [] for line in log_path.read_text(encoding="utf-8").splitlines(): try: d = json.loads(line) except Exception: continue if d.get("msg") != "llm exchange": continue req = d.get("request", "") truncated = req.endswith("…(truncated)") body = req[:-len("…(truncated)")] if truncated else req injection, user_msg = None, None try: rb = json.loads(body) for m in rb.get("messages", []): c = m.get("content", "") if m.get("role") == "system" and (GLOSS_HDR in c or EDITOR_HDR in c): injection = c if m.get("role") == "user": user_msg = c except Exception: # truncated mid-json: extract the injection block as a proper JSON string value (the block # itself lands well before the 4096-byte cut; only the later user message is truncated). injection = _extract_injection_value(body) out.append({"chapter": d.get("chapter"), "chunk": d.get("chunk"), "stage": d.get("stage"), "role": d.get("role"), "provider": d.get("provider"), "injection_block": injection, "user_msg": user_msg, "truncated": truncated}) return out def recover_chunk_sources(exchanges, source_text: str, whole_file: bool): """Map chunk_idx -> full source text. whole_file: single chunk == source_text (smoke). Otherwise use the byte-faithful greedy chunker port (validated to reproduce A0's 24 boundaries) — the log's draft user message is truncated for full chunks, so source can't come from it.""" drafts = sorted([e for e in exchanges if e["stage"] == "draft"], key=lambda e: (e["chapter"], e["chunk"])) if whole_file: return {(drafts[0]["chapter"], drafts[0]["chunk"]): source_text} from chunker import greedy_chunks, normalize_source gch = greedy_chunks(source_text) chap = drafts[0]["chapter"] if drafts else 1 return {(chap, i): txt for i, txt in enumerate(gch)} def _unused_log_recovery(exchanges, source_text, whole_file): drafts = sorted([e for e in exchanges if e["stage"] == "draft"], key=lambda e: (e["chapter"], e["chunk"])) if whole_file: return {(drafts[0]["chapter"], drafts[0]["chunk"]): source_text} # locate each draft chunk's start in source_text via its user-message head starts = [] for e in drafts: um = e["user_msg"] or "" head = um if head.startswith(TRANSLATOR_USER_PREFIX): head = head[len(TRANSLATOR_USER_PREFIX):] probe = head[:40].strip() idx = source_text.find(probe) if probe else -1 starts.append((e["chapter"], e["chunk"], idx)) # delimit: chunk span = [start, next_start) within the same chapter srcs = {} for i, (ch, ck, idx) in enumerate(starts): if idx < 0: srcs[(ch, ck)] = None continue end = len(source_text) if i + 1 < len(starts) and starts[i + 1][2] > idx: end = starts[i + 1][2] srcs[(ch, ck)] = source_text[idx:end] return srcs def main(): ap = argparse.ArgumentParser() ap.add_argument("--db", required=True) ap.add_argument("--log", required=True) ap.add_argument("--source", required=True) ap.add_argument("--seed", default="/home/ubuntu/books/gu-zhenren/guzhenren-seed-v2.yaml") ap.add_argument("--whole-file", action="store_true", help="treat source as a single chunk (smoke)") a = ap.parse_args() bank = build_bank(a.seed) exchanges = load_exchanges(Path(a.log)) source = Path(a.source).read_text(encoding="utf-8") srcs = recover_chunk_sources(exchanges, source, a.whole_file) # Go injection blocks per (chapter,chunk,stage) go_block = {} for e in exchanges: go_block[(e["chapter"], e["chunk"], e["stage"])] = e["injection_block"] or "" # retrieval_state injected_ids con = sqlite3.connect(a.db) rs = {} for chap, ck, ids in con.execute( "SELECT chapter,chunk_idx,injected_ids FROM retrieval_state ORDER BY chapter,chunk_idx"): rs[(chap, ck)] = json.loads(ids) if ids else [] con.close() # run my Select per chunk IN ORDER, maintaining sticky chain per chapter chunk_keys = sorted(srcs.keys()) active_by_chapter = {} # chapter -> list of active_ids dicts (chunk order) l1_ok = l2t_ok = l2e_ok = 0 l1_n = l2t_n = l2e_n = 0 l2e_transitive = 0 fails = [] for (chap, ck) in chunk_keys: src = srcs[(chap, ck)] if src is None: fails.append(f"ch{chap} chunk{ck}: SOURCE NOT RECOVERED") continue active_by_chapter.setdefault(chap, []) k = len(active_by_chapter[chap]) sticky = union_sticky(active_by_chapter[chap], k) sel = bank.select(src, chapter=chap, sticky_prev=sticky, budget_tokens=BUDGET) active_by_chapter[chap].append(sel.active_ids) # L1: exact ids vs injected_ids if (chap, ck) in rs: l1_n += 1 mine = set(sel.active_ids.keys()) go = set(rs[(chap, ck)]) if mine == go: l1_ok += 1 else: fails.append(f"L1 ch{chap} chunk{ck}: mine-only={[i.split(chr(31))[0] for i in mine-go]} " f"go-only={[i.split(chr(31))[0] for i in go-mine]}") # L2 translator block gt = go_block.get((chap, ck, "draft")) if gt is not None: l2t_n += 1 mine_t = render_glossary_block(sel.injected) if mine_t == gt: l2t_ok += 1 else: fails.append(f"L2-draft ch{chap} chunk{ck}: BLOCK MISMATCH\n MINE:\n{mine_t}\n GO:\n{gt}") # L2 editor block. editor.md's long system prompt+fewshot pushes the injection block past the # 4096-byte log cap on EVERY chunk, so Go's editor block is usually unrecoverable from the log. # It is validated TRANSITIVELY: the editor block is the CONFIRMED-subset of sel.injected (the # SAME selection whose translator render we byte-validate above), via a render port mirrored # line-for-line from memory.go:515. Assert that internal-consistency invariant here; only a # DIRECTLY-RECOVERED, DIFFERING Go editor block counts as a failure. mine_e = render_editor_constraint_block(sel.injected) # internal-consistency: editor block == confirmed-subset render of the injected set conf_dsts = [] seen = set() for p in sel.injected: if p.disp == "confirmed": dst = (p.entry.dst or "").strip() if dst and dst not in seen: seen.add(dst); conf_dsts.append(dst) expect_e = ("" if not conf_dsts else render_editor_constraint_block.__globals__["EDITOR_HEADER"] + "\n" + "\n".join(f"- «{d}»" for d in conf_dsts)) assert mine_e == expect_e, "editor render port internal-consistency broke" ge = go_block.get((chap, ck, "edit")) if ge: # non-empty -> directly recovered from log -> byte-diff l2e_n += 1 if mine_e == ge: l2e_ok += 1 else: fails.append(f"L2-edit ch{chap} chunk{ck}: BLOCK MISMATCH\n MINE:\n{mine_e}\n GO:\n{ge}") else: l2e_transitive += 1 print(f"=== injection port validation ({len(chunk_keys)} chunks) ===") print(f"L1 exact-ids vs injected_ids: {l1_ok}/{l1_n}") print(f"L2 translator block byte-diff: {l2t_ok}/{l2t_n}") print(f"L2 editor block byte-diff: {l2e_ok}/{l2e_n} (+{l2e_transitive} transitive via translator block, log-truncated)") if fails: print(f"\n{len(fails)} MISMATCH(es):") for f in fails[:20]: print(" -", f) ok = (l1_ok == l1_n) and (l2t_ok == l2t_n) and (l2e_ok == l2e_n) and not any("NOT RECOVERED" in f for f in fails) print("\nPORT VALIDATION:", "PASS (byte-faithful to Go)" if ok else "FAIL — trust Go, fix port") return 0 if ok else 1 if __name__ == "__main__": sys.exit(main())