#!/usr/bin/env python3 """Референс-реализация ГОРЯЧЕГО ПУТИ банка памяти + self-tests. Не продукт — исполняемая СПЕКА механизма (по architecture/06 §Контракт горячего пути и research/13 Q1/Q3/Q4), по которой бэкенд пишет Go. Проверяет, что сам МЕХАНИЗМ работает как задумано, без бэкенда и без LLM-вызовов: normalize (NFKC + trad→simp + kana-fold) A4 «тихо-пусто» из-за орфографии → multi-pattern точный матч ключей/алиасов Q3 detерм. > dense на именах с запретом одиночных ключей (longest-match) A3 омографы 灶/炎/气/修 → спойлер-фильтр since_ch/until_ch (hard) C1 утечка будущего факта → sticky scene-inertia (местоименные чанки) A5 → токен-бюджет с приоритетом + лог вытеснения F2 → disposition: confirmed / ambiguous / reject A2 трёхстороннее решение → post-check: утв. dst (по decl) в выходе? E1 ловит и утечку, и отравление Запуск: eval/.venv/bin/python eval/memory_hotpath.py (печатает PASS/FAIL по кейсам) Прод-замены помечены [PROD]: OpenCC вместо мини-карты, Aho-Corasick вместо substring, pymorphy/decl вместо accept-регэкспов. """ from __future__ import annotations import re import unicodedata from dataclasses import dataclass, field # --- [PROD] в бою — OpenCC (opencc: t2s); здесь мини-карта под демо-термины --- TRAD2SIMP = {"魯": "鲁", "鎮": "镇", "趙": "赵", "蕭": "萧", "煉": "炼", "門": "门", "闆": "板"} def normalize(s: str) -> str: """NFKC (полу/полноширина) + trad→simp + катакана→хирагана. Симметрично к ключам и тексту — иначе 魯鎮 ≠ 鲁镇 и запись «тихо не совпадёт» (A4).""" s = unicodedata.normalize("NFKC", s) s = "".join(TRAD2SIMP.get(c, c) for c in s) out = [] for c in s: o = ord(c) out.append(chr(o - 0x60) if 0x30A1 <= o <= 0x30F6 else c) # katakana→hiragana return "".join(out).lower() def cjk_len(s: str) -> int: """Длина ключа в «значимых» знаках (CJK + буквы), для запрета одиночных ключей.""" return len(re.findall(r"[぀-ヿ㐀-鿿豈-﫿A-Za-zЀ-ӿ]", s)) @dataclass class Entry: id: str src: str dst: str accept: str # регэксп утв. формы в РУ-выходе ([PROD] через decl/лемматизатор) aliases: list = field(default_factory=list) status: str = "approved" # approved | auto | draft sense: str = "" since_ch: int | None = None # запись валидна с этой главы (спойлер) until_ch: int | None = None # ... по эту главу allow_short: bool = False # разрешить одиночный ключ (редко; только с guard) def keys(self): return [self.src] + self.aliases MIN_KEY = 2 # запрет одиночного Han/буквы как самостоятельного ключа (A3) @dataclass class Picked: entry: Entry via: str # какой ключ сработал | "sticky" disp: str # confirmed | ambiguous | reject: def select(glossary, chunk, chapter, sticky_prev=(), budget=6): """Горячий путь: из глоссария выбрать записи для инъекции в ЭТОТ чанк. Возвращает (injected, rejected, evicted, active_now).""" ntext = normalize(chunk) hits, rejected = {}, [] for e in glossary: matched_key = None # longest-match: длинные ключи раньше, одиночные — под запретом for k in sorted(e.keys(), key=len, reverse=True): nk = normalize(k) if cjk_len(nk) < MIN_KEY and not e.allow_short: continue if nk and nk in ntext: matched_key = k break if matched_key is None: continue # спойлер-окно — ЖЁСТКИЙ reject с логом (C1: safety-гейт, не UX) if e.since_ch is not None and chapter < e.since_ch: rejected.append(Picked(e, matched_key, f"reject:spoiler(since_ch={e.since_ch})")) continue if e.until_ch is not None and chapter > e.until_ch: rejected.append(Picked(e, matched_key, f"reject:spoiler(until_ch={e.until_ch})")) continue disp = "confirmed" if e.status == "approved" else "ambiguous" hits[e.id] = Picked(e, matched_key, disp) # sticky scene-inertia: активные в предыдущем чанке — тянем, если не совпали active_ids = set(hits) for e in glossary: if e.id in sticky_prev and e.id not in hits: if e.since_ch is not None and chapter < e.since_ch: continue # спойлер бьёт и sticky hits[e.id] = Picked(e, "sticky", "confirmed" if e.status == "approved" else "ambiguous") # токен-бюджет с приоритетом: confirmed>ambiguous, exact>sticky, approved>auto order = sorted(hits.values(), key=lambda p: (p.disp != "confirmed", p.via == "sticky", p.entry.status != "approved")) injected, evicted = order[:budget], order[budget:] return injected, rejected, evicted, active_ids def render_block(injected): """Сериализация в промпт (SakuraLLM-стиль); ambiguous помечены «проверить».""" lines = [] for p in injected: tag = "" if p.disp == "confirmed" else " ⟨unverified — проверить⟩" lines.append(f"{p.entry.src} → {p.entry.dst}{tag}") return "\n".join(lines) def postcheck(injected, output): """E1: для каждой ИНЪЕКТИРОВАННОЙ approved-записи — есть ли её утв. форма в выходе? нет → флаг (модель проигнорировала ИЛИ мы инъектили неверное).""" flags = [] for p in injected: if p.disp != "confirmed": continue if not re.search(p.entry.accept, output): flags.append((p.entry.id, p.entry.dst, "MISSING")) return flags # ============================ SELF-TESTS ============================ G = [ Entry("ah_q", "阿Q", "А-кью", r"А[-\s]?[Кк]ью", sense="name"), Entry("sishu", "四叔", "Четвёртый дядюшка", r"[Дд]ядюшк", aliases=["鲁四老爷"], sense="char"), Entry("luzhen", "鲁镇", "Лучжэнь", r"Лучжэн"), Entry("songzao","送灶", "проводы бога очага", r"бога очага", sense="festival"), # НЕ бывает от 灶 Entry("xiaoyan","萧炎", "Сяо Янь", r"Сяо\s?Янь", aliases=["炎哥"], sense="name"), # НЕ от 炎 Entry("xiuwei", "修为", "уровень совершенствования", r"совершенствован", sense="cultivation"), # НЕ от 修 Entry("shadow", "影卫", "Тёмный страж", r"[Тт]ёмн\w+ страж", since_ch=200, sense="spoiler"), # раскрытие в гл.200 Entry("newname","小D", "Малыш Дэ", r"Малыш\s?Дэ", status="auto"), # неподтверждённый → ambiguous ] def check(name, cond, detail=""): print(f" [{'PASS' if cond else 'FAIL'}] {name}" + (f" — {detail}" if detail else "")) return cond def main(): print("=== Референс горячего пути банка памяти — self-tests ===\n") ok = [] # T1: имена в чанке → отбор + confirmed; алиас 鲁四老爷 тянет 四叔 inj, rej, ev, _ = select(G, "阿Q走进鲁镇,遇见鲁四老爷。", chapter=1) ids = {p.entry.id: p.disp for p in inj} ok.append(check("T1 exact+alias select", ids.get("ah_q") == "confirmed" and ids.get("luzhen") == "confirmed" and ids.get("sishu") == "confirmed", f"выбрано: {ids}")) # T2: 送...灶 в бытовом смысле — 送灶 НЕ инъектится (биграм 送灶 нет; 灶 одиночный запрещён) inj, *_ = select(G, "厨房里,老妈子把饭菜送到灶间去热一热。", chapter=1) ok.append(check("T2 wrong-sense 送灶 rejected", "songzao" not in {p.entry.id for p in inj}, "омограф не инъектирован (A3)")) # T3: 炎=пламя — 萧炎 НЕ инъектится (одиночный 炎 не ключ, 萧炎 отсутствует) inj, *_ = select(G, "山火蔓延,炎势冲天,村民连夜逃走。", chapter=1) ok.append(check("T3 wrong-sense 炎 rejected", "xiaoyan" not in {p.entry.id for p in inj})) # T4: 修=чинить — 修为 НЕ инъектится inj, *_ = select(G, "工匠把石桥修好了。", chapter=1) ok.append(check("T4 wrong-sense 修 rejected", "xiuwei" not in {p.entry.id for p in inj})) # T5: нормализация trad→simp — 魯鎮 совпадает с ключом 鲁镇 inj, *_ = select(G, "魯鎮的冬天很冷。", chapter=1) # traditional ok.append(check("T5 trad→simp normalization", "luzhen" in {p.entry.id for p in inj}, "魯鎮 → 鲁镇 сматчилось (A4)")) # T6: местоименный чанк (нет имён) — sticky тянет 四叔 из прошлого чанка inj, *_ = select(G, "他慢慢站起来,叹了口气。", chapter=2, sticky_prev={"sishu"}) picked = {p.entry.id: p.via for p in inj} ok.append(check("T6 sticky scene-inertia", picked.get("sishu") == "sticky", f"местоименный чанк: {picked}")) # T7: спойлер — 影卫 (since_ch=200) в главе 5 → hard reject inj, rej, *_ = select(G, "影卫出现了。", chapter=5) ok.append(check("T7 spoiler hard-reject", "shadow" not in {p.entry.id for p in inj} and any(p.entry.id == "shadow" for p in rej), f"reject: {[p.disp for p in rej]}")) # ... а в главе 250 та же запись проходит inj250, *_ = select(G, "影卫出现了。", chapter=250) ok.append(check("T7b spoiler passes after reveal", "shadow" in {p.entry.id for p in inj250})) # T8: disposition — неподтверждённый auto-термин 小D помечен ambiguous inj, *_ = select(G, "小D也来了。", chapter=1) d = {p.entry.id: p.disp for p in inj} ok.append(check("T8 auto-term → ambiguous", d.get("newname") == "ambiguous", f"disposition: {d}; render:\n " + render_block(inj).replace("\n", "\n "))) # T9: post-check ловит утечку — approved 阿Q инъектирован, но в выходе имени НЕТ inj, *_ = select(G, "阿Q走进鲁镇。", chapter=1) flags_bad = postcheck(inj, "Некто вошёл в Лучжэнь.") # А-кью пропал flags_good = postcheck(inj, "А-кью вошёл в Лучжэнь.") # всё на месте ok.append(check("T9 post-check catches missing dst", any(f[0] == "ah_q" for f in flags_bad) and not flags_good, f"плохой выход флаги={flags_bad}; хороший={flags_good}")) # T10: post-check ловит ОТРАВЛЕНИЕ — инъектировали неверный dst, выход ему следует poisoned = Picked(Entry("ah_q", "阿Q", "А-кью", r"А[-\s]?[Кк]ью"), "阿Q", "confirmed") fl = postcheck([poisoned], "Линь Чун вошёл в деревню.") # выход использует чужое имя ok.append(check("T10 post-check catches poisoning", any(f[2] == "MISSING" for f in fl), "утв. форма отсутствует → флаг (страх владельца, эксп.05 C3)")) print(f"\n=== {sum(ok)}/{len(ok)} PASS ===") return 0 if all(ok) else 1 if __name__ == "__main__": raise SystemExit(main())