#!/usr/bin/env python3
"""記事 I（3 社の AI のくせ）の数値を、討論の記録の本文から規則だけで数える。

使い方（debatecross-cloud で実行）:
  python3 headless-experiments/tools/i_numbers.py \
      --set "本実験（記事 A・GPT-6.1 Sol Pro）=headless-results/exp1v2-final" \
      --set "参考（GPT-6.1 Sol 版）=headless-results/expB-sol-s1,headless-results/expB-sol-s2,headless-results/expB-sol-s3" \
      --set "参考（Gemini 4 Argon 版）=headless-results/expB-argon-s1,headless-results/expB-argon-s2,headless-results/expB-argon-s3" \
      --topics headless-experiments/topics-v2.jsonl \
      --out headless-experiments/exp1v2-final-inputs/i-numbers-2026-10-09.md

--set を省くと上の 2 組を使う。各組で同じ表を出す（2 組目以降は参考の別表）。

方針（docs/seo/README.md §4.6・proposals/article-i-ai-personalities-proposal-2026-10-05.md）:
- 正確さの順位は付けない（記事 B の主題）。誤り率・正誤判定はここでは一切使わない。
- AI の API は呼ばない。本文の文字列を正規表現で数えるだけ。語の一覧・正規表現はすべて下に明示する。
- 数字は「今回の版・条件（ウェブなし・日本語の解説文・この 15 テーマ）での傾向」。
- 揺れの幅: テーマ単位のクラスター・ブートストラップ（テーマを復元抽出、10,000 回、seed 1、95% 区間）。
- 書き手としてのくせ（Round 1 の長さ・字数指定）は a_numbers.py にあるので重複させない。
  ここでは Round 2（批評）と Round 3（統合）を中心にし、Round 1 は比の分母と取り込みの比較元にだけ使う。
"""
import argparse
import glob
import json
import os
import random
import re
import statistics
import unicodedata
from collections import Counter, defaultdict

# ───────────────────────── モデルの呼び名 ─────────────────────────
FAMILY = {"anthropic": "claude", "openai": "gpt", "google": "gemini"}
DISPLAY = {
    "anthropic/claude-sonnet-5.5": "Claude Sonnet 5.5",
    "openai/gpt-6.1-sol-pro": "GPT-6.1 Sol Pro",
    "openai/gpt-6.1-sol": "GPT-6.1 Sol",
    "google/gemini-3.8-flash": "Gemini 3.8 Flash",
    "google/gemini-4-argon": "Gemini 4 Argon",
}
ORDER = ["claude", "gpt", "gemini"]


def fam(model_id):
    return FAMILY.get(model_id.split("/")[0], model_id)


# 本文中でモデルを名前で指した箇所。ASCII だけを続け字に取る（「ClaudeとGPT」を 1 語にしないため）。
# google は「Google Scholar」などお題の語と紛れるので「google/gemini…」と「google回答・googleの回答」の形だけ数える。flash・sol 単独は数えない。
ALIAS_RE = re.compile(
    r"(?:anthropic/)?claude[a-z0-9.\-]*|sonnet[a-z0-9.\-]*|anthropic"
    r"|(?:openai/)?gpt[a-z0-9.\-]*|openai"
    r"|(?:google/)?gemini[a-z0-9.\-]*|google(?=\s*の?\s*(?:回答|案|系))",
    re.I,
)


def alias_family(tok):
    t = tok.lower()
    if t.startswith(("anthropic", "claude", "sonnet")):
        return "claude"
    if t.startswith(("openai", "gpt")):
        return "gpt"
    return "gemini"


def mentions(text):
    return [alias_family(m.group(0)) for m in ALIAS_RE.finditer(text)]


# 位置だけで指した呼び方（回答1・回答A など）。どのモデルかは記録から復元できない（入力順が保存されていない）ので、対象不明として件数だけ出す。
POS_LABEL_RE = re.compile(r"回答\s*[1-3１-３A-CＡ-Ｃ①-③](?!\s*[（(][^)）]*(?:claude|sonnet|anthropic|gpt|openai|gemini))", re.I)

# ───────────────────────── 形式の規則 ─────────────────────────
HEADING_RE = re.compile(r"^\s{0,3}(#{1,6})\s+(.*)$")
BOLD_LINE_RE = re.compile(r"^\s*\*\*([^*]{1,60})\*\*\s*[:：]?\s*$")
LIST_RE = re.compile(r"^\s*(?:[-*+・•]|\d{1,2}\s*[.．)）]|[（(]\d{1,2}[)）]|[①-⑳])\s*\S")
TABLE_ROW_RE = re.compile(r"^\s*\|.*\|\s*$")
TABLE_SEP_RE = re.compile(r"^\s*\|?\s*:?-{2,}")
CROSSCHECK_RE = re.compile(r"🔍\s*\**\s*クロスチェック")

# Round 2 の観点（プロンプトの 7 観点＋最終評価・総評）。見出しの文字にこの語があれば、その節とみなす（上から順に最初に当たったもの）。
ASPECTS = [
    ("praise", "妥当な点", r"妥当"),
    ("doubt", "疑問点", r"疑問|不正確|事実面|事実上の"),
    ("bias", "バイアス検出", r"バイアス"),
    ("miss", "見落とし", r"見落とし"),
    ("counter", "反対意見", r"反対意見|代替"),
    ("practice", "実務上の注意", r"実務"),
    ("improve", "改善提案", r"改善"),
    ("final", "最終評価", r"最終評価"),
    ("overall", "総評", r"総評|全体所見|全体評価"),
]
ASPECT_KEYS = [a[0] for a in ASPECTS[:7]]

# ───────────────────────── 語の一覧（Round 2 の表現） ─────────────────────────
# 見出し行（# で始まる行・太字だけの行）は数えない（「妥当な点」「疑問点」などプロンプト由来の見出し語を除くため）。
# 否定の形（不正確・正しくない・妥当でない など）は肯定語から外し、否定・訂正語に入れる。それ以上の文脈（「誤りはない」など）は区別しない。
LEX = {
    "肯定": [
        r"(?<!不)正確(?!で(?:は)?な|さを欠|性を欠|性に欠)",
        r"正しい|正しく(?!な)",
        r"(?<!不)適切(?!で(?:は)?な|さを欠)",
        r"妥当(?!性|で(?:は)?な)",
        r"的確",
        r"優れ",
        r"評価でき",
        r"同意",
        r"賛同",
        r"有用",
        r"明快",
    ],
    "否定・訂正": [
        r"誤り|誤っ|誤認|誤記",
        r"不正確",
        r"間違",
        r"不適切",
        r"正しくな",
        r"妥当で(?:は)?な",
        r"根拠(?:が|の)(?:ない|無い|乏し|薄)",
        r"不足",
        r"欠落|欠如|欠け",
        r"曖昧|あいまい",
        r"過大|過小",
        r"古い",
        r"混同",
        r"飛躍",
        r"疑問",
    ],
    "補足": [r"補足", r"追記", r"付記", r"補[うえいっ]", r"加筆"],
    "留保": [
        r"判断できな",
        r"確認できな",
        r"断定できな",
        r"検証できな",
        r"可能性が(?:ある|あり|高)",
        r"と思われ",
        r"かもしれ",
        r"要確認",
        r"確認が必要",
    ],
}
LEX_RE = {k: re.compile("|".join(f"(?:{p})" for p in v)) for k, v in LEX.items()}

