#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
pilot_icd11_v5.py  ——  査読対応・新規実験ランナー（v4 の拡張版）
=================================================================
3通の査読が求める「新規データ取得を要する」項目に対応する実験用スクリプトです。
v4 (本番) の検証済みロジックを土台に、次の3点を追加・変更しています:

  (A) k>=5 反復           … 査読2.4: 言語内のブレ(within-language)を測り、
                            言語間(between-language)効果と分散分解する。temperature は
                            0 と >0 の両方を選べる（temperature 0 でも商用APIは非決定的）。
  (B) 全6言語ネイティブ指示 … 査読2.5: zh/ar/ru も指示文を内容言語に統一し、
                            指示文言語の交絡を除く。
  (C) 定義長フィルタ       … 査読2.6: 6言語すべてで定義が存在し、長さが同等(相対差が
                            閾値以内)のエンティティに限定した感度分析を可能にする。

★重要★ 出力ファイル名は v4 と分離しています（entities_v5.json / results_v5.csv）。
       v4 の本番データ（entities_prod.json / results_prod.csv）は上書きしません。

使い方（ユーザPCの who フォルダ。v4 と同じ5つの環境変数を設定）:
    pip install requests numpy
    # Windows 例:
    #   set ICD_CLIENT_ID=...      (引用符なし、ウィンドウを閉じると消える)
    #   set ICD_CLIENT_SECRET=...
    #   set OPENAI_API_KEY=...
    #   set GOOGLE_API_KEY=...
    #   set ANTHROPIC_API_KEY=...
    python pilot_icd11_v5.py

レジューム対応: 途中で止めても、再実行すれば成功済み (entity,lang,model,rep) はスキップ。

注意: k=5 にすると API 呼び出しは v4 の5倍（300×6×6×5=54,000）。コストと時間が増えます。
      まず SUBSET_PER_CHAPTER を使って代表サブセット（例: 各章2件=46件）で回すことを推奨。
      46×6×6×5 = 8,280 呼び出し程度で分散分解の予備評価が可能です。
