textmachine/eval/exp15/trap_mine.py

152 lines
9.7 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 — trap mining (kimi-k2.6). Design §D1: candidates are LLM-mined then MANUALLY curated; the
miner is family-isolated (Moonshot != DeepSeek/GLM arms != xAI/Mistral judges) so traps aren't preloaded
toward any arm or judge. temp MUST be 1 (kimi), verbose reasoning billed as output.
Mines the 8 trap types over S2' in seam-centred windows (the cohesion locus). Each candidate carries an
EXACT source span, the antecedent location + distance (for the K=3 stratification), the phenomenon, and
the correct-RU expectation. SELECTION (which candidates straddle an A0 greedy boundary = common
denominator) and the min-N quota are enforced AFTER A0 boundaries are known, in trap_curate.py.
Per-call predicted-cost gate; usage/cost persisted (D30.10). Self-review: prints per-window yield + cost.
Run: python trap_mine.py --run (paid, ~$0.3) | --dry (print prompts only, $0)
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import exp15_llm as L
S2 = Path("/home/ubuntu/books/gu-zhenren/exp15/S2prime.txt")
SEAMS = Path("/home/ubuntu/books/gu-zhenren/exp15/S2prime_seams.json")
OUT = Path("/home/ubuntu/books/gu-zhenren/exp15/trap_candidates.json")
LEDGER = Path("/home/ubuntu/books/gu-zhenren/exp15/mine_costs.jsonl")
MINER = "kimi-k2.6"
WINDOW = 900 # runes each side of a seam
TAXONOMY = (
"T-ana: анафора НАЗАД через границу (китайское нулевое подлежащее/местоимение; антецедент раньше, "
"реализация позже — по-русски нужно эксплицитно назвать деятеля/род).\n"
"T-cat: катафора ВПЕРЁД (референт назван позже — «это был…» → имя дальше).\n"
"T-ell: эллипсис/восстановление сказуемого или объекта через границу.\n"
"T-ent: сущность/термин/титул введён раньше, повторён позже через границу (нужна консистентность).\n"
"T-gen: род говорящего/деятеля виден только из контекста (по-русски прошедшее время маркирует род: "
"пошёл/пошла) — если разрыв отрезает контекст, род можно перепутать.\n"
"T-tense: единство нарративного времени внутри сцены через разрез.\n"
"T-reg: единство регистра/вежливости/голоса персонажа через разрез.\n"
)
SYS = (
"Ты — эксперт по китайско-русскому художественному переводу и лингвистике связности. Тебе дают фрагмент "
"китайского исходника (ранобэ 蛊真人). Найди КОНКРЕТНЫЕ места, где верность перевода на русский зависит от "
"КОНТЕКСТА, который может оказаться по ДРУГУЮ сторону границы нарезки на чанки (т.е. если текст разрезать "
"между двумя предложениями, переводчик второго куска может ошибиться: перепутать род/референт, потерять "
"антецедент нулевого местоимения, сменить время или регистр, рассогласовать термин).\n\n"
f"ТИПЫ ФЕНОМЕНОВ:\n{TAXONOMY}\n"
"Для КАЖДОГО найденного кандидата верни объект:\n"
'{"type":"T-ana|T-cat|T-ell|T-ent|T-gen|T-tense|T-reg",'
'"anchor_span":"ТОЧНАЯ цитата предложения(й) с АНТЕЦЕДЕНТОМ/источником (из исходника)",'
'"dependent_span":"ТОЧНАЯ цитата предложения с ЗАВИСИМЫМ элементом (местоимение/глагол/повтор)",'
'"antecedent_distance_sentences":ЦЕЛОЕ (сколько предложений между антецедентом и зависимым),'
'"phenomenon":"кратко: какая зависимость и почему разрез её ломает",'
'"expected_ru":"что ДОЛЖЕН сделать верный русский перевод (напр. явно назвать Фан Юаня; муж. род; то же время)",'
'"failure_if_split":"как именно ошибётся перевод, если контекст отрезан"}\n'
"Верни СТРОГО JSON-массив таких объектов (только реально присутствующие в тексте случаи; цитаты — дословно "
"из исходника; 38 кандидатов на фрагмент). Без пояснений вне JSON."
)
def windows():
text = S2.read_text(encoding="utf-8")
seams = json.load(open(SEAMS))["seams"]
out = []
for s in seams:
off = s["rune_offset"]
lo, hi = max(0, off - WINDOW), min(len(text), off + WINDOW)
out.append({"seam_id": s["seam_id"], "prev_sec": s["prev_sec"], "next_sec": s["next_sec"],
"seam_offset": off, "window_lo": lo, "window_hi": hi, "text": text[lo:hi]})
return out
def parse_array(text):
import re
m = re.search(r"\[.*\]", text or "", re.S)
if not m:
return None
try:
return json.loads(m.group(0))
except Exception:
return None
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--run", action="store_true")
ap.add_argument("--dry", action="store_true")
ap.add_argument("--per-call-cap", type=float, default=0.28) # kimi reasoning uncapped by max_tokens
a = ap.parse_args()
wins = windows()
if a.dry:
print(f"{len(wins)} seam windows, ~{WINDOW*2} runes each. Miner={MINER} temp=1.")
print("SYS prompt bytes:", len(SYS))
print("sample window (seam 4):", wins[3]["text"][:200])
return
if not a.run:
ap.print_help(); return
sp = L.Spender(str(LEDGER), per_call_cap=a.per_call_cap, hard_cap=3.0)
results = []
for w in wins:
msgs = [{"role": "system", "content": SYS},
{"role": "user", "content": f"Фрагмент (шов {w['seam_id']}, 节{w['prev_sec']}→节{w['next_sec']}):\n\n{w['text']}"}]
in_est = (len(SYS) + len(w["text"])) // 2
ok, pred, why = sp.gate(MINER, in_est, L.MODELS[MINER]["max_tokens"])
print(f"[gate] seam {w['seam_id']} pred=${pred:.4f} {'OK' if ok else 'BLOCK: '+why}")
if not ok:
results.append({"seam_id": w["seam_id"], "err": why, "candidates": []})
continue
# kimi-k2.6 over-reasons: with max_tokens 16000 the reasoning can consume the whole budget and
# leave content EMPTY (seen: reasoning 15999, content ""). 24000 gives content headroom after
# reasoning; 600s covers the latency. NEVER re-feed kimi's (possibly empty) reply as an assistant
# turn (400 "assistant must not be empty") — re-ask FRESH with a "reason briefly, emit JSON" nudge.
NUDGE = {"role": "user", "content": "Не рассуждай долго. Сразу выведи ТОЛЬКО компактный валидный JSON-массив кандидатов (36 шт.), без markdown-ограды."}
r = L.call(MINER, msgs, temp=1.0, max_tokens=24000, timeout=600)
sp.record({"seam_id": w["seam_id"], "usage": r.get("usage"), "cost": r.get("cost", 0.0),
"err": r.get("err"), "latency_s": r.get("latency_s"), "finish": r.get("finish")})
cands = parse_array(r.get("text", ""))
err = r.get("err") or ""
need_retry = bool(err) or cands is None # timeout, empty content, or unparseable
if need_retry:
r2 = L.call(MINER, msgs + [NUDGE], temp=1.0, max_tokens=24000, timeout=600)
sp.record({"seam_id": w["seam_id"], "retry": True, "usage": r2.get("usage"),
"cost": r2.get("cost", 0.0), "err": r2.get("err"), "latency_s": r2.get("latency_s"),
"finish": r2.get("finish")})
c2 = parse_array(r2.get("text", "")) if not r2.get("err") else None
if c2 is not None:
cands = c2
cands = cands or []
for c in cands:
c["seam_id"] = w["seam_id"]
c["seam_offset"] = w["seam_offset"]
row = {"seam_id": w["seam_id"], "n": len(cands), "cost": r.get("cost"),
"err": r.get("err"), "candidates": cands}
results.append(row)
# incremental dump so a background run can be inspected mid-flight
with open(str(OUT) + ".partial.jsonl", "a", encoding="utf-8") as pf:
pf.write(json.dumps(row, ensure_ascii=False) + "\n")
print(f" seam {w['seam_id']}: {len(cands)} candidates cost=${r.get('cost',0):.4f} "
f"reasoning_tok={r.get('usage',{}).get('reasoning_tokens')} err={r.get('err')}")
OUT.write_text(json.dumps({"miner": MINER, "window_runes": WINDOW, "results": results},
ensure_ascii=False, indent=2), encoding="utf-8")
total = sum(x.get("cost") or 0 for x in results)
ncand = sum(len(x["candidates"]) for x in results)
from collections import Counter
by_type = Counter(c["type"] for x in results for c in x["candidates"] if c.get("type"))
print(f"\nmined {ncand} candidates over {len(wins)} windows; by type: {dict(by_type)}")
print(f"total mining spend: ${total:.4f} -> {OUT}")
if __name__ == "__main__":
main()