# 自分の回答であることへの言及（自己評価の偏りへの断りなど）。「自己評価」単独は法令の用語（年次報告・自己評価）と紛れるので、偏り・甘さと結び付いた形だけ数える。
SELF_CAVEAT_RE = re.compile(
    r"自己評価(?:が|に|の|を|は)?(?:甘|バイアス|偏|割り引)|自己評価を含|自己評価による|自己選択|自己選好|自己採点|自己批判|自己バイアス"
    r"|自分自身|私自身|自回答|自身の回答|自分の回答|自分の出力|自分と同じ|自身と同じ|同一モデル|同一系統|同名モデル"
)

# 具体性（NFKC で全角数字を半角にしてから数える）
NUM_RE = re.compile(r"\d+(?:[.,]\d+)*")
DATE_RE = re.compile(r"(?:\d{4}|令和\s*\d{1,2}|平成\s*\d{1,2}|昭和\s*\d{1,2})\s*年(?:\s*\d{1,2}\s*月)?(?:\s*\d{1,2}\s*日)?")
LAW_RE = re.compile(r"第\s*\d+\s*条(?:\s*の\s*\d+)?(?:\s*第\s*\d+\s*項)?|\d+\s*条(?:\s*の\s*\d+)?|RFC\s*\d{3,5}|ISO(?:/IEC)?\s*\d{3,5}|ECMA-\d+|JIS\s*[A-Z]?\s*\d{3,5}")

# ───────────────────────── Round 3 の規則 ─────────────────────────
# 統合の補足説明（統合メモ）の始まり: 「統合の補足」「統合メモ」を含む 60 字以内の行（見出し・太字・【】の別を問わない）。
META_START_RE = re.compile(r"統合の補足|統合メモ")
META_ITEMS = [("合意点", r"合意点"), ("少数意見", r"少数意見"), ("意見分岐点", r"分岐"), ("採用基準", r"採用基準|採否")]
# 本文（統合メモより前）で討論の経過に触れた文: 討論・批評・他の AI・モデル名など。
PROCESS_RE = re.compile(r"討論|批評|各AI|他のAI|複数のAI|3つの回答|三つの回答|各回答|回答者|" + ALIAS_RE.pattern, re.I)

# 取り込みの比較に使う語（NFKC 後）
UNIT = r"(?:%|パーセント|年度|年|か月|ヶ月|カ月|箇月|月|週間|週|日間|日|時間|分|秒|万円|億円|円|万人|万|億|人|件|字|条|項|号|歳|倍|回|割|ビット|bit|バイト|byte|桁|世帯|都県|都道府県)"
NUMTOK_RE = re.compile(r"\d+(?:,\d{3})*(?:\.\d+)?\s*" + UNIT)
ASCII_TERM_RE = re.compile(r"[A-Za-z][A-Za-z0-9\-]*[A-Za-z0-9]")
KATA_TERM_RE = re.compile(r"[ァ-ヴー]{3,}")
QUOTE_TERM_RE = re.compile(r"「([^」\n]{2,30})」")
# 書式・ありふれた英字語は除く
ASCII_STOP = {"md", "the", "and", "or", "of", "to", "in", "is", "a", "an", "for", "on", "by", "with", "be"}


def nfkc(s):
    return unicodedata.normalize("NFKC", s)


def num_tokens(t):
    return {re.sub(r"[,\s]", "", m.group(0)) for m in NUMTOK_RE.finditer(nfkc(t))}


def term_tokens(t):
    t = nfkc(t)
    s = set()
    for m in ASCII_TERM_RE.finditer(t):
        w = m.group(0).lower()
        if len(w) >= 2 and w not in ASCII_STOP and not ALIAS_RE.fullmatch(w):
            s.add(w)
    s.update(m.group(0) for m in KATA_TERM_RE.finditer(t))
    s.update(m.group(1) for m in QUOTE_TERM_RE.finditer(t))
    return s


# ───────────────────────── 小道具 ─────────────────────────
def lines_of(t):
    return t.split("\n")


def is_heading(line):
    return bool(HEADING_RE.match(line) or BOLD_LINE_RE.match(line))


def aspect_of(text):
    for key, _, pat in ASPECTS:
        if re.search(pat, text):
            return key
    return None


def split_aspects(t):
    """Round 2 の本文を観点の節に分ける。返り値 {観点: 字数}・{観点: 箇条書きの数}。
    Markdown 見出しか太字だけの行に観点の語があれば、そこから次の観点見出しまでをその節とする。
    観点の語のない Markdown 見出しは、いま開いている観点見出しと同じか浅い階層なら節を閉じる（モデル名の見出しなど）。"""
    chars, items = Counter(), Counter()
    cur, cur_level = None, 99
    for line in lines_of(t):
        h = HEADING_RE.match(line)
        b = BOLD_LINE_RE.match(line)
        if h or b:
            text = h.group(2) if h else b.group(1)
            level = len(h.group(1)) if h else 7
            a = aspect_of(text)
            if a:
                cur, cur_level = a, level
                continue
            if h and level <= cur_level:
                cur, cur_level = None, 99
            continue
        if cur:
            chars[cur] += len(line)
            if LIST_RE.match(line):
                items[cur] += 1
    return chars, items


def final_section(t):
    """「最終評価」の語が最後に出る見出し（なければ最後の出現）以降の本文。"""
    idx = -1
    for m in re.finditer(r"最終評価", t):
        idx = m.start()
    return t[idx:] if idx >= 0 else ""


