textmachine/eval/exp15/validate_injection.py

253 lines
11 KiB
Python

#!/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 <db> --log <log.json> --source <S2'.txt> [--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())