textmachine/eval/memory_hotpath.py

224 lines
12 KiB
Python
Raw Permalink 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
"""Референс-реализация ГОРЯЧЕГО ПУТИ банка памяти + 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:<reason>
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())