def final_pick(t, self_fam):
    """最終評価の最初の 1 文で選ばれたモデル。self / 他のモデル名 / tie（同等）/ unknown。"""
    sec = final_section(t)
    if not sec:
        return "none"
    sec = re.sub(r"^最終評価[】\]\s#*:：]*", "", sec).strip()
    sents = [x for x in re.split(r"(?<=[。])|\n", sec) if x.strip()]
    # 選んだことを書いた最初の文（なければ最初の文）
    first = next((x for x in sents if re.search(r"最も|最良|最優秀|選び|選ん|同等|甲乙つけ", x)), sents[0] if sents else "")
    if re.search(r"同等|甲乙つけ", first):
        return "tie"
    ms = mentions(first)
    if not ms:
        return "unknown"
    if len(set(ms)) > 1:
        # 「回答3(GPT)が最も優れ…回答1は…」のように複数出たら、「最も」「最良」「最優秀」「選び」の直前に最も近い名前を採る
        k = re.search(r"最も|最良|最優秀|選び|選ん", first)
        if k:
            before = [(m.start(), alias_family(m.group(0))) for m in ALIAS_RE.finditer(first) if m.start() < k.start()]
            after = [(m.start(), alias_family(m.group(0))) for m in ALIAS_RE.finditer(first) if m.start() >= k.start()]
            if before:
                return before[-1][1]
            if after:
                return after[0][1]
        return "unknown"
    return ms[0]


def body_lines(t):
    return [l for l in lines_of(t) if not is_heading(l)]


def split_meta(t):
    """Round 3 を本文と統合メモに分ける。統合メモの開始行の直前の区切り線（---）は本文に入れない。"""
    ls = lines_of(t)
    for i, l in enumerate(ls):
        if len(l.strip()) <= 60 and META_START_RE.search(l):
            body = "\n".join(ls[:i]).rstrip()
            body = re.sub(r"\n\s*(?:---+|\*\*\*+|___+)\s*$", "", body).rstrip()
            return body, "\n".join(ls[i:])
    return t.rstrip(), ""


def sentences(t):
    return [s for s in re.split(r"[。\n]", t) if s.strip()]


# ───────────────────────── 集計（クラスター・ブートストラップ） ─────────────────────────
def boot_ci(by_theme, n=10000, seed=1):
    """by_theme: {テーマ: (Σ分子, Σ分母)}。テーマを復元抽出し、Σ分子/Σ分母 の 2.5%・97.5% 点。"""
    themes = [t for t, (_, d) in by_theme.items()]
    if not themes:
        return None, None
    rnd = random.Random(seed)
    sums = [by_theme[t] for t in themes]
    k = len(sums)
    vals = []
    for _ in range(n):
        a = b = 0.0
        for _ in range(k):
            x, y = sums[rnd.randrange(k)]
            a += x
            b += y
        if b > 0:
            vals.append(a / b)
    vals.sort()
    if not vals:
        return None, None
    return vals[int(0.025 * len(vals))], vals[int(0.975 * len(vals)) - 1]


def agg(units):
    """units: [(テーマ, 分子, 分母)] → (点推定, 区間, テーマ別の値)"""
    th = defaultdict(lambda: [0.0, 0.0])
    for t, x, y in units:
        th[t][0] += x
        th[t][1] += y
    by = {t: (v[0], v[1]) for t, v in th.items() if v[1] > 0}
    a = sum(v[0] for v in by.values())
    b = sum(v[1] for v in by.values())
    est = a / b if b else None
    lo, hi = boot_ci(by)
    per_theme = {t: v[0] / v[1] for t, v in by.items()}
    return est, (lo, hi), per_theme


def fmt(x, kind):
    if x is None:
        return "-"
    if kind == "pct":
        return f"{x * 100:.0f}%"
    if kind == "int":
        return f"{x:,.0f}"
    if kind == "f1":
        return f"{x:.1f}"
    if kind == "f2":
        return f"{x:.2f}"
    return str(x)


# ───────────────────────── 記録の読み込み ─────────────────────────
def load_set(dirs):
    debates = []
    for d in dirs:
        for f in sorted(glob.glob(os.path.join(d, "0*.json"))):
            sj = json.load(open(f, encoding="utf-8"))
            if sj.get("status") != "completed":
                continue
            att = [a for a in sj["attempts"] if a.get("status") == "completed" and a.get("content")]
            r = {1: {}, 2: {}, 3: {}}
            for a in att:
                r[int(a["roundNo"])][a["model"]] = a
            debates.append(dict(file=f"{os.path.basename(d)}/{os.path.basename(f)}", theme=sj["questionId"], prompt=sj["config"]["prompt"],
                                synth=sj["config"]["synthesizer"], models=sj["config"]["models"], r=r))
    return debates


# ───────────────────────── Round 2 ─────────────────────────
def r2_units(debates):
    out = []
    for d in debates:
        for model, a in d["r"][2].items():
            t = a["content"]
            me = fam(model)
            ms = mentions(t)
            mc = Counter(ms)
            chars, items = split_aspects(t)
            bl = "\n".join(body_lines(t))
            lex = {k: len(rx.findall(bl)) for k, rx in LEX_RE.items()}
            nt = nfkc(t)
            # クロスチェックの宛先: 行内の名前（自分以外を優先）。名前がなければ、直前の見出し（# 行・太字だけの行）で名前が 1 体だけのものを宛先とする（ctx_ 付きで別に数える）。
            cc_lines, cc_targets = [], []
            ctx = None
            for l in lines_of(t):
                if is_heading(l):
                    hs = set(mentions(l))
                    ctx = next(iter(hs)) if len(hs) == 1 else (None if hs or HEADING_RE.match(l) else ctx)
                    continue
                if not CROSSCHECK_RE.search(l):
                    continue
                cc_lines.append(l)
                fs = mentions(l)
                others = [x for x in fs if x != me]
                if others:
                    cc_targets.append(others[0])
                elif me in fs:
                    cc_targets.append("self")
                elif ctx:
                    cc_targets.append("ctx_" + ("self" if ctx == me else ctx))
                else:
                    cc_targets.append("unknown")
            aspect_total = sum(chars[k] for k in ASPECT_KEYS)
            out.append(dict(
                theme=d["theme"], file=d["file"], fam=me, model=model, chars=len(t),
                list_items=sum(1 for l in lines_of(t) if LIST_RE.match(l)),
                headings=sum(1 for l in lines_of(t) if HEADING_RE.match(l)),
                cc=len(cc_lines), cc_targets=cc_targets,
                m_self=mc[me], m_other=sum(v for k, v in mc.items() if k != me), m_by=mc,
                pos_labels=len(POS_LABEL_RE.findall(t)),
                aspects_found=sum(1 for k in ASPECT_KEYS if chars[k] > 0),
                praise_chars=chars["praise"], doubt_chars=chars["doubt"], aspect_total=aspect_total,
                doubt_items=items["doubt"] + items["miss"],
                both_sections=chars["praise"] > 0 and chars["doubt"] > 0,
                lex=lex,
                nums=len(NUM_RE.findall(nt)), dates=len(DATE_RE.findall(nt)), laws=len(LAW_RE.findall(nt)),
                pick=final_pick(t, me), self_caveat=bool(SELF_CAVEAT_RE.search(t)),
            ))
    return out


