"""
=============================================================================
CS486 · 第 1 讲 · 代码 01：一次前向 vs 先想再答——"思考 token"到底给了模型什么？
=============================================================================

【这个脚本要回答的问题】（讲义 §3）
    同样大小（2 层、约 10 万参数）的模型、同样的训练题、同样的训练步数，
    只改变**作答方式**：
        direct：读完题直接输出答案（一次前向计算）
        pause ：题后面给 k 个填充 token "_"，再输出答案（更多的计算位置，但不写中间结果）
        cot   ：先把每一跳到达的字母写出来，再输出答案（思维链）
    哪一种学得会多跳指针追踪？为什么？

【五个演示】
    演示 1：同一道题的三种作答方式；"计算账本"——每种方式在答案之前做了几次串行的前向、
            最长的计算路径穿过了几层（串行深度）。这是讲义 §3.2 的理论核心。
    演示 2：训练（或读取缓存的）三个模型。
    演示 3：学习曲线——训练过程中探针准确率怎样变化。
    演示 4：主结果——准确率随跳数 k 的变化（n = 8，k = 1–7，每格 300 道题）。
    演示 5：模型到底在答什么？把错误答案分类；看几条实际的生成。

【运行】
    python3 01_one_pass_vs_thinking.py           # 第一次约 1.5–2 分钟（训练 3 个模型并缓存），之后约 10 秒
    python3 01_one_pass_vs_thinking.py --quick   # 冒烟测试：每个模型只训练 60 步，几秒钟（数字没有意义）

【依赖】PyTorch + 本目录的 hop_world.py、tiny_reasoner.py、research_kit.py、console.py。
"""

import argparse
import random
import time
from collections import Counter

import hop_world as hw
import tiny_reasoner as tr
from console import pad, pct, print_header, print_observations, print_table
from research_kit import wilson_interval

MODE_NAMES = {"direct": "direct 直接回答", "pause": "pause 填充 token", "cot": "cot 思维链"}
MODEL_OF = {"direct": "direct_L2", "pause": "pause_L2", "cot": "cot_L2"}


# ---------------------------------------------------------------------------
# 演示 1：三种作答方式与"计算账本"
# ---------------------------------------------------------------------------
def compute_ledger(prob, mode, n_layers):
    """
    一道题在某种作答方式下，"答案之前"做了多少计算。

    【思路】（讲义 §3.2）
        Transformer 的一次前向计算里，信息只能"向上一层、向右若干位置"流动：
        位置 i 第 l 层的结果，只能影响位置 j ≥ i 第 l+1 层及以上的结果。
        所以无论输入有多长，从任何输入 token 到答案的**最长计算路径**只穿过 n_layers 层——
        多出来的位置（pause 的填充 token）增加的是**并行**的计算量，不是**串行**的步数。
        思维链不同：每生成一个 token，它会被当成新的输入、**从第 0 层重新进入模型**。
        答案在第 m 次前向时生成，最长路径就是 m × n_layers 层。

    【参数】prob：HopProblem；mode：作答方式；n_layers：模型层数
    【返回】dict：
        positions ：答案生成之前，模型一共计算了多少个位置的表示（= 输入长度 + 已生成的 token 数）
        passes    ：答案之前有几次**串行**的前向计算（每次生成一个 token）
        depth     ：最长计算路径穿过的层数（串行深度）
    【边界】这里数的是"有用的"计算；评测时我们为了简单每步重算整个序列（没有 KV cache），
        实际 FLOPs 更多，但不影响串行深度这个结论。
    """
    inp, _ = hw.format_example(prob, mode)
    if mode in ("direct", "pause"):
        return {"positions": len(inp), "passes": 1, "depth": n_layers}
    # cot：k 个推理字母 + "|" 都要一个一个生成，答案在第 k + 2 次前向时生成
    passes = prob.k + 2
    return {"positions": len(inp) + prob.k + 1, "passes": passes, "depth": passes * n_layers}


def demo1_formats():
    print_header("演示 1：同一道题的三种作答方式，以及每种方式在答案之前做了多少计算")
    prob = hw.make_problem(random.Random(2026), n=8, k=3)
    print(f"题面：{prob.prompt}")
    print(f"路径：{' → '.join(prob.walk)}，答案 = {prob.answer}\n")
    for mode in hw.MODES:
        inp, tgt = hw.format_example(prob, mode)
        print(f"  {pad(MODE_NAMES[mode], 18)} 输入     = {inp}")
        print(f"  {pad('', 18)} 期望输出 = {tgt}")
    print("\n计算账本（2 层模型，n = 8 的题）：")
    rows = []
    for k in (1, 3, 7):
        p = hw.make_problem(random.Random(k), n=8, k=k)
        for mode in hw.MODES:
            led = compute_ledger(p, mode, 2)
            rows.append([f"k = {k}", MODE_NAMES[mode], led["positions"], led["passes"], led["depth"]])
    print_table(["跳数", "作答方式", "计算的位置数", "串行前向次数", "串行深度（层）"], rows,
                [8, 18, 12, 12, 14], ["<", "<", ">", ">", ">"])
    print_observations([
        "pause 比 direct 多算了 k 个位置（计算量更大），但串行前向次数和串行深度都和 direct 一样：1 次、2 层。",
        "cot 的串行深度随 k 线性增长：k = 7 时答案经过了 9 次前向、18 层——这是一个 2 层模型"
        "在一次前向里永远得不到的串行计算量。",
        "下面的实验检验：对一个天生串行的任务，真正起作用的是'更多的位置'还是'更深的串行计算'。",
    ])


