#!/usr/bin/env python3
"""exp15/Q4a — paired 2x2 generator (B-hybrid, orchestrator-ratified 2026-07-18).

Generates the full square {flash, pro} x {raw draft, +glm-editor}, x2 draft seeds, on the 7 exp14
meaning-trap chunks. PAIR INTEGRITY IS SUPREME (orchestrator pin): both flash and pro drafts get
BYTE-IDENTICAL prompts + injection — the exp14 injected_ids reconstruction (bilingual_glossary for
the draft, editor_constraint for the editor), imperfect (no sticky) but SHARED by both sides; we do
NOT "improve" it with the S2' mem_select port. The ONLY variable in the draft row is the draft model.

Assembly (exp14 harness, matches the saved flash-draft provenance):
  draft  : messages_with_injection(translator_system(), bilingual_glossary, translator_user(src))
           deepseek-v4-{flash|pro}, temp 0.3, thinking-ON (never disabled — echo-mine mandate)
  editor : messages_with_injection(editor_system("discourse"), editor_constraint, editor_user(src,draft))
           glm-5, temp 0.4, thinking-OFF  (same editor for both sides -> editor-delta is model-clean)

Why regenerate the flash side too (vs reuse on-disk r["draft"]/arm C): the on-disk flash came from
the Go backend (real sticky injection); regenerating flash with the SAME reconstruction assembly as
pro GUARANTEES the flash<->pro pairing the orchestrator ranks above reuse. On-disk backend flash is
retained (records.json / exp14b arm C) as a provenance cross-check, DET-scored in q4a_score.py.

Valley guard: deepseek-v4-pro calls are BLOCKED in DeepSeek peak windows (UTC 01-04 & 06-10, the
x2 surge) — abort with a defer message; flash allowed anytime. Per-call predicted-cost gate + own
ledger (every attempt incl. failures persisted, D30.10). Resumable (skip non-empty existing files).
"""
from __future__ import annotations
import argparse
import json
import sys
import time
from datetime import datetime, timezone
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "exp14"))
import exp14_common as C
import exp14_prompts as P

SD = Path("/home/ubuntu/books/gu-zhenren/exp15")
OUT = SD / "q4a"
LEDGER = SD / "q4a_gen_costs.jsonl"
RECS = {(r["chapter"], r["chunk_idx"]): r
        for r in json.load(open("/home/ubuntu/books/gu-zhenren/rerun/records.json"))}
INJ = json.load(open("/home/ubuntu/books/gu-zhenren/exp14/injection_blocks.json"))

TRAP_CHUNKS = ["6.0", "7.0", "9.1", "10.1", "16.0", "17.0", "19.0"]   # 9 meaning-traps live here
SIDES = {"flash": "deepseek-v4-flash", "pro": "deepseek-v4-pro"}
SEEDS = [0, 1, 2]
DRAFT_TEMP, EDIT_TEMP = 0.3, 0.4
# DRAFT_MAX raised 8000->16000: deepseek-v4-pro reasoning can consume the whole 8000 cap and TRUNCATE the
# translation (seen: pro_raw/7.0.s0 finish=length, reasoning starved content -> a false "omission" of the
# lost tail; adversarial verification 2026-07-18). finish=length is now REJECTED in _gen (never accepted).
DRAFT_MAX, EDIT_MAX = 16000, 12000
PEAK_UTC_HOURS = {1, 2, 3, 6, 7, 8, 9}     # DeepSeek surge windows (01-04 & 06-10 UTC) — pro forbidden
GEN_HARD_STOP = 1.10                         # cumulative gen ceiling ($; leaves room for judging under $2)

CAPS = {"deepseek-v4-flash": 0.03, "deepseek-v4-pro": 0.06, "glm-5": 0.08}


def _now_utc_hour():
    return datetime.now(timezone.utc).hour


def _src_inj(cid):
    ch, ck = cid.split("."); k = (int(ch), int(ck))
    b = INJ.get(f"{ch}/{ck}", {})
    return RECS[k]["source"], b.get("bilingual_glossary", ""), b.get("editor_constraint", "")


def _ledger_total():
    if not LEDGER.exists():
        return 0.0
    t = 0.0
    for line in LEDGER.read_text(encoding="utf-8").splitlines():
        try:
            t += json.loads(line).get("cost", 0.0)
        except Exception:
            pass
    return t