R2_METRICS = [
    # key, 表示名, 単位ごとの (分子, 分母), 書式
    ("chars", "批評文の長さ（字）", lambda u: (u["chars"], 1), "int"),
    ("list_items", "箇条書き・番号つきの行の数", lambda u: (u["list_items"], 1), "f1"),
    ("doubt_items", "うち「疑問点」「見落とし」の節の箇条書き行", lambda u: (u["doubt_items"], 1), "f1"),
    ("cc", "クロスチェック（🔍）の数", lambda u: (u["cc"], 1), "f2"),
    ("cc_any", "クロスチェックを 1 つ以上書いた割合", lambda u: (1 if u["cc"] else 0, 1), "pct"),
    ("praise_share", "「妥当な点」の節が観点 7 節に占める字数の割合", lambda u: (u["praise_chars"], u["aspect_total"]), "pct"),
    ("doubt_share", "「疑問点」の節が観点 7 節に占める字数の割合", lambda u: (u["doubt_chars"], u["aspect_total"]), "pct"),
    ("pos_rate", "肯定語（1,000 字あたり）", lambda u: (u["lex"]["肯定"], u["chars"] / 1000), "f1"),
    ("neg_rate", "否定・訂正語（1,000 字あたり）", lambda u: (u["lex"]["否定・訂正"], u["chars"] / 1000), "f1"),
    ("neg_ratio", "否定・訂正語 ÷（肯定語＋否定・訂正語）", lambda u: (u["lex"]["否定・訂正"], u["lex"]["肯定"] + u["lex"]["否定・訂正"]), "pct"),
    ("supp_rate", "補足語（1,000 字あたり）", lambda u: (u["lex"]["補足"], u["chars"] / 1000), "f1"),
    ("hedge_rate", "留保語（1,000 字あたり）", lambda u: (u["lex"]["留保"], u["chars"] / 1000), "f1"),
    ("num_rate", "数字の並び（1,000 字あたり）", lambda u: (u["nums"], u["chars"] / 1000), "f1"),
    ("date_rate", "年・年月日（1,000 字あたり）", lambda u: (u["dates"], u["chars"] / 1000), "f1"),
    ("law_rate", "条文・規格番号（1,000 字あたり）", lambda u: (u["laws"], u["chars"] / 1000), "f2"),
    ("self_share", "モデル名の言及のうち自分の回答を指した割合", lambda u: (u["m_self"], u["m_self"] + u["m_other"]), "pct"),
    ("self_pick", "最終評価で自分の回答を選んだ割合", lambda u: (1 if u["pick"] == u["fam"] else 0, 1), "pct"),
    ("tie", "最終評価で「同等」とした割合", lambda u: (1 if u["pick"] == "tie" else 0, 1), "pct"),
    ("self_caveat", "自分の回答であることに触れた割合", lambda u: (1 if u["self_caveat"] else 0, 1), "pct"),
]

R2_DEFS = {
    "chars": "Round 2 の本文の文字数（Python の len、Markdown の記号を含む。a_numbers.py の charCount と同じ数え方）。",
    "list_items": "行頭が「- * + ・ •」「1. 1) (1) ①」の行の数。指摘の数の近似（1 行に 2 つ書いた指摘や、段落で書いた指摘は 1 つ以下に数えられる）。",
    "doubt_items": "「疑問点」「見落とし」の観点の節（下の節の規則で検出）にある箇条書き行の数。節を見出しで書かなかった批評は 0 になる。",
    "cc": "プロンプトが「他の AI の明確な事実誤認を正すときだけ使う」と定めた記法「🔍 クロスチェック」の出現数。形式が決まっているので数え漏れが少ない。",
    "cc_any": "クロスチェックを 1 つ以上含む批評の割合。",
    "praise_share": "見出し（# 行または太字だけの行）に観点の語（妥当／疑問・不正確・事実面／バイアス／見落とし／反対意見・代替／実務／改善）がある節を観点の節とし、7 観点の節の字数の合計に占める「妥当な点」の節の字数。観点の語のない見出し（モデル名など）は、同じか浅い階層なら節を閉じる。全体の比は字数の合算（テーマ内で合算した比をブートストラップ）。",
    "doubt_share": "同じ規則で「疑問点」の節（見出しに 疑問・不正確・事実面・事実上の）の字数の割合。",
    "pos_rate": "見出し行を除いた本文で、下の「語の一覧」の肯定語の正規表現に当たった数 ÷ 字数 × 1,000。否定の形（不正確・正しくない・妥当でない・不適切）は肯定語に数えない。「誤りはない」のような二重否定は区別しない。",
    "neg_rate": "同じく否定・訂正語の数 ÷ 字数 × 1,000。",
    "neg_ratio": "否定・訂正語 ÷（肯定語＋否定・訂正語）。全体の比は語数の合算。50% を超えると否定・訂正の語のほうが多い。",
    "supp_rate": "補足・追記・補う・付記・加筆 の数 ÷ 字数 × 1,000。",
    "hedge_rate": "「判断できない」「確認できない」「可能性がある」「かもしれない」「要確認」などの数 ÷ 字数 × 1,000（プロンプトは判断できない点を明記するよう求めている）。",
    "num_rate": "NFKC で正規化したあとの数字の並び（1,234・3.5 も 1 つ）の数 ÷ 字数 × 1,000。批評は Round 1 の数値を引用するので、新しく持ち込んだ数値の数ではない。",
    "date_rate": "「2023 年」「令和 5 年 10 月 1 日」の形の数 ÷ 字数 × 1,000。",
    "law_rate": "「第 32 条」「36 条」「RFC 8446」「ISO 8601」「ECMA-404」の形の数 ÷ 字数 × 1,000。",
    "self_share": "本文中でモデルを名前で指した箇所（claude・sonnet・anthropic・gpt・openai・gemini・google/gemini、大文字小文字を区別しない）のうち、批評者自身を指した割合。プロンプトは各回答に正式なモデル ID を付けて渡し、批評者にも自分の ID を伝えている。「回答1」「回答A」のような位置だけの呼び方は、記録に入力の順番が残っていないので対象不明として数えない。",
    "self_pick": "「最終評価」の語が最後に出た箇所から後で、選んだことを書いた文（「最も・最良・最優秀・選び・選ん・同等」を含む最初の文。なければ最初の文）に名前を挙げたモデルが批評者自身だった割合（批評者はどれが自分の回答かを知らされている）。名前が複数あるときは「最も・最良・最優秀・選び」の直前の名前を採る。どのモデルの回答が正確かを示すものではなく、批評者が何を選んだかの記録。",
    "tie": "同じ 1 文に「同等」「甲乙つけ」がある割合（プロンプトは選びきれないとき「同等」と書くよう求めている）。",
    "self_caveat": "本文のどこかに「自分自身」「私自身」「自回答」「自分の回答」「同一モデル」「自己選好」「自己批判」「自己評価が甘…」などがある割合（正規表現は付録）。自分の回答も批評の対象であることへの断り書き（「自己評価に甘くならないよう厳しめに見る」など）の有無の近似。「自己評価」単独は法令の用語と紛れるので数えない。",
}


