224 lines
12 KiB
Python
224 lines
12 KiB
Python
#!/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())
|