# ---------------------------------------------------------------------------
# 演示 2–3：训练与学习曲线
# ---------------------------------------------------------------------------
def demo2_train(quick):
    print_header("演示 2：训练三个模型（同样的结构、同样的训练题、同样的步数，只改作答方式）")
    print("训练分布：环大小 n ∈ [4, 8]，跳数 k ∈ [1, n-1] 均匀，陈述顺序随机打乱，每步 64 道新题。\n")
    models, infos = {}, {}
    for mode in hw.MODES:
        model, info, tcfg = tr.get_model(MODEL_OF[mode], quick=quick)
        models[mode], infos[mode] = model, info
    print()
    rows = []
    for mode in hw.MODES:
        info = infos[mode]
        h = info["history"]
        rows.append([MODE_NAMES[mode], f"{info['n_params']:,}", h[-1]["step"], f"{h[-1]['loss']:.4f}"])
    print_table(["模型", "参数量", "训练步数", "最后一步的损失"], rows, [18, 10, 10, 14])
    print("（损失只在'期望输出'的 token 上平均；三种方式的输出长度不同，损失的绝对值不能直接横向比较。）")
    return models, infos


def demo3_curves(infos):
    print_header("演示 3：学习曲线——训练过程中的探针准确率（n = 8，k = 1–7 各 20 道，共 140 道）")
    curves = {m: {h["step"]: h["probe_acc"] for h in infos[m]["history"] if h["probe_acc"] is not None}
              for m in hw.MODES}
    steps = sorted(curves["cot"])
    rows = [[s] + [pct(curves[m].get(s)) for m in hw.MODES] for s in steps]
    print_table(["训练步数"] + [MODE_NAMES[m] for m in hw.MODES], rows, [10, 18, 18, 18])
    return curves


# ---------------------------------------------------------------------------
# 演示 4：主结果
# ---------------------------------------------------------------------------
def demo4_main(models, count):
    print_header(f"演示 4：主结果——准确率随跳数 k 的变化（n = 8，每格 {count} 道题，括号里是 Wilson 95% 区间）")
    table, results = [], {}
    for k in range(1, 8):
        probs = hw.make_eval_set(8, k, count, seed=1)
        row = [f"k = {k}"]
        for mode in hw.MODES:
            res = tr.evaluate(models[mode], probs, mode)
            results[(mode, k)] = (probs, res)
            c = sum(r["correct"] for r in res)
            lo, hi = wilson_interval(c, len(res))
            row.append(f"{pct(c / len(res))} [{pct(lo, 0)}, {pct(hi, 0)}]")
        table.append(row)
    avg = ["平均"]
    for mode in hw.MODES:
        accs = [tr.accuracy(results[(mode, k)][1]) for k in range(1, 8)]
        avg.append(pct(sum(accs) / len(accs)))
    table.append(avg)
    print_table(["跳数"] + [MODE_NAMES[m] for m in hw.MODES], table, [8, 22, 22, 22])
    print(f"随机猜环上的一个字母：1/8 = {pct(1 / 8)}；随机猜'除起点以外'的一个字母：1/7 = {pct(1 / 7)}（见演示 5）")
    return results


# ---------------------------------------------------------------------------
# 演示 5：错误分析
# ---------------------------------------------------------------------------
def classify(prob, pred):
    """
    把一个答案归类。

    【思路】错误分析（error analysis）是研究里最有用、也最常被跳过的一步：
        "准确率 12%"可以是"随机乱猜"，也可以是"系统性地只走了 1 跳"——两种情况的含义完全不同。
    【返回】类别字符串
    """
    if pred is None:
        return "无法解析"
    if pred == prob.answer:
        return "正确"
    if pred == prob.walk[1]:
        return "只走了 1 跳"
    if pred == prob.start:
        return "停在起点"
    if pred in prob.walk or pred in "".join(prob.statements):
        return "题里的其他字母"
    return "题里没有的字母"