# ───────────────────────── Round 3 ─────────────────────────
def r3_units(debates):
    out = []
    for d in debates:
        model = d["synth"]
        a = d["r"][3].get(model)
        if not a:
            continue
        t = a["content"]
        body, meta = split_meta(t)
        me = fam(model)
        r1 = {fam(m): x["content"] for m, x in d["r"][1].items()}
        own = r1.get(me, "")
        others = [v for k, v in r1.items() if k != me]
        ptoks_n, ptoks_t = num_tokens(d["prompt"]), term_tokens(d["prompt"])
        b_n, b_t = num_tokens(body), term_tokens(body)

        def uptake(tok_fn, prompt_toks, btoks):
            own_s = tok_fn(own) - prompt_toks
            oth = [tok_fn(o) - prompt_toks for o in others]
            only_others = (set().union(*oth) if oth else set()) - own_s
            in_one = {x for x in only_others if sum(1 for o in oth if x in o) == 1}
            all_r1 = own_s.union(*oth) if oth else own_s
            novel = btoks - prompt_toks - all_r1
            return dict(
                other_n=len(only_others), other_in=len(only_others & btoks),
                one_n=len(in_one), one_in=len(in_one & btoks),
                own_n=len(own_s), own_in=len(own_s & btoks),
                body_n=len(btoks - prompt_toks), novel=len(novel),
            )

        un = uptake(num_tokens, ptoks_n, b_n)
        ut = uptake(term_tokens, ptoks_t, b_t)
        bls = lines_of(body)
        meta_m = Counter(mentions(meta))
        proc = [s for s in sentences(body) if PROCESS_RE.search(s)]
        out.append(dict(
            theme=d["theme"], file=d["file"], fam=me, model=model,
            total=len(t), body=len(body), meta=len(meta),
            own_r1=len(own), mean_r1=sum(len(v) for v in r1.values()) / len(r1) if r1 else 0,
            body_ok=800 <= len(body) <= 1200, total_ok=800 <= len(t) <= 1200,
            headings=sum(1 for l in bls if HEADING_RE.match(l)),
            list_items=sum(1 for l in bls if LIST_RE.match(l)),
            table_rows=sum(1 for l in bls if TABLE_ROW_RE.match(l) and not TABLE_SEP_RE.match(l.replace("|", "", 1))),
            un=un, ut=ut,
            has_meta=bool(meta), meta_items=sum(1 for _, p in META_ITEMS if re.search(p, meta)),
            meta_self=meta_m[me], meta_other=sum(v for k, v in meta_m.items() if k != me), meta_by=meta_m,
            proc_sent=len(proc), proc_any=bool(proc), proc_examples=proc[:2],
        ))
    return out


R3_METRICS = [
    ("total", "最終回答の長さ（全体・字）", lambda u: (u["total"], 1), "int"),
    ("body", "本文の長さ（統合メモを除く・字）", lambda u: (u["body"], 1), "int"),
    ("meta", "統合メモの長さ（字）", lambda u: (u["meta"], 1), "int"),
    ("meta_share", "統合メモが全体に占める割合", lambda u: (u["meta"], u["total"]), "pct"),
    ("ratio_own", "本文 ÷ 自分の Round 1 の長さ", lambda u: (u["body"] / u["own_r1"], 1) if u["own_r1"] else (0, 0), "f2"),
    ("ratio_total_own", "全体 ÷ 自分の Round 1 の長さ", lambda u: (u["total"] / u["own_r1"], 1) if u["own_r1"] else (0, 0), "f2"),
    ("ratio_mean", "本文 ÷ 3 体の Round 1 の平均の長さ", lambda u: (u["body"] / u["mean_r1"], 1) if u["mean_r1"] else (0, 0), "f2"),
    ("body_ok", "本文が 800〜1,200 字に収まった割合", lambda u: (1 if u["body_ok"] else 0, 1), "pct"),
    ("total_ok", "全体が 800〜1,200 字に収まった割合", lambda u: (1 if u["total_ok"] else 0, 1), "pct"),
    ("headings", "本文の見出しの数", lambda u: (u["headings"], 1), "f1"),
    ("list_items", "本文の箇条書き行の数", lambda u: (u["list_items"], 1), "f1"),
    ("table_rows", "本文の表の行の数", lambda u: (u["table_rows"], 1), "f1"),
    ("num_other", "他の 2 体にしかない数値の取り込み率", lambda u: (u["un"]["other_in"], u["un"]["other_n"]), "pct"),
    ("num_one", "うち 1 体だけが書いた数値の取り込み率", lambda u: (u["un"]["one_in"], u["un"]["one_n"]), "pct"),
    ("num_own", "（比較）自分の Round 1 の数値を残した率", lambda u: (u["un"]["own_in"], u["un"]["own_n"]), "pct"),
    ("num_novel", "本文の数値のうち、どの Round 1 にもお題にもない割合", lambda u: (u["un"]["novel"], u["un"]["body_n"]), "pct"),
    ("term_other", "他の 2 体にしかない語（英字・カタカナ・「」）の取り込み率", lambda u: (u["ut"]["other_in"], u["ut"]["other_n"]), "pct"),
    ("term_own", "（比較）自分の Round 1 の語を残した率", lambda u: (u["ut"]["own_in"], u["ut"]["own_n"]), "pct"),
    ("meta_items", "統合メモにある 4 項目（合意点・少数意見・分岐・採用基準）の数", lambda u: (u["meta_items"], 1), "f2"),
    ("meta_self_share", "統合メモのモデル名の言及のうち自分を指した割合", lambda u: (u["meta_self"], u["meta_self"] + u["meta_other"]), "pct"),
    ("meta_names", "統合メモでモデル名を 1 回以上出した割合", lambda u: (1 if (u["meta_self"] + u["meta_other"]) else 0, 1), "pct"),
    ("proc_any", "本文（統合メモより前）で討論の経過に触れた割合", lambda u: (1 if u["proc_any"] else 0, 1), "pct"),
    ("proc_sent", "本文で討論の経過に触れた文の数", lambda u: (u["proc_sent"], 1), "f2"),
]