def _gen(sp, model, messages, temp, max_out, meta):
    """Gate -> call -> persist EVERY attempt (incl. gate-block/error). Returns text or None."""
    in_est = sum(len(m["content"]) for m in messages) // 2
    ok, pred = sp.guard(model, in_est, max_out)
    if not ok:
        sp.record({**meta, "model": model, "keyenv": model, "usage": {},
                   "err": f"per-call gate pred ${pred:.4f} > cap ${CAPS.get(model)}", "gate_block": True})
        print(f"    [GATE BLOCK] {meta} pred ${pred:.4f}")
        return None
    total = _ledger_total()
    if total + pred > GEN_HARD_STOP:
        print(f"    [GEN HARD-STOP] {total:.3f}+{pred:.4f} > {GEN_HARD_STOP} — stopping generation")
        return "__STOP__"
    s = C.spec(model, temp=temp, max_tokens=max_out)
    text, err, usage = C.call(s, messages)
    fin = usage.get("finish_reason")
    if not err and fin == "length":     # truncated output (reasoning ate the cap) — NOT a complete draft
        err = f"truncated|finish=length|completion={usage.get('completion_tokens')}"
    sp.record({**meta, "model": model, "keyenv": model, "usage": usage, "err": err})
    if err or not text:
        print(f"    [BAD] {meta} err={err} finish={fin}")
        return None
    return text


def run(limit_chunks=0, sanity=False):
    OUT.mkdir(parents=True, exist_ok=True)
    for cell in ("flash_raw", "pro_raw", "flash_glm", "pro_glm"):
        (OUT / cell).mkdir(exist_ok=True)
    sp = C.Spender(LEDGER, CAPS)
    # sanity gate: FIRST chunk, seed 0 only, but BOTH sides -> flash+pro draft AND editor (4 cells), so
    # the pro/deepseek path (content non-empty, thinking-ON) is exercised before the full valley run.
    chunks = TRAP_CHUNKS[:1] if sanity else (TRAP_CHUNKS[:limit_chunks] if limit_chunks else TRAP_CHUNKS)
    seeds = [0] if sanity else SEEDS
    ed_sys = P.editor_system("discourse")
    tr_sys = P.translator_system()
    made, skipped = 0, 0
    for cid in chunks:
        src, gloss, edconstr = _src_inj(cid)
        tr_user = P.translator_user(src)
        for side, dmodel in SIDES.items():
            for seed in seeds:
                # ---- draft ----
                dpath = OUT / f"{side}_raw" / f"{cid}.s{seed}.txt"
                if dpath.exists() and dpath.read_text(encoding="utf-8").strip():
                    draft = dpath.read_text(encoding="utf-8"); skipped += 1
                else:
                    if dmodel == "deepseek-v4-pro" and _now_utc_hour() in PEAK_UTC_HOURS:
                        print(f"  [VALLEY DEFER] pro draft {cid} s{seed}: UTC hour {_now_utc_hour()} in peak "
                              f"{sorted(PEAK_UTC_HOURS)} — rerun in valley."); return
                    msgs = C.messages_with_injection(tr_sys, gloss, tr_user)
                    draft = _gen(sp, dmodel, msgs, DRAFT_TEMP, DRAFT_MAX,
                                 {"cell": f"{side}_raw", "chunk": cid, "seed": seed, "stage": "draft"})
                    if draft == "__STOP__":
                        return
                    if not draft:
                        continue
                    dpath.write_text(draft, encoding="utf-8"); made += 1
                    print(f"  {side}_raw {cid} s{seed}: {len(draft)}c  gen-cum ${_ledger_total():.4f}")
                # ---- editor over that draft ----
                epath = OUT / f"{side}_glm" / f"{cid}.s{seed}.txt"
                if epath.exists() and epath.read_text(encoding="utf-8").strip():
                    skipped += 1
                else:
                    emsgs = C.messages_with_injection(ed_sys, edconstr, P.editor_user(src, draft))
                    final = _gen(sp, "glm-5", emsgs, EDIT_TEMP, EDIT_MAX,
                                 {"cell": f"{side}_glm", "chunk": cid, "seed": seed, "stage": "edit"})
                    if final == "__STOP__":
                        return
                    if not final:
                        continue
                    epath.write_text(final, encoding="utf-8"); made += 1
                    print(f"  {side}_glm {cid} s{seed}: {len(final)}c  gen-cum ${_ledger_total():.4f}")
    print(f"\ngen done: {made} made, {skipped} reused. gen total ${_ledger_total():.4f}")


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--limit-chunks", type=int, default=0)
    ap.add_argument("--sanity", action="store_true",
                    help="gate: 1 chunk, seed 0, BOTH sides (flash+pro draft+editor = 4 cells)")
    a = ap.parse_args()
    print(f"[q4a-gen] UTC hour {_now_utc_hour()} (peaks {sorted(PEAK_UTC_HOURS)}); "
          f"gen ledger total ${_ledger_total():.4f}; hard-stop ${GEN_HARD_STOP}")
    run(a.limit_chunks, a.sanity)


if __name__ == "__main__":
    main()
