#!/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")