R3_DEFS = {
    "total": "Round 3 の本文全体の文字数（len。a_numbers.py の R3 平均字数と同じ数え方）。",
    "body": "「統合の補足」「統合メモ」を含む 60 字以内の行（見出し・太字・【】を問わない）より前の部分の文字数。直前の区切り線（---）は除く。統合のプロンプトは末尾に「統合の補足説明」を付けるよう求めているので、この行より前を「お題への回答」、後を「統合メモ」とみなす。",
    "meta": "その行から最後までの文字数（記事 A で「meta」として別に数えた、討論の経過についての記述に当たる部分）。",
    "meta_share": "統合メモの字数 ÷ 全体の字数（字数の合算）。",
    "ratio_own": "統合役自身の Round 1（同じ討論）の文字数に対する本文の文字数の比。1 より大きいと、統合で自分の初回より長くなった。",
    "ratio_total_own": "同じく全体（統合メモ込み）の比。",
    "ratio_mean": "同じ討論の 3 体の Round 1 の平均文字数に対する本文の比。",
    "body_ok": "本文の文字数が 800 以上 1,200 以下だった割合。お題は 800〜1,200 字を指定している（統合のプロンプト自体は字数に触れず、お題の文中で指定されている）。Markdown の記号も 1 字に数えるので、記号を除いた字数より少し多めに出る。",
    "total_ok": "統合メモを含む全体で同じ判定をした割合。",
    "headings": "本文で # から始まる行の数。",
    "list_items": "本文で行頭が「- * + ・ • 1. 1) (1) ①」の行の数。",
    "table_rows": "本文で | から | までの行（区切り行 |---| を除く）の数。",
    "num_other": "数値トークン＝NFKC 後の「数字＋単位」（%・年・月・日・時間・円・万・人・件・条・項・歳・倍・回・割・ビット・桁 など。単位は下の一覧）。お題の文中にある数値は除く。統合役の Round 1 になく他の 2 体の Round 1 のどちらかにある数値のうち、本文に同じ文字列で現れた割合（合算）。言い換え（「2割」と「20%」）や丸めは一致しない。",
    "num_one": "そのうち他の 2 体のうち 1 体だけが書いた数値（少数の側の情報）の取り込み率。",
    "num_own": "比較のため、統合役自身の Round 1 にあった数値が本文に残った割合。",
    "num_novel": "本文の数値トークン（お題のものを除く）のうち、3 体のどの Round 1 にもない割合。批評（Round 2）由来か、統合役が新たに書いたもの。",
    "term_other": "語＝NFKC 後の英字の語（2 字以上、小文字にそろえる。モデル名を除く）・カタカナ 3 字以上の連なり・「」で囲んだ 2〜30 字。固有名詞に限らない（「インボイス」「クロスチェック」なども入る）。数値と同じ規則の取り込み率。",
    "term_own": "比較のため、自分の Round 1 の語が本文に残った割合。",
    "meta_items": "統合メモに「合意点」「少数意見」「分岐」「採用基準・採否」の語がいくつあるか（プロンプトが求める 4 項目）。",
    "meta_self_share": "統合メモの中のモデル名の言及（Round 2 と同じ規則）のうち統合役自身を指した割合（合算）。誰の指摘を採ったと書くかの近似。",
    "meta_names": "統合メモにモデル名が 1 回以上出た割合（「複数の AI が」のように名前を出さずに書く統合役もいる）。",
    "proc_any": "本文（統合メモより前）を「。」と改行で文に分け、討論・批評・各AI・他のAI・複数のAI・3つの回答・各回答・回答者・モデル名のどれかを含む文が 1 つ以上ある割合。プロンプトは「各 AI の意見を整理すると…」のような分析文から始めないよう求めている。",
    "proc_sent": "その文の数の平均。",
}


# ───────────────────────── 出力 ─────────────────────────
def model_order(units):
    fams = {u["fam"]: u["model"] for u in units}
    return [(f, fams[f]) for f in ORDER if f in fams]


def table(P, units, metrics, defs):
    mods = model_order(units)
    P("**指標の定義**\n")
    for key, label, _, _ in metrics:
        P(f"- **{label}**: {defs[key]}")
    P("")
    P("| 指標 | " + " | ".join(DISPLAY.get(m, m) for _, m in mods) + " |")
    P("|---|" + "---|" * len(mods))
    results = {}
    for key, label, fn, kind in metrics:
        row = []
        for f, m in mods:
            us = [u for u in units if u["fam"] == f]
            vals = [(u["theme"],) + tuple(fn(u)) for u in us]
            est, (lo, hi), per = agg(vals)
            results[(key, f)] = (est, lo, hi, per)
            row.append(f"{fmt(est, kind)} [{fmt(lo, kind)}, {fmt(hi, kind)}]")
        P(f"| {label} | " + " | ".join(row) + " |")
    P("\n値は「点推定 [95% 区間]」。区間はテーマ単位のクラスター・ブートストラップ（15 テーマを復元抽出、10,000 回、seed 1）。割合・1,000 字あたりの値は、テーマ内で合算した比をブートストラップした合算比。")
    return results


def spread_table(P, units, metrics, results):
    mods = model_order(units)
    P("| 指標 | " + " | ".join(f"{DISPLAY.get(m, m)} 最小〜最大（SD）" for _, m in mods) + " |")
    P("|---|" + "---|" * len(mods))
    for key, label, _, kind in metrics:
        row = []
        for f, _ in mods:
            per = results[(key, f)][3]
            v = list(per.values())
            if not v:
                row.append("-")
                continue
            sd = statistics.stdev(v) if len(v) > 1 else 0.0
            lo_t = min(per, key=per.get)
            hi_t = max(per, key=per.get)
            if fmt(min(v), kind) == fmt(max(v), kind):
                row.append(f"全テーマ {fmt(min(v), kind)}")
            else:
                row.append(f"{fmt(min(v), kind)}（{lo_t}）〜{fmt(max(v), kind)}（{hi_t}）（{fmt(sd, kind)}）")
        P(f"| {label} | " + " | ".join(row) + " |")


def theme_matrix(P, units, key, label, fn, kind, topics):
    mods = model_order(units)
    P(f"\n**{label}（テーマ別の平均）**\n")
    P("| テーマ | 分類 | " + " | ".join(DISPLAY.get(m, m) for _, m in mods) + " |")
    P("|---|---|" + "---|" * len(mods))
    themes = sorted({u["theme"] for u in units})
    for t in themes:
        row = []
        for f, _ in mods:
            us = [fn(u) for u in units if u["fam"] == f and u["theme"] == t]
            a = sum(x for x, _ in us)
            b = sum(y for _, y in us)
            row.append(fmt(a / b if b else None, kind) + f"（{len(us)}）")
        cat = topics.get(t, {}).get("category", "")
        if topics.get(t, {}).get("trap") and "トラップ" not in cat:
            cat += "・トラップ"
        P(f"| {t} | {cat} | " + " | ".join(row) + " |")