def demo5_errors(results, quick):
    print_header("演示 5：模型到底在答什么？（把 k = 2–7 的全部答案分类）")
    cats = ["正确", "只走了 1 跳", "停在起点", "题里的其他字母", "题里没有的字母", "无法解析"]
    rows, fractions = [], {}
    for mode in hw.MODES:
        cnt = Counter()
        for k in range(2, 8):
            probs, res = results[(mode, k)]
            for p, r in zip(probs, res):
                cnt[classify(p, r["pred"])] += 1
        total = sum(cnt.values())
        fractions[mode] = {c: cnt[c] / total for c in cats}
        rows.append([MODE_NAMES[mode]] + [pct(cnt[c] / total) for c in cats])
    print_table(["模型"] + cats, rows, [18, 8, 11, 9, 14, 14, 9])

    print("\n同一道题（n = 8，k = 5）上三个模型的实际输出：")
    probs, _ = results[("cot", 5)]
    p = probs[0]
    print(f"  题面 {p.prompt}    正确路径 {' → '.join(p.walk)}")
    for mode in hw.MODES:
        r = results[(mode, 5)][1][0]
        out = r["generated"].split("$")[0] + ("$" if "$" in r["generated"] else "")
        verdict = "对" if r["correct"] else "错"
        print(f"  {pad(MODE_NAMES[mode], 18)} 生成 {out!r:<12} → 答案 {r['pred']}（{verdict}）")

    # 思维链的推理链本身对不对？
    chain_ok = chain_total = 0
    for k in range(1, 8):
        probs, res = results[("cot", k)]
        for p, r in zip(probs, res):
            chain_total += 1
            chain_ok += r["generated"].startswith(p.trace + hw.ANSWER_MARK)
    print(f"\n思维链模型：推理链逐字正确（且紧接着写了 '|'）的比例 = {pct(chain_ok / chain_total)}（k = 1–7 全部 {chain_total} 道）")
    return chain_ok / chain_total, fractions["direct"]


def _range(values):
    """把一组准确率写成 "最小–最大"；全部相同时只写一个数（例如 "全部 100.0%"）。"""
    lo, hi = min(values), max(values)
    return f"全部 {pct(lo)}" if lo == hi else f"{pct(lo)}–{pct(hi)}"


def main():
    ap = argparse.ArgumentParser(description=__doc__.split("\n")[2])
    ap.add_argument("--quick", action="store_true", help="冒烟测试：只训练 60 步，评测题数也很少")
    args = ap.parse_args()
    t0 = time.perf_counter()
    count = 30 if args.quick else 300

    demo1_formats()
    models, infos = demo2_train(args.quick)
    curves = demo3_curves(infos)
    results = demo4_main(models, count)
    chain_rate, cats_direct = demo5_errors(results, args.quick)

    if args.quick:
        print("\n（--quick 冒烟测试：模型只训练了 60 步，上面的数字没有意义，不打印观察。）")
        print(f"\n⏱ 总用时 {time.perf_counter() - t0:.0f} 秒")
        return
    acc = {m: [tr.accuracy(results[(m, k)][1]) for k in range(1, 8)] for m in hw.MODES}
    first_100 = next((s for s in sorted(curves["cot"]) if curves["cot"][s] >= 0.99), None)
    print_observations([
        f"思维链（2 层）在 k = 1–7 上的准确率是 {_range(acc['cot'])}；"
        f"它在训练的第 {first_100} 步左右探针准确率就达到 99% 以上（演示 3）。推理链本身逐字正确的比例 {pct(chain_rate)}。",
        f"直接回答和填充 token 在每个 k 上都停在随机水平附近（直接回答 {pct(min(acc['direct']))}–{pct(max(acc['direct']))}，"
        f"填充 {pct(min(acc['pause']))}–{pct(max(acc['pause']))}，平均 {pct(sum(acc['direct']) / 7)} 和 "
        f"{pct(sum(acc['pause']) / 7)}，几乎正好是 1/7 = 14.3%）——**连 k = 1 都没学会**，"
        "而 k = 1 和思维链的第一步是完全相同的计算。代码 02 专门研究这一点：一次前向到底能做几跳。",
        "填充 token 多给了 k 个计算位置，却没有任何帮助：对这个天生串行的任务，起作用的不是'更多的位置'，"
        "而是'把中间结果写下来、再从第 0 层读回去'带来的串行深度（演示 1 的账本）。",
        f"演示 5：直接回答模型**从不**回答起点字母（{pct(cats_direct['停在起点'])}），也从不回答题里没有的字母；"
        f"'只走了 1 跳'只占 {pct(cats_direct['只走了 1 跳'])}（≈ 1/7）。也就是说，它在'除起点外的 7 个字母'里均匀地猜——"
        "它学到的是两条表面规律（答案是题里的字母；k ≤ n-1 时答案不会是起点），而不是任何一跳的查找。"
        "这也解释了为什么平均准确率是 1/7 而不是 1/8。",
    ])
    print(f"\n⏱ 总用时 {time.perf_counter() - t0:.0f} 秒")


if __name__ == "__main__":
    main()
