textmachine/eval/exp14_arms.py

175 lines
8.6 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
"""exp14 — раннер editor-армов (нарезка × промпт + аблации). Прямые вызовы, ручная упаковка.
Per-call predicted-cost кап (НЕ group-level). Персист usage/cost в exp14/costs.jsonl.
P0 = reuse records.json final ($0). Черновик держим общим (flash-draft из records) — варьируем
editor-стадию (модель × промпт × нарезка × reference).
Использование: exp14_arms.py --arm P1a [--chapters trap|stage2] [--model glm-5] [--dry]
Армы: P0 P1a P1b P1c A-layout A-2pass A-ref-prev A-ref-next A-ref-both (+ frontier: F-base F-disc)
"""
from __future__ import annotations
import argparse, json, re, sys, time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import exp14_common as C
import exp14_prompts as P
MAT = json.load(open(C.DIAG / "material.json"))
ARMSDIR = C.DIAG / "arms"; ARMSDIR.mkdir(exist_ok=True)
CAPS = {"glm-5": 0.08, "glm-5.1": 0.10, "deepseek-v4-flash": 0.03, "deepseek-v4-pro": 0.06,
"gpt-5.4": 0.40, "gemini-3.1-pro-preview": 0.30, "grok-4.3": 0.40,
"gpt-5-mini": 0.20, "kimi-k2.6": 0.20, "mistral-large-2512": 0.10}
SP = C.Spender(C.DIAG / "costs.jsonl", CAPS)
def deformat_draft(draft: str) -> str:
"""A-layout: снять построчную/пустострочную разбивку черновика (нейтральные сепараторы)."""
lines = [l.strip() for l in draft.split("\n") if l.strip()]
return " ".join(lines) # один поток; редактор не якорится на форме черновика
def chunk_units(scope: str):
"""Возвращает список единиц {id, source, draft, final_p0, editor_constraint, chapter, chunk_idx}."""
if scope == "trap":
return [dict(id=f"{u['chapter']}.{u['chunk_idx']}", **u) for u in MAT["trap_chunks"]]
if scope == "stage2":
out = []
for ch, chunks in MAT["stage2"].items():
for u in chunks:
out.append(dict(id=f"{ch}.{u['chunk_idx']}", chapter=int(ch), **u))
return out
raise ValueError(scope)
def chapter_units(scope: str):
"""Whole-chapter единицы: клеим source/draft чанков главы + chapter-level инъекция."""
inj = json.load(open(C.DIAG / "injection_blocks.json"))
units = chunk_units(scope)
by_ch = {}
for u in units:
by_ch.setdefault(u["chapter"], []).append(u)
out = []
for ch, us in by_ch.items():
if len(us) < 2: # whole-chapter тест осмыслен только когда все чанки главы в наборе (ch8/11/24)
continue
us = sorted(us, key=lambda x: x["chunk_idx"])
src = "\n\n".join(x["source"] for x in us)
drf = "\n\n".join(x["draft"] for x in us)
eb = inj.get(f"{ch}/ALL", {}).get("editor_constraint", "")
out.append(dict(id=f"{ch}.ALL", chapter=ch, chunk_idx="ALL",
source=src, draft=drf, editor_constraint=eb))
return out
def run_editor(arm: str, variant: str, units, model: str, reference_by=None,
two_pass=False, deformat=False, dry=False):
outdir = ARMSDIR / arm; outdir.mkdir(exist_ok=True)
sys_disc = P.editor_system(variant)
total_pred = 0.0
for u in units:
outp = outdir / f"{u['id']}.txt"
if outp.exists():
print(f" [skip exists] {arm} {u['id']}"); continue
draft = deformat_draft(u["draft"]) if deformat else u["draft"]
reference = reference_by.get(u["id"], "") if reference_by else ""
user = P.editor_user(u["source"], draft, reference)
msgs = C.messages_with_injection(sys_disc, u.get("editor_constraint", ""), user)
in_est = C.count_tokens(sys_disc + u.get("editor_constraint", "") + user,
"deepseek" if model.startswith("deepseek") else "glm")
s = C.spec(model, temp=0.4)
ok, pred = SP.guard(model, in_est, s["max_tokens"])
total_pred += pred
print(f" {arm} {u['id']}: in≈{in_est}tok predict=${pred:.4f} cap=${CAPS[model]} {'OK' if ok else 'OVER-CAP'}")
if dry:
continue
if not ok:
print(f" !! OVER per-call cap — skipped"); continue
t0 = time.time()
text, err, usage = C.call(s, msgs, timeout=360)
dt = round(time.time() - t0, 1)
rec = {"arm": arm, "model": model, "keyenv": s["keyenv"], "id": u["id"],
"variant": variant, "err": err, "latency_s": dt, "usage": usage}
c = SP.record(rec)
if err:
print(f" ERR {err[:80]} (${c:.4f}, {dt}s)")
continue
if two_pass: # second narrow pass: target-only literary reflow of the just-edited RU
sys2 = P.editor_system("discourse") # target-only reflow uses discourse core
u2 = ("Ниже — русский перевод. Перевёрстай его в естественные русские абзацы по "
"правилам дискурс-перевёрстки; НЕ меняй смысл, НЕ добавляй и НЕ удаляй атомы, "
"НЕ сверяйся с иностранным исходником (его нет):\n\n" + text)
msgs2 = [{"role": "system", "content": sys2}, {"role": "user", "content": u2}]
in2 = C.count_tokens(sys2 + u2, "glm")
ok2, pred2 = SP.guard(model, in2, s["max_tokens"])
if ok2:
text2, err2, usage2 = C.call(s, msgs2, timeout=360)
SP.record({"arm": arm, "model": model, "keyenv": s["keyenv"], "id": u["id"],
"variant": "reflow-pass2", "err": err2, "usage": usage2})
if not err2:
text = text2
outp.write_text(text, encoding="utf-8")
print(f" ok {len(text)}chars ${c:.4f} {dt}s → {outp.name}")
print(f" [{arm}] predicted total ≈ ${total_pred:.4f}; spent-by-key: {SP.by_key}")
def run_p0(scope):
outdir = ARMSDIR / "P0"; outdir.mkdir(exist_ok=True)
for u in chunk_units(scope):
(outdir / f"{u['id']}.txt").write_text(u["final_p0"], encoding="utf-8")
print(f"[P0] reused {len(chunk_units(scope))} finals ($0)")
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--arm", required=True)
ap.add_argument("--scope", default="trap", choices=["trap", "stage2"])
ap.add_argument("--model", default="glm-5")
ap.add_argument("--dry", action="store_true")
a = ap.parse_args()
if a.arm == "P0":
run_p0(a.scope); return
if a.arm == "P1a":
run_editor("P1a", "discourse", chunk_units(a.scope), a.model, dry=a.dry)
elif a.arm == "P1b":
run_editor("P1b", "baseline", chapter_units(a.scope), a.model, dry=a.dry)
elif a.arm == "P1c":
run_editor("P1c", "discourse", chapter_units(a.scope), a.model, dry=a.dry)
elif a.arm == "A-layout":
run_editor("A-layout", "discourse", chunk_units(a.scope), a.model, deformat=True, dry=a.dry)
elif a.arm == "A-2pass":
run_editor("A-2pass", "baseline", chunk_units(a.scope), a.model, two_pass=True, dry=a.dry)
elif a.arm in ("A-ref-prev", "A-ref-next", "A-ref-both"):
ref = build_reference(a.arm)
run_editor(a.arm, "discourse", [u for u in chunk_units(a.scope) if u["id"] in ref],
a.model, reference_by=ref, dry=a.dry)
elif a.arm in ("F-base", "F-disc"):
variant = "baseline" if a.arm == "F-base" else "discourse"
run_editor(a.arm, variant, chunk_units(a.scope), a.model, dry=a.dry)
else:
raise SystemExit(f"unknown arm {a.arm}")
def build_reference(kind: str) -> dict:
"""±1 reference для кросс-граничных трапов (T5 24.1←24.0, T6 11.1←11.0). prev/next/both."""
units = {u["id"]: u for u in chunk_units("trap")}
pairs = [("24.0", "24.1"), ("11.0", "11.1")] # (prev_chunk, target_chunk)
ref = {}
for prev, tgt in pairs:
parts = []
if kind in ("A-ref-prev", "A-ref-both"):
ptail = "\n".join(units[prev]["source"].split("\n")[-6:])
ttail = "\n".join(units[prev]["final_p0"].split("\n")[-6:])
parts.append("[ПРЕД-ИСХОДНИК]\n" + ptail + "\n[ПРЕД-ПЕРЕВОД]\n" + ttail)
if kind in ("A-ref-next", "A-ref-both"):
# next-source: следующий чанк того же trap-набора, если есть (для 24.1/11.1 нет — пропуск)
pass
if parts:
ref[tgt] = "\n\n".join(parts)
return ref
if __name__ == "__main__":
main()