"""

import os, re, csv, json, time, random, statistics
from datetime import datetime
from collections import defaultdict
from concurrent.futures import ThreadPoolExecutor, as_completed

try:
    import requests
except ImportError:
    raise SystemExit("requests が必要です: pip install requests")
try:
    import numpy as np
except ImportError:
    raise SystemExit("numpy が必要です: pip install numpy")

# ============================== 設定 ==============================
ICD_CLIENT_ID     = os.environ.get("ICD_CLIENT_ID", "")
ICD_CLIENT_SECRET = os.environ.get("ICD_CLIENT_SECRET", "")
ICD_RELEASE       = "2025-01"
ICD_LINEARIZATION = "mms"

LANGUAGES   = ["en", "es", "fr", "zh", "ar", "ru"]

# ---- 新規実験のパラメータ（ここを編集して条件を変える）----
N_ENTITIES         = 300     # 完全版。まずは代表サブセットを推奨（下記）
SUBSET_PER_CHAPTER = 2       # >0 にすると各章この件数だけに絞る（0で全件）。査読2.4の予備に
K_REPS             = 5       # 査読2.4: 言語内反復。分散分解に最低5推奨
TEMPERATURE        = 0.0     # 0 と 0.7 など両方試すと「温度0でも非決定的」を実証できる
NATIVE_INSTRUCTIONS_ALL_LANGS = True   # 査読2.5: 全6言語をネイティブ指示に
DEF_LEN_MATCH      = False   # 査読2.6: True で「6言語の定義長が相対差±RATIO以内」に限定
DEF_LEN_RATIO      = 0.5     # 例: 0.5 = 各言語の定義長が英語の50%〜200%に収まるもののみ
MIN_DEF_LEN        = 60      # 英語定義の最小長（v4と同じ）
SEED               = 42
# ----------------------------------------------------------------

MODELS = [
    ("openai",     "gpt-5.4-nano"),
    ("openai",     "gpt-5.4"),
    ("google",     "gemini-2.5-flash-lite"),
    ("google",     "gemini-2.5-flash"),
    ("anthropic",  "claude-haiku-4-5"),
    ("anthropic",  "claude-sonnet-4-6"),
]
# 査読2.3対応: これは「価格/規模に基づく記述ラベル」であって能力の検証済み尺度ではない
LABEL = {
    "gpt-5.4-nano": "budget",          "gpt-5.4": "mid",
    "gemini-2.5-flash-lite": "budget", "gemini-2.5-flash": "budget-mid",
    "claude-haiku-4-5": "budget-mid",  "claude-sonnet-4-6": "mid",
}

ANTHROPIC_API_KEY = os.environ.get("ANTHROPIC_API_KEY", "")
OPENAI_API_KEY    = os.environ.get("OPENAI_API_KEY", "")
GOOGLE_API_KEY    = os.environ.get("GOOGLE_API_KEY", "")

PRICE = {  # (input $/1M, output $/1M)  ★必ず最新の価格表で確認★
    "gpt-5.4-nano": (0.20, 1.25), "gpt-5.4": (2.50, 15.00),
    "gemini-2.5-flash-lite": (0.10, 0.40), "gemini-2.5-flash": (0.30, 2.50),
    "claude-haiku-4-5": (1.00, 5.00), "claude-sonnet-4-6": (3.00, 15.00),
}
JPY_PER_USD = 155.0

LLM_WORKERS      = 6
ICD_WORKERS      = 8
MAX_RETRIES      = 4
CONSEC_ERR_LIMIT = 8

ENTITIES_CACHE = "entities_v5.json"     # ★ v4 と分離
RESULTS_CSV    = "results_v5.csv"       # ★ v4 と分離

SKIP_CHAPTER_KEYWORDS = ["external cause", "extension", "supplementary",
                         "provisional", "functioning"]
RESIDUAL_TITLE_KEYWORDS = ["unspecified", "other specified", "not elsewhere",
                           "without specification", "other forms", "site unspecified"]
CODE_RE = re.compile(r'\b[0-9A-Z]{1,2}[0-9A-Z][0-9A-Z](?:\.[0-9A-Z]+)?\b')

# ---- 指示文: 全6言語ネイティブ（査読2.5）。en/es/fr は v4 と同一、zh/ar/ru を追加 ----
INSTRUCTION_BY_LANG = {
    "en": ('You are a clinical coder. Read the clinical description and return the single '
           'best ICD-11 MMS code for the condition it describes. Respond ONLY as JSON: '
           '{"code":"XXXX"}. No explanation.\n\nDescription: {DESCRIPTION}'),
    "es": ('Eres un codificador clínico. Lee la descripción clínica y devuelve el único '
           'mejor código ICD-11 MMS de la afección descrita. Responde SOLO como JSON: '
           '{"code":"XXXX"}. Sin explicación.\n\nDescripción: {DESCRIPTION}'),
    "fr": ('Vous êtes un codeur clinique. Lisez la description clinique et renvoyez le '
           'meilleur code ICD-11 MMS unique de l’affection décrite. Répondez UNIQUEMENT '
           'en JSON : {"code":"XXXX"}. Aucune explication.\n\nDescription : {DESCRIPTION}'),
    # --- 以下、査読2.5 対応で新規追加（ネイティブ話者の確認を推奨）---
    "zh": ('你是一名临床编码员。请阅读以下临床描述，返回最匹配的单个 ICD-11 MMS 编码。'
           '仅以 JSON 格式回答：{"code":"XXXX"}。不要解释。\n\n描述：{DESCRIPTION}'),
    "ar": ('أنت مُرمِّز سريري. اقرأ الوصف السريري وأعد رمز ICD-11 MMS الأنسب الواحد '
           'للحالة الموصوفة. أجب فقط بصيغة JSON: {"code":"XXXX"}. بدون تفسير.'
           '\n\nالوصف: {DESCRIPTION}'),
    "ru": ('Вы — клинический кодировщик. Прочитайте клиническое описание и верните один '
           'наиболее подходящий код ICD-11 MMS для описанного состояния. Ответьте ТОЛЬКО '
           'в формате JSON: {"code":"XXXX"}. Без пояснений.\n\nОписание: {DESCRIPTION}'),
}
# ネイティブ指示を使わない場合(=v4再現)のフォールバック先
def instruction_for(lang):
    if NATIVE_INSTRUCTIONS_ALL_LANGS:
        return INSTRUCTION_BY_LANG.get(lang, INSTRUCTION_BY_LANG["en"])
    # v4挙動: en/es/fr のみネイティブ、他は英語指示
    return INSTRUCTION_BY_LANG.get(lang, INSTRUCTION_BY_LANG["en"]) if lang in ("en", "es", "fr") \
        else INSTRUCTION_BY_LANG["en"]

RUN_TS = datetime.now().strftime("%Y-%m-%dT%H:%M:%S")

# ============================== ICD-API ==============================
def get_icd_token():
    r = requests.post("https://icdaccessmanagement.who.int/connect/token",
                      data={"client_id": ICD_CLIENT_ID, "client_secret": ICD_CLIENT_SECRET,
                            "scope": "icdapi_access", "grant_type": "client_credentials"},
                      timeout=30)
    r.raise_for_status()
    return r.json()["access_token"]

def icd_get(url, token, lang):
    h = {"Authorization": f"Bearer {token}", "Accept": "application/json",
         "Accept-Language": lang, "API-Version": "v2"}
    r = requests.get(url, headers=h, timeout=30)
    r.raise_for_status()
    return r.json()

def _title(e):      return (e.get("title") or {}).get("@value", "") or ""
def _definition(e): return (e.get("definition") or {}).get("@value", "") or ""

def is_eligible(e):
    if not e.get("code"): return False
    if e.get("child"):    return False
    d = _definition(e)
    if len(d) < MIN_DEF_LEN: return False
    t = _title(e).lower()
    if any(k in t for k in RESIDUAL_TITLE_KEYWORDS): return False
    return True

def fetch_entities_parallel(urls, token, lang="en", workers=ICD_WORKERS):
    out = {}
    def one(u):
        try:    return u, icd_get(u, token, lang)
        except Exception: return u, None
    with ThreadPoolExecutor(max_workers=workers) as ex:
        for u, e in ex.map(one, urls):
            if e is not None: out[u] = e
    return out

def sample_entities(token, n, seed):
    """各章の子孫を include=descendant で一括取得→適格判定→章ごとにクォータ抽出。
       SUBSET_PER_CHAPTER>0 なら章ごとにその件数で打ち切り(査読2.4 の代表サブセット)。"""
    random.seed(seed)
    root = icd_get(f"https://id.who.int/icd/release/11/{ICD_RELEASE}/{ICD_LINEARIZATION}",
                   token, "en")
    chapters = root.get("child", [])
    usable = []
    for ch_url in chapters:
        ch = icd_get(ch_url, token, "en")
        if any(k in _title(ch).lower() for k in SKIP_CHAPTER_KEYWORDS):
            continue
        usable.append(ch_url)
    if SUBSET_PER_CHAPTER > 0:
        per = SUBSET_PER_CHAPTER
    else:
        per = max(1, -(-n // len(usable)))   # ceil
    picked = []
    for ch_url in usable:
        try:
            chd = icd_get(ch_url + "?include=descendant", token, "en")
        except Exception:
            continue
        desc_urls = chd.get("descendant", []) or []
        random.shuffle(desc_urls)
        got = []
        for batch_start in range(0, len(desc_urls), 40):
            batch = desc_urls[batch_start:batch_start + 40]
            ents = fetch_entities_parallel(batch, token, "en")
            for u in batch:
                e = ents.get(u)
                if e and is_eligible(e):
                    got.append({"url": u, "code": e["code"], "title_en": _title(e)})
                    if len(got) >= per: break
            if len(got) >= per: break
        picked.extend(got)
    if SUBSET_PER_CHAPTER == 0:
        random.shuffle(picked)
        picked = picked[:n]
    print(f"  標本: {len(picked)} 件（{len(usable)}章 × 最大{per}件/章）", flush=True)
    return picked

def fetch_all_localized(entities, token):
    """各エンティティの6言語 title/definition を取得して loc に格納。"""
    def one(ent):
        loc = {}
        for lang in LANGUAGES:
            try:
                e = icd_get(ent["url"], token, lang)
                loc[lang] = {"title": _title(e), "definition": _definition(e)}
            except Exception:
                loc[lang] = {"title": "", "definition": ""}
        ent["loc"] = loc
        return ent
    out = []
    with ThreadPoolExecutor(max_workers=ICD_WORKERS) as ex:
        for ent in ex.map(one, entities):
            out.append(ent)
    return out

def apply_def_len_filter(entities):
    """査読2.6: 6言語すべてに定義があり、各言語の定義長が英語比 [RATIO, 1/RATIO] に収まる
       エンティティだけに限定する。"""
    if not DEF_LEN_MATCH:
        return entities
    kept = []
    for ent in entities:
        loc = ent.get("loc", {})
        if any(not (loc.get(l, {}).get("definition") or "").strip() for l in LANGUAGES):
            continue
        en_len = len(loc["en"]["definition"])
        if en_len == 0: continue
        ok = True
        for l in LANGUAGES:
            ll = len(loc[l]["definition"])
            ratio = ll / en_len
            if ratio < DEF_LEN_RATIO or ratio > 1.0 / DEF_LEN_RATIO:
                ok = False; break
        if ok: kept.append(ent)
    print(f"  定義長フィルタ後: {len(kept)} / {len(entities)} 件（英語比 {DEF_LEN_RATIO}〜{1/DEF_LEN_RATIO:.1f}）", flush=True)
    return kept

def load_or_build_entities(token):
    if os.path.exists(ENTITIES_CACHE):
        data = json.load(open(ENTITIES_CACHE, encoding="utf-8"))
        print(f"  キャッシュ {ENTITIES_CACHE} を使用（{len(data['entities'])}件）", flush=True)
        return apply_def_len_filter(data["entities"])
    print("  標本抽出中 ...", flush=True)
    ents = sample_entities(token, N_ENTITIES, SEED)
    print("  6言語ローカライズ取得中 ...", flush=True)
    ents = fetch_all_localized(ents, token)
    json.dump({"release": ICD_RELEASE, "languages": LANGUAGES, "n": len(ents),
               "subset_per_chapter": SUBSET_PER_CHAPTER, "entities": ents},
              open(ENTITIES_CACHE, "w", encoding="utf-8"), ensure_ascii=False, indent=1)
    print(f"  保存: {ENTITIES_CACHE}", flush=True)
    return apply_def_len_filter(ents)

# ============================== LLM 呼び出し ==============================
def call_anthropic(model, prompt):
    r = requests.post("https://api.anthropic.com/v1/messages",
        headers={"x-api-key": ANTHROPIC_API_KEY, "anthropic-version": "2023-06-01",
                 "content-type": "application/json"},
        json={"model": model, "max_tokens": 128, "temperature": TEMPERATURE,
              "messages": [{"role": "user", "content": prompt}]}, timeout=60)
    r.raise_for_status()
    j = r.json()
    return "".join(b.get("text", "") for b in j.get("content", [])), j.get("model", model)

def call_openai(model, prompt):
    r = requests.post("https://api.openai.com/v1/chat/completions",
        headers={"Authorization": f"Bearer {OPENAI_API_KEY}", "Content-Type": "application/json"},
        json={"model": model, "temperature": TEMPERATURE,
              "messages": [{"role": "user", "content": prompt}]}, timeout=60)
    r.raise_for_status()
    j = r.json()
    return j["choices"][0]["message"]["content"], j.get("model", model)

def call_google(model, prompt):
    r = requests.post(
        f"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent",
        headers={"x-goog-api-key": GOOGLE_API_KEY, "Content-Type": "application/json"},
        json={"contents": [{"parts": [{"text": prompt}]}],
              "generationConfig": {"temperature": TEMPERATURE, "maxOutputTokens": 256,
                                   "thinkingConfig": {"thinkingBudget": 0}}}, timeout=60)
    r.raise_for_status()
    j = r.json()
    cand = (j.get("candidates") or [{}])[0]
    parts = (cand.get("content") or {}).get("parts") or [{}]
    return "".join(p.get("text", "") for p in parts), j.get("modelVersion", model)

def call_llm(provider, model, prompt):
    if provider == "anthropic": return call_anthropic(model, prompt)
    if provider == "openai":    return call_openai(model, prompt)
    if provider == "google":    return call_google(model, prompt)
    raise ValueError(provider)

def llm_call_with_retry(provider, model, prompt):
    last = ""
    for attempt in range(MAX_RETRIES):
        try:
            txt, ver = call_llm(provider, model, prompt)
            return txt, ver, ""
        except requests.exceptions.HTTPError as e:
            code = e.response.status_code if e.response is not None else 0
            last = f"HTTP {code}"
            if code == 429 or code >= 500:
                time.sleep(min(2 ** attempt, 30)); continue
            return "", "", last
        except Exception as e:
            last = type(e).__name__
            time.sleep(min(2 ** attempt, 15)); continue
    return "", "", last

def normalize_code(c):
    if not c: return ""
    c = str(c).upper().strip().strip('"').strip("'").rstrip(".").strip()
    return c.split()[0] if c.split() else c

def parse_code(text):
    if not text: return ""
    try:
        j = json.loads(text)
        if isinstance(j, dict) and "code" in j:
            return normalize_code(j["code"])
    except Exception:
        pass
    m = re.search(r'"code"\s*:\s*"([^"]+)"', text)
    if m: return normalize_code(m.group(1))
    m = CODE_RE.search(text or "")
    return normalize_code(m.group(0)) if m else ""

def grade(pred, gold):
    p, g = normalize_code(pred), normalize_code(gold)
    if not p: return "empty"
    if p == g: return "exact"
    ps, gs = p.split(".")[0], g.split(".")[0]
    if ps == gs: return "stem"
    if ps[:3] == gs[:3]: return "prefix3"
    if ps[:1] == gs[:1]: return "chapter"
    return "miss"

# ============================== レジューム ==============================
FIELDS = ["run_ts", "entity_url", "gold_code", "title_en", "lang", "provider",
          "model", "model_version", "label", "temperature", "rep",
          "pred_code", "grade", "raw_text", "error"]

def load_done_and_rows():
    done, rows = set(), []
    if os.path.exists(RESULTS_CSV):
        for r in csv.DictReader(open(RESULTS_CSV, encoding="utf-8")):
            rows.append(r)
            if not r.get("error"):
                done.add((r["entity_url"], r["lang"], r["model"], str(r["rep"])))
    return done, rows

def open_results_appender():
    exists = os.path.exists(RESULTS_CSV)
    f = open(RESULTS_CSV, "a", newline="", encoding="utf-8")
    w = csv.DictWriter(f, fieldnames=FIELDS)
    if not exists: w.writeheader()
    return f, w

def preflight(models):
    print("\n--- 事前接続テスト ---", flush=True)
    ok = []
    test = INSTRUCTION_BY_LANG["en"].replace("{DESCRIPTION}",
            "A chronic disease characterized by high blood glucose due to insulin deficiency.")
    for prov, model in models:
        txt, ver, err = llm_call_with_retry(prov, model, test)
        if err: print(f"  ✗ {model}: {err}（このモデルはスキップ）", flush=True)
        else:
            ok.append((prov, model))
            print(f"  ✓ {model} → 例: {parse_code(txt) or '(空)'} / 版 {ver}", flush=True)
    if not ok:
        print("  全モデル接続失敗。APIキー/残高を確認してください。", flush=True)
        return None
    return ok

def estimate_cost(rows):
    n = len(rows)
    usd = 0.0
    for r in rows:
        ip, op = PRICE.get(r["model"], (0, 0))
        usd += (300 * ip + 30 * op) / 1_000_000   # 粗い推定（入力~300tok, 出力~30tok）
    return usd, usd * JPY_PER_USD

# ============================== 分散分解（査読2.4の核心） ==============================
def variance_decomposition(rows):
    """言語内(within-language)反復のブレ と 言語間(between-language)差 を分離する。
       各 (model, entity) について:
         - 言語ごとに k 反復の最頻章を取り、反復間の不一致率 = within-language instability
         - 言語間の章のばらつき = between-language instability
       これにより『言語間差がノイズを超えるか』を評価できる（査読2.4）。"""
    def chap(c): c = normalize_code(c); return c[:1] if c else ""
    # 整理: data[model][entity][lang] = [章ラベルの反復リスト]
    data = defaultdict(lambda: defaultdict(lambda: defaultdict(list)))
    for r in rows:
        if r.get("error"): continue
        data[r["model"]][r["entity_url"]][r["lang"]].append(chap(r["pred_code"]))
    print("\n==================== 分散分解（査読2.4） ====================", flush=True)
    print(f"{'model':24s} {'within-lang不一致':>16s} {'between-lang不一致':>18s} {'比(between/within)':>18s}", flush=True)
    for model in [m for _, m in MODELS if m in data]:
        within_rates = []   # 同一言語・反復間で章が割れた割合
        between_rates = []   # 言語間で(各言語の最頻章が)割れた割合
        for ent, langs in data[model].items():
            per_lang_mode = {}
            for lang, reps in langs.items():
                if len(reps) >= 2:
                    # 反復間の不一致 = 1 - (最頻値の割合)
                    mode = max(set(reps), key=reps.count)
                    within_rates.append(1 - reps.count(mode) / len(reps))
                    per_lang_mode[lang] = mode
                elif reps:
                    per_lang_mode[lang] = reps[0]
            modes = list(per_lang_mode.values())
            if len(modes) >= 2:
                mode_all = max(set(modes), key=modes.count)
                between_rates.append(1 - modes.count(mode_all) / len(modes))
        w = statistics.mean(within_rates) if within_rates else float("nan")
        b = statistics.mean(between_rates) if between_rates else float("nan")
        ratio = (b / w) if (w and w == w and w > 0) else float("nan")
        print(f"{model:24s} {w:16.3f} {b:18.3f} {ratio:18.2f}", flush=True)
    print("\n解釈: 比(between/within) が 1 を大きく超えるほど、言語間差が言語内ノイズを上回る＝"
          "『言語間効果は本物』の証拠（査読2.4への直接回答）。", flush=True)

# ============================== メイン ==============================
def main():
    t_start = time.time()
    print(f"=== pilot v5（査読対応・新規実験） 開始: {RUN_TS} ===", flush=True)
    print(f"  設定: K_REPS={K_REPS}, temperature={TEMPERATURE}, "
          f"全言語ネイティブ指示={NATIVE_INSTRUCTIONS_ALL_LANGS}, "
          f"定義長フィルタ={DEF_LEN_MATCH}, 各章={SUBSET_PER_CHAPTER or '全件'}", flush=True)
    assert ICD_CLIENT_ID and ICD_CLIENT_SECRET, "ICD_CLIENT_ID / ICD_CLIENT_SECRET を設定してください"

    working = preflight(MODELS)
    if working is None: return

    print("\nICD-API トークン取得中 ...", flush=True)
    token = get_icd_token()
    entities = load_or_build_entities(token)
    if not entities:
        print("適格エンティティ0件。フィルタ条件を緩めてください。", flush=True); return

    done, _ = load_done_and_rows()
    tasks = []
    for ent in entities:
        for lang in LANGUAGES:
            for (prov, model) in working:
                for rep in range(K_REPS):
                    if (ent["url"], lang, model, str(rep)) in done: continue
                    tasks.append((ent, lang, prov, model, rep))
    total_all = len(entities) * len(LANGUAGES) * len(working) * K_REPS
    print(f"\n残タスク {len(tasks)} / 全 {total_all}", flush=True)
    print(f"  ※ API呼び出し見込み {len(tasks)} 回。コスト/時間にご注意。", flush=True)

    fout, writer = open_results_appender()
    counters = {"ok": 0, "err": 0, "empty": 0}
    consec = defaultdict(int); warned = set()
    t0 = time.time()

    def work(task):
        ent, lang, prov, model, rep = task
        loc = ent["loc"][lang]
        desc = (loc["definition"] or "").strip() or (loc["title"] or ent.get("title_en") or "").strip()
        instr = instruction_for(lang)
        txt, ver, err = llm_call_with_retry(prov, model, instr.replace("{DESCRIPTION}", desc))
        pred = parse_code(txt) if not err else ""
        return task, txt, ver, pred, err

    if tasks:
        with ThreadPoolExecutor(max_workers=LLM_WORKERS) as ex:
            futs = {ex.submit(work, t): t for t in tasks}
            dn = 0
            for fut in as_completed(futs):
                task, txt, ver, pred, err = fut.result()
                ent, lang, prov, model, rep = task
                gold = str(ent["code"]).upper()
                writer.writerow({
                    "run_ts": RUN_TS, "entity_url": ent["url"], "gold_code": gold,
                    "title_en": ent.get("title_en", ""), "lang": lang, "provider": prov,
                    "model": model, "model_version": ver, "label": LABEL.get(model, ""),
                    "temperature": TEMPERATURE, "rep": rep, "pred_code": pred,
                    "grade": grade(pred, gold),
                    "raw_text": (txt or "").replace("\n", " ")[:200], "error": err,
                })
                fout.flush(); dn += 1
                if err:    counters["err"] += 1;   consec[model] += 1
                elif pred: counters["ok"] += 1;    consec[model] = 0
                else:      counters["empty"] += 1; consec[model] = 0
                if err and consec[model] >= CONSEC_ERR_LIMIT and model not in warned:
                    print(f"\n⚠ {model} が {consec[model]}回連続失敗。直近: {err}", flush=True)
                    warned.add(model)
                el = time.time() - t0; rate = dn / el if el > 0 else 0
                eta = (len(tasks) - dn) / rate if rate > 0 else 0
                print(f"\r  {dn}/{len(tasks)} ({dn/len(tasks):.0%}) | 経過{el/60:.1f}分 "
                      f"| 残り推定{eta/60:.0f}分 | OK{counters['ok']} ERR{counters['err']} 空{counters['empty']}",
                      end="", flush=True)
        print(flush=True)
    fout.close()

    _, rows = load_done_and_rows()

    # 健全性チェック
    print("\n--- 健全性チェック ---", flush=True)
    problem = False
    for (_, model) in MODELS:
        sub = [r for r in rows if r["model"] == model]
        if not sub: continue
        nerr = sum(1 for r in sub if r["error"]); rr = 1 - nerr / len(sub)
        if rr < 0.5:
            print(f"  ⚠ {model}: 応答率{rr:.0%}", flush=True); problem = True
    if not problem: print("  問題なし", flush=True)

    # 版の記録
    print("\n測定したモデル版:", flush=True)
    for m, v in sorted({(r["model"], r["model_version"]) for r in rows if r["model_version"]}):
        print(f"  {m} → {v}", flush=True)

    # 査読2.4: 分散分解
    variance_decomposition(rows)

    usd, jpy = estimate_cost(rows)
    el = time.time() - t_start
    print(f"\n概算コスト(累計{len(rows)}行): ≈ ${usd:.2f} ≈ ¥{jpy:,.0f}（★PRICE/レート要確認）", flush=True)
    print(f"総所要: {el/60:.1f}分   出力: {RESULTS_CSV}", flush=True)
    print("\n次の分析: 既存の recompute.py を results_v5.csv に向けて実行すれば、"
          "章レベルα・章正確性・CI を新条件で再計算できます。", flush=True)

if __name__ == "__main__":
    main()
