#!/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-массив таких объектов (только реально присутствующие в тексте случаи; цитаты — дословно " "из исходника; 3–8 кандидатов на фрагмент). Без пояснений вне 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-массив кандидатов (3–6 шт.), без 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()