def render_set(P, name, debates, topics, h="##"):
    r2 = r2_units(debates)
    r3 = r3_units(debates)
    P(f"{h} {name}\n")
    P(f"- 討論 {len(debates)} 本（テーマ {len({d['theme'] for d in debates})}）／Round 2 の批評 {len(r2)} 本／Round 3 の最終回答 {len(r3)} 本")
    P("- 参加: " + "・".join(DISPLAY.get(m, m) for m in sorted({m for d in debates for m in d['models']}, key=lambda m: ORDER.index(fam(m)))))
    P("- 統合役の内訳: " + "・".join(f"{DISPLAY.get(m, m)} {c} 本" for m, c in sorted(Counter(d["synth"] for d in debates).items(), key=lambda x: ORDER.index(fam(x[0])))))
    P("")

    # ── Round 2 ──
    P(f"{h}# Round 2（批評者として）\n")
    P("単位は批評 1 本（1 討論 × 批評者 1 体）。Round 2 は 3 体とも同じ討論の同じ 3 本の Round 1 を読んで書くので、モデル間の差は同じ材料に対する書き方の差。\n")
    res2 = table(P, r2, R2_METRICS, R2_DEFS)

    mods = model_order(r2)
    P(f"\n{h}## 批評の対象（モデル名で指した先の配分）\n")
    P("各行は批評者。値は名前で指した言及の合計件数と、行内の割合。位置だけの呼び方（回答1・回答A など、括弧で名前を添えていないもの）は対象不明として右端に件数だけ出す。\n")
    P("| 批評者 \\ 指した先 | " + " | ".join(DISPLAY.get(m, m) for _, m in mods) + " | 位置だけの呼び方（対象不明） |")
    P("|---|" + "---|" * (len(mods) + 1))
    for f, m in mods:
        us = [u for u in r2 if u["fam"] == f]
        c = Counter()
        for u in us:
            c.update(u["m_by"])
        tot = sum(c.values())
        P(f"| {DISPLAY.get(m, m)} | " + " | ".join(f"{c[g]}（{c[g] / tot:.0%}）{'＝自分' if g == f else ''}" for g, _ in mods) + f" | {sum(u['pos_labels'] for u in us)} |")
    P("\n他の 2 体のどちらをより多く名指ししたか（自分を除いた言及の中の割合。区間はテーマ単位のブートストラップ）:\n")
    for f, m in mods:
        us = [u for u in r2 if u["fam"] == f]
        oth = [g for g, _ in mods if g != f]
        if len(oth) != 2:
            continue
        g = oth[0]
        est, (lo, hi), _ = agg([(u["theme"], u["m_by"][g], u["m_by"][oth[0]] + u["m_by"][oth[1]]) for u in us])
        P(f"- {DISPLAY.get(m, m)}: {DISPLAY.get(dict(mods)[g])} を指した割合 {fmt(est, 'pct')} [{fmt(lo, 'pct')}, {fmt(hi, 'pct')}]（残りは {DISPLAY.get(dict(mods)[oth[1]])}）")

    P(f"\n{h}## クロスチェック（🔍）の宛先\n")
    P("クロスチェックの行で、批評者以外のモデル名が最初に出た先。自分の名前しかない行は「自分（自分の回答の訂正）」。行に名前がないときは直前の見出しに名前が 1 体だけあればその宛先とし、括弧内に「うち見出しで判定」の件数を出す。どちらにも名前がない行（「回答2の…」など位置だけで指した行を含む）は「不明」。\n")
    P("| 批評者 | 件数 | " + " | ".join(f"→ {DISPLAY.get(m, m)}" for _, m in mods) + " | 自分 | 不明 |")
    P("|---|---:|" + "---:|" * (len(mods) + 2))
    for f, m in mods:
        c = Counter(t for u in r2 if u["fam"] == f for t in u["cc_targets"])
        cell = lambda g: f"{c[g] + c['ctx_' + g]}" + (f"（うち見出し {c['ctx_' + g]}）" if c["ctx_" + g] else "")
        P(f"| {DISPLAY.get(m, m)} | {sum(c.values())} | " + " | ".join("—" if g == f else cell(g) for g, _ in mods) + f" | {cell('self')} | {c['unknown']} |")
    # 受けた側から見た件数（自分の回答の訂正は除く）
    recv = Counter(t.replace("ctx_", "") for u in r2 for t in u["cc_targets"] if t.replace("ctx_", "") in dict(mods))
    P("\n- 受けた側から見た件数（他のモデルからの分）: " + "／".join(f"{DISPLAY.get(m, m)} {recv[f]}" for f, m in mods))
    P("- 注意: 受けた件数は「事実誤認を指摘された回数」なので正確さに近い。記事 I では批評者側の書き方（誰がどれだけ使うか・自分の回答を訂正したか）として扱い、受けた側の多少で順位を付けない（正確さは記事 B）。指摘が正しかったかは数えていない。")

    P(f"\n{h}## 最終評価で選んだ回答（批評者の選択の記録。正確さの順位ではない）\n")
    P("規則は R2 の表の「最終評価で自分の回答を選んだ割合」と同じ。「判別できず」はほとんどが「回答3」「C」のように位置だけで選んだもの（記録に入力の順番が残っていないので復元しない）。選ばれた回数の多少はモデルの優劣を示さないので、記事 I では批評者側の選び方（自分を選ぶか）だけを使う。\n")
    P("| 批評者 | 本数 | " + " | ".join(f"{DISPLAY.get(m, m)} を選択" for _, m in mods) + " | 同等 | 判別できず | 最終評価の節なし |")
    P("|---|---:|" + "---:|" * (len(mods) + 3))
    for f, m in mods:
        c = Counter(u["pick"] for u in r2 if u["fam"] == f)
        P(f"| {DISPLAY.get(m, m)} | {sum(c.values())} | " + " | ".join(f"{c[g]}{'（自分）' if g == f else ''}" for g, _ in mods) + f" | {c['tie']} | {c['unknown']} | {c['none']} |")

    P(f"\n{h}## 規則の当たり具合（Round 2）\n")
    for f, m in mods:
        us = [u for u in r2 if u["fam"] == f]
        P(f"- {DISPLAY.get(m, m)}: 観点 7 節のうち見出しで検出できた数の平均 {sum(u['aspects_found'] for u in us) / len(us):.1f}／「妥当な点」「疑問点」の両方を検出できた批評 {sum(1 for u in us if u['both_sections'])}/{len(us)}／見出し行の平均 {sum(u['headings'] for u in us) / len(us):.1f}")
    P("- 語の数え方は表層の一致で、否定の形のうち「不正確・正しくない・妥当でない・不適切」だけを肯定から外している。「誤りは見当たらない」「不足はない」のような打ち消しは否定・訂正語に数えられる（打ち消しを正しく扱うには AI の判定が必要）。")

    # ── Round 3 ──
    P(f"\n{h}# Round 3（統合役として）\n")
    P("単位は最終回答 1 本（統合役ごとに 15 本）。統合役は討論ごとに 1 体なので、モデル間の差には担当したテーマの組み合わせの違いも入る（本実験はテーマごとに 3 体が 1 回ずつ統合役を務める割り付け）。\n")
    res3 = table(P, r3, R3_METRICS, R3_DEFS)
    P(f"\n{h}## 統合メモで名前を挙げた先（統合役 × 指した先、件数）\n")
    m3 = model_order(r3)
    P("| 統合役 \\ 指した先 | " + " | ".join(DISPLAY.get(m, m) for _, m in m3) + " |")
    P("|---|" + "---:|" * len(m3))
    for f, m in m3:
        c = Counter()
        for u in r3:
            if u["fam"] == f:
                c.update(u["meta_by"])
        P(f"| {DISPLAY.get(m, m)} | " + " | ".join(f"{c[g]}{'（自分）' if g == f else ''}" for g, _ in m3) + " |")
    P(f"\n{h}## 規則の当たり具合（Round 3）\n")
    P(f"- 統合メモの開始行を検出できた最終回答: {sum(1 for u in r3 if u['has_meta'])}/{len(r3)}")
    ex = [(u["file"], u["fam"], s) for u in r3 for s in u["proc_examples"]][:6]
    if ex:
        P("- 本文で討論の経過に触れたと数えた文の例（規則の確認用）:")
        for fl, f, s in ex:
            P(f"  - {f} {fl.split('/')[-1][:3]}: 「{s.strip()[:70]}」")

    # ── テーマ別 ──
    P(f"\n{h}# テーマ別のばらつき\n")
    P("各モデルについて、テーマごとの平均（割合は合算比）を出し、その最小〜最大（括弧はテーマ名）とテーマ間の標準偏差（標本 SD）。Round 3 は 1 テーマ 1 本なので、ばらつきは本数 1 の値のばらつき。\n")
    P(f"**Round 2**\n")
    spread_table(P, r2, R2_METRICS, res2)
    P(f"\n**Round 3**\n")
    spread_table(P, r3, R3_METRICS, res3)
    for key in ("chars", "cc", "neg_ratio"):
        k, label, fn, kind = next(x for x in R2_METRICS if x[0] == key)
        theme_matrix(P, r2, k, "Round 2: " + label, fn, kind, topics)
    for key in ("total", "ratio_own", "num_other"):
        k, label, fn, kind = next(x for x in R3_METRICS if x[0] == key)
        theme_matrix(P, r3, k, "Round 3: " + label, fn, kind, topics)
    P("\n括弧内は本数。")
    return r2, r3


