textmachine/eval/exp15/mem_select.py

508 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""exp15 — byte-faithful Python PORT of the backend memory-bank hot path (memory.go + memnorm.go).
Reconstructs the injection the Go pipeline actually feeds per chunk: normalize -> Aho-Corasick
(here: exact multi-substring, same result set) -> spaced-phonetic boundary drop -> trust-gated
longest-match suppression -> spoiler window -> three-way disposition -> sticky scene-inertia (depth 2)
-> priority token budget -> render (translator glossary block / editor constraint block).
WHY a port (orchestrator verdict 2026-07-17): retrieval_state.injected_ids carries EXACT hits only
(sticky-blind by construction), so faithful arm injection needs the full Select, VALIDATED against Go's
actually-rendered glossary block (LOG_LLM_BODIES byte-diff), NOT against injected_ids. On divergence:
trust Go (fix the port). This module is the injection engine shared by A0-validation and all arms.
Source of truth mirrored (backend/internal/pipeline/):
memnorm.go normalizeSourceKey / significantLen / trad2simp
memory.go Select / suppressContained / matchTrust / trustRank / dispositionFor / spoilerBlocked
/ priorityRank / glossaryLineTokens / renderGlossaryBlock / renderEditorConstraintBlock
/ stickyDepth=2 / minKeyLenHan=2 / minKeyLenPhonetic=3
memseed.go loadGlossarySeed (status ""->approved; surfaces = src + aliases when dst non-empty)
Self-review by EXECUTION: run `python mem_select.py --selftest` for invariants; the real gate is the
byte-diff vs Go's logged block in validate_injection.py.
"""
from __future__ import annotations
import sys
import unicodedata
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional
import regex # \p{Han} etc. — exact Unicode script tables, matching Go's unicode.Han/Hiragana/...
import yaml
BACKEND = Path("/home/ubuntu/projects/textmachine/backend")
TRAD2SIMP_FILE = BACKEND / "internal/pipeline/data/trad2simp.txt"
MIN_KEY_LEN_HAN = 2
MIN_KEY_LEN_PHONETIC = 3
STICKY_DEPTH = 2
# disposition constants (memory.go:89-93)
CONFIRMED = "confirmed"
AMBIGUOUS = "ambiguous"
REJECT = "reject"
GLOSSARY_HEADER = ("ГЛОССАРИЙ (используй эти утверждённые переводы имён и терминов последовательно; "
"строки с пометкой ⟨проверить⟩ — неподтверждённые кандидаты):")
EDITOR_HEADER = ("КАНОНИЧЕСКИЕ ПЕРЕВОДЫ имён и терминов (в черновике эти сущности должны быть переданы "
"ИМЕННО этими формами — приводи к ним любые расхождения, склоняя по контексту; не вводи "
"иных вариантов и не меняй ничего другого):")
# --- Unicode script predicates (match Go unicode.Han|Hiragana|Katakana|Hangul + IsLetter/Cf) --------
_RE_CJK = regex.compile(r"[\p{Han}\p{Hiragana}\p{Katakana}\p{Hangul}]")
_RE_LETTER = regex.compile(r"\p{L}")
_RE_HAN = regex.compile(r"\p{Han}")
_RE_LATIN = regex.compile(r"\p{Latin}")
_RE_CYRILLIC = regex.compile(r"\p{Cyrillic}")
def _is_cjk(ch: str) -> bool:
return _RE_CJK.match(ch) is not None
def _is_han(ch: str) -> bool:
return _RE_HAN.match(ch) is not None
def _is_letter(ch: str) -> bool:
return _RE_LETTER.match(ch) is not None
def _is_apostrophe_variant(ch: str) -> bool:
return ch in ("", "", "ʼ", "") # ' ' ʼ (memnorm.go:189-195)
def _is_ignorable_for_match(ch: str) -> bool:
# unicode.Cf OR variation selectors (memnorm.go:207-212)
if unicodedata.category(ch) == "Cf":
return True
o = ord(ch)
return (0xFE00 <= o <= 0xFE0F) or (0xE0100 <= o <= 0xE01EF)
# --- trad2simp table (loaded from the Go embed file, parsed identically) ----------------------------
def _load_trad2simp() -> dict:
m = {}
for line in TRAD2SIMP_FILE.read_text(encoding="utf-8").split("\n"):
line = line.strip()
if not line or line.startswith("#"):
continue
fields = line.split()
if len(fields) != 2:
raise ValueError(f"trad2simp corrupt line: {line!r}")
tr, si = fields
if len(tr) != 1 or len(si) != 1:
raise ValueError(f"trad2simp non-single-rune: {line!r}")
m[tr] = si
return m
_TRAD2SIMP = _load_trad2simp()
def normalize_source_key(s: str) -> str:
"""memnorm.go:104 — NFKC -> apostrophe fold -> drop ignorables -> trad2simp -> kana->hira -> lower."""
s = unicodedata.normalize("NFKC", s)
out = []
for ch in s:
if _is_apostrophe_variant(ch):
ch = "'"
if _is_ignorable_for_match(ch):
continue
ch = _TRAD2SIMP.get(ch, ch)
o = ord(ch)
if 0x30A1 <= o <= 0x30F6: # katakana -> hiragana
ch = chr(o - 0x60)
out.append(ch.lower())
return "".join(out)
def significant_len(normalized: str) -> int:
"""memnorm.go:220 — count CJK/kana/Hangul ideographs + letters."""
n = 0
for ch in normalized:
if _is_cjk(ch) or _is_letter(ch):
n += 1
return n
def runes_any_han(s: str) -> bool:
return _RE_HAN.search(s) is not None
def min_key_len_for(norm_key: str) -> int:
return MIN_KEY_LEN_HAN if runes_any_han(norm_key) else MIN_KEY_LEN_PHONETIC
def collision_prone_key(norm_key: str) -> bool:
return (not runes_any_han(norm_key)) and significant_len(norm_key) <= MIN_KEY_LEN_PHONETIC
def glossary_line_tokens(src: str, dst: str) -> int:
"""memory.go:304 — cjk + other//3 over 'src → dst' (floor-free)."""
cjk = other = 0
for ch in f"{src}{dst}":
if _is_cjk(ch):
cjk += 1
elif ch.isspace():
pass
else:
other += 1
return cjk + other // 3
def trust_rank(d: str) -> int:
return {CONFIRMED: 2, AMBIGUOUS: 1}.get(d, 0)
# --- data model -------------------------------------------------------------------------------------
@dataclass
class MemoryEntry:
id: str
src: str
dst: str
status: str
sense: str
since_ch: int
until_ch: int
norm_keys: list = field(default_factory=list)
allow_short: bool = False
@dataclass
class Picked:
entry: MemoryEntry
via: str # firing key, or "sticky"
disp: str
@dataclass
class Selection:
injected: list = field(default_factory=list) # [Picked] in budget priority order
rejected: list = field(default_factory=list)
evicted: list = field(default_factory=list)
trust_gated: list = field(default_factory=list)
active_ids: dict = field(default_factory=dict) # id -> disp (EXACT only; feeds next sticky)
class MemoryBank:
"""Materialized frozen glossary. Built once from seed rows; never mutated (memory.go:180)."""
def __init__(self, rows: list):
self.entries: list[MemoryEntry] = []
self.key_owners: dict[str, list[int]] = {}
seen_key = set()
all_keys = []
# frozen entry order = store's GlossaryForBook: ORDER BY src, sense, since_ch, until_ch, status, dst
# (SQLite BINARY collation == UTF-8 byte order == codepoint order for BMP CJK). This fixes the
# injection BLOCK line order (the id SET already matched; only the order differed).
rows = sorted(rows, key=lambda r: (r["src"], r.get("sense") or "",
int(r.get("since_ch", 0) or 0), int(r.get("until_ch", 0) or 0),
r.get("status") or "approved", r.get("dst") or ""))
for row in rows:
src = row["src"]
sense = row.get("sense", "") or ""
since = int(row.get("since_ch", 0) or 0)
until = int(row.get("until_ch", 0) or 0)
status = row.get("status") or "approved" # memseed.go:100-102
allow_short = bool(row.get("allow_short", False))
e = MemoryEntry(
id=f"{src}\x1f{sense}\x1f{since}\x1f{until}",
src=src, dst=row.get("dst", "") or "", status=status,
sense=sense, since_ch=since, until_ch=until, allow_short=allow_short,
)
# surfaces = src + aliases, ONLY when dst non-empty (memory.go:202-208)
surfaces = []
if (e.dst or "").strip() != "":
surfaces.append(src)
for a in (row.get("aliases") or []):
al = (a.get("alias") or "").strip()
if al:
surfaces.append(al)
for s in surfaces:
nk = normalize_source_key(s)
if nk == "":
continue
if significant_len(nk) < min_key_len_for(nk) and not allow_short:
continue # single-key ban
e.norm_keys.append(nk)
idx = len(self.entries)
self.entries.append(e)
for nk in e.norm_keys:
self.key_owners.setdefault(nk, []).append(idx)
if nk not in seen_key:
seen_key.add(nk)
all_keys.append(nk)
self.keys = sorted(all_keys) # deterministic (memory.go:241)
self.key_index = {k: i for i, k in enumerate(self.keys)}
# ---- matching (AC-equivalent exact multi-substring over normalized text) ----
def _matches(self, ntext: str):
"""Return [(key_idx, start, end)] for every occurrence of every key, sorted (start,end,key_idx).
Overlapping occurrences included (pos advances by 1), matching AC output exactly."""
out = []
for k in self.keys:
ki = self.key_index[k]
klen = len(k)
pos = 0
while True:
i = ntext.find(k, pos)
if i < 0:
break
out.append((ki, i, i + klen))
pos = i + 1
out.sort(key=lambda m: (m[1], m[2], m[0]))
return out
def _spaced_phonetic_script(self, norm_key: str):
"""memory.go:781 — Latin/Cyrillic word-delimited script, or None for Han/kana/mixed."""
script = None
for ch in norm_key:
if _is_cjk(ch):
return None
if _RE_LATIN.match(ch):
if script == "cyr":
return None
script = "lat"
elif _RE_CYRILLIC.match(ch):
if script == "lat":
return None
script = "cyr"
return script
def _suppress_unbounded_phonetic(self, ms, ntext):
kept = []
for (ki, s, e) in ms:
script = self._spaced_phonetic_script(self.keys[ki])
if script is not None:
re_s = _RE_LATIN if script == "lat" else _RE_CYRILLIC
before_bad = s > 0 and _is_letter(ntext[s - 1]) and re_s.match(ntext[s - 1])
after_bad = e < len(ntext) and _is_letter(ntext[e]) and re_s.match(ntext[e])
if before_bad or after_bad:
continue
kept.append((ki, s, e))
return kept
def _match_trust(self, m, chapter: int):
"""memory.go:656 — best disposition over spoiler-valid owners of the key; hasValid flag."""
key = self.keys[m[0]]
disp, has_valid = None, False
for ei in self.key_owners[key]:
e = self.entries[ei]
if spoiler_blocked(e, chapter):
continue
d = disposition_for(e, key)
if not has_valid or trust_rank(d) > trust_rank(disp):
disp, has_valid = d, True
return disp, has_valid
def _suppress_contained(self, ms, chapter: int):
"""memory.go:684 — trust-gated longest-match. Returns (kept, trust_gate_events)."""
kept = []
gated = []
for i, m in enumerate(ms):
con_disp, con_valid = self._match_trust(m, chapter)
contained = False
refused = None
for j, o in enumerate(ms):
if i == j:
continue
# o must STRICTLY contain m (memory.go:696)
if not (o[1] <= m[1] and o[2] >= m[2] and (o[2] - o[1]) > (m[2] - m[1])):
continue
sup_disp, sup_valid = self._match_trust(o, chapter)
if not sup_valid:
continue
if (not con_valid) or trust_rank(sup_disp) >= trust_rank(con_disp):
contained = True
break
if refused is None:
refused = {
"suppressor": self.keys[o[0]], "suppressor_disp": sup_disp,
"protected": self.keys[m[0]], "protected_disp": con_disp,
}
if contained:
continue
kept.append(m)
if refused is not None:
gated.append(refused)
return kept, _dedupe_trust_gate(gated)
def select(self, chunk: str, chapter: int, sticky_prev: dict, budget_tokens: int) -> Selection:
ntext = normalize_source_key(chunk)
occ = self._matches(ntext)
occ = self._suppress_unbounded_phonetic(occ, ntext)
occ, trust_gated = self._suppress_contained(occ, chapter)
# surviving key occurrences -> entries, longest firing key per entry (memory.go:340-348)
matched_via = {}
for m in occ:
k = self.keys[m[0]]
for ei in self.key_owners[k]:
cur = matched_via.get(ei)
if cur is None or len(k) > len(cur):
matched_via[ei] = k
sel = Selection(trust_gated=trust_gated)
hits = {} # id -> Picked (exact)
for ei, e in enumerate(self.entries):
via = matched_via.get(ei)
if via is None:
continue
if spoiler_blocked(e, chapter):
sel.rejected.append(Picked(e, via, REJECT))
continue
disp = disposition_for(e, via)
hits[e.id] = Picked(e, via, disp)
sel.active_ids[e.id] = disp
# sticky scene-inertia (memory.go:375-388) — inherit prior disposition
for e in self.entries:
if e.id not in sticky_prev:
continue
if e.id in hits:
continue
if spoiler_blocked(e, chapter):
continue
hits[e.id] = Picked(e, "sticky", sticky_prev[e.id])
# priority order (frozen-entry order, then stable-sort by priorityRank) (memory.go:393-401)
order = [hits[e.id] for e in self.entries if e.id in hits]
order.sort(key=priority_rank) # Python sort is stable
if budget_tokens > 0:
used, cut = 0, len(order)
for i, p in enumerate(order):
used += glossary_line_tokens(p.entry.src, p.entry.dst)
if used > budget_tokens:
cut = i
break
sel.injected = order[:cut]
sel.evicted = order[cut:]
else:
sel.injected = order
return sel
def spoiler_blocked(e: MemoryEntry, chapter: int) -> bool:
if e.since_ch > 0 and chapter < e.since_ch:
return True
if e.until_ch > 0 and chapter > e.until_ch:
return True
return False
def disposition_for(e: MemoryEntry, via: str) -> str:
if via != "sticky" and not e.allow_short and collision_prone_key(via):
return AMBIGUOUS
if e.status == "approved":
return CONFIRMED
return AMBIGUOUS
def priority_rank(p: Picked) -> int:
rank = 0
if p.disp != CONFIRMED:
rank |= 1 << 2
if p.via == "sticky":
rank |= 1 << 1
if p.entry.status != "approved":
rank |= 1 << 0
return rank
def _dedupe_trust_gate(evs):
if len(evs) < 2:
return evs
seen = set()
out = []
for e in evs:
k = (e["suppressor"], e["protected"])
if k in seen:
continue
seen.add(k)
out.append(e)
return out
def render_glossary_block(injected) -> str:
"""memory.go:477 — translator glossary block."""
lines = []
for p in injected:
if (p.entry.dst or "").strip() == "":
continue
line = f"{p.entry.src}{p.entry.dst}"
if p.disp != CONFIRMED:
line += " ⟨проверить⟩"
lines.append(line)
if not lines:
return ""
return GLOSSARY_HEADER + "\n" + "\n".join(lines)
def render_editor_constraint_block(injected) -> str:
"""memory.go:515 — editor constraint block (CONFIRMED-only, dedup by dst)."""
lines = []
seen = set()
for p in injected:
if p.disp != CONFIRMED:
continue
dst = (p.entry.dst or "").strip()
if dst == "" or dst in seen:
continue
seen.add(dst)
lines.append(f"- «{dst}»")
if not lines:
return ""
return EDITOR_HEADER + "\n" + "\n".join(lines)
# --- seed loading (mirror loadGlossarySeed enough for the hot path) ---------------------------------
def load_seed_rows(seed_path: str) -> list:
d = yaml.safe_load(Path(seed_path).read_text(encoding="utf-8"))
return d["terms"]
def union_sticky(active_ids_per_chunk: list, k: int, depth: int = STICKY_DEPTH) -> dict:
"""sticky_prev for chunk k = union of the previous `depth` chunks' active_ids (same chapter).
active_ids_per_chunk[i] is the dict from chunk i's Selection.active_ids. (bookrun.go unionSticky)."""
merged = {}
for i in range(max(0, k - depth), k):
for cid, disp in active_ids_per_chunk[i].items():
merged[cid] = disp # later chunk wins on the disposition it carried
return merged
def build_bank(seed_path: str) -> MemoryBank:
return MemoryBank(load_seed_rows(seed_path))
if __name__ == "__main__":
if "--selftest" in sys.argv:
seed = "/home/ubuntu/books/gu-zhenren/guzhenren-seed-v2.yaml"
bank = build_bank(seed)
print(f"entries={len(bank.entries)} unique_keys={len(bank.keys)}")
# normalize sanity: NFKC folds fullwidth space U+3000 -> U+0020 (kept; Go does NOT trim)
assert normalize_source_key(" 方源 ") == " 方源 ", repr(normalize_source_key(" 方源 "))
assert normalize_source_key("Q版") == "q版", repr(normalize_source_key("Q版"))
# key "方源" still substring-matches inside the space-padded normalized text
assert "方源" in normalize_source_key(" 方源 ")
# a chunk from S2' head
chunk = ("方源笑了,握紧拳头。古月方源!蛊师有九转。四代族长古月博。"
"沈翠端着酒菜。方正是甲等资质,方源只是丙等。")
sel = bank.select(chunk, chapter=1, sticky_prev={}, budget_tokens=800)
print("\n-- exact active_ids (no sticky) --")
for cid, disp in sel.active_ids.items():
print(f" {disp:9} {cid.split(chr(31))[0]}")
print("\n-- trust_gated events --")
for g in sel.trust_gated:
print(f" {g['suppressor']}({g['suppressor_disp']}) X-> {g['protected']}({g['protected_disp']})")
print("\n-- TRANSLATOR block --")
print(render_glossary_block(sel.injected))
print("\n-- EDITOR block --")
print(render_editor_constraint_block(sel.injected))
# longest-match check: 古月方源 should suppress nested 方源 and 古月 as firing keys,
# but the ENTITY 方源 still fires via its own occurrences elsewhere in the chunk.
print("\nselftest OK")