#!/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()