def main():
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("--set", action="append", default=[], help="「表示名=dir1,dir2」。複数指定可（最初が本表、以降は参考の別表）")
    ap.add_argument("--topics", default="headless-experiments/topics-v2.jsonl")
    ap.add_argument("--out", help="Markdown の出力先（省略時は標準出力）")
    a = ap.parse_args()
    sets = a.set or [
        "本実験（記事 A・GPT-6.1 Sol Pro）=headless-results/exp1v2-final",
        "参考（GPT-6.1 Sol 版）=headless-results/expB-sol-s1,headless-results/expB-sol-s2,headless-results/expB-sol-s3",
    ]
    topics = {}
    if os.path.exists(a.topics):
        for l in open(a.topics, encoding="utf-8"):
            if l.strip():
                t = json.loads(l)
                topics[t["questionId"]] = t
    out = []
    P = out.append
    P("# 記事 I の数値（自動生成・i_numbers.py）\n")
    P("- 対象: Round 2（批評者として）と Round 3（統合役として）。書き手としての Round 1 の長さ・字数指定は a_numbers.py にあるので、ここでは比の分母と取り込みの比較元にだけ使う。")
    P("- 数え方: 本文の文字列を正規表現で数えただけ（AI の判定は使っていない）。正確さ・誤り率は扱わない（記事 B の主題）。")
    P("- 読み方: すべて**今回の版・条件（ウェブ検索なし・日本語の解説文・この 15 テーマ・2026 年 10 月の版）での傾向**。モデルの一般的な性格や優劣を示すものではない。区間が重なる差は、この規模では言い切れない。")
    P(f"- 生成: `python3 headless-experiments/tools/i_numbers.py " + " ".join(f'--set \"{s}\"' for s in sets) + f" --topics {a.topics}" + (f" --out {a.out}" if a.out else "") + "`\n")
    for i, s in enumerate(sets):
        name, dirs = s.split("=", 1)
        debates = load_set([d.strip() for d in dirs.split(",") if d.strip()])
        if i > 0:
            P("\n---\n")
        render_set(P, name.strip(), debates, topics)

    P("\n---\n\n## 付録: 語の一覧と正規表現（コードと同じもの）\n")
    for k, v in LEX.items():
        P(f"- {k}: " + "／".join(f"`{p}`" for p in v))
    P(f"- 自己評価への断り: `{SELF_CAVEAT_RE.pattern}`")
    P(f"- モデル名: `{ALIAS_RE.pattern}`（大文字小文字を区別しない）")
    P(f"- 位置だけの呼び方: `{POS_LABEL_RE.pattern}`")
    P(f"- 箇条書き行: `{LIST_RE.pattern}`")
    P(f"- クロスチェック: `{CROSSCHECK_RE.pattern}`")
    P("- Round 2 の観点の見出し語: " + "／".join(f"{lab} `{p}`" for _, lab, p in ASPECTS))
    P(f"- 数字の並び: `{NUM_RE.pattern}`／年・年月日: `{DATE_RE.pattern}`／条文・規格番号: `{LAW_RE.pattern}`")
    P(f"- 統合メモの開始: `{META_START_RE.pattern}`（60 字以内の行）／4 項目: " + "／".join(f"{n} `{p}`" for n, p in META_ITEMS))
    P(f"- 討論の経過に触れた文: `{PROCESS_RE.pattern}`")
    P(f"- 数値トークン: `{NUMTOK_RE.pattern}`")
    P(f"- 語トークン: 英字 `{ASCII_TERM_RE.pattern}`（2 字以上・小文字化・モデル名と {sorted(ASCII_STOP)} を除く）／カタカナ `{KATA_TERM_RE.pattern}`／かぎ括弧 `{QUOTE_TERM_RE.pattern}`")

    text = "\n".join(out) + "\n"
    if a.out:
        with open(a.out, "w", encoding="utf-8") as fh:
            fh.write(text)
    else:
        print(text, end="")


if __name__ == "__main__":
    main()
