"""
=============================================================================
CS486 · 第 5 讲 · 代码 02：RL 是"教会了新东西"，还是"把会的东西挑出来"？——pass@k 之争
=============================================================================

【这个脚本要回答的问题】（讲义 §7）
    Yue et al.（2025）"Does Reinforcement Learning Really Incentivize Reasoning Capacity in LLMs Beyond
    the Base Model?" 的核心观察：RL 训练后的模型 pass@1 更高，但在 k 很大时（例如 pass@256），
    **基座模型反而更高**——两条 pass@k 曲线会交叉。他们的解释：RL 没有教会新的推理能力，
    只是把基座模型本来就能（偶尔）采样到的正确路径变得更容易采到，同时牺牲了多样性。
    之后的工作（ProRL 等）报告了相反的情况：训练够久、题目够难时，RL 能解出基座采样很多次也解不出的题。

    在我们的实验台上，"基座"就是 cot_L2，"能力边界"可以精确测量：每道题采样 512 次，看有没有一次答对。
    我们比较代码 01 训练好的三个模型（种子 0）和起点 cot_L2：

    演示 1：pass@k 曲线（k = 1 … 512，温度 1.0），在 n = 12、14、16 上。
    演示 2：边界——"起点采样 512 次一次都没对、训练后却答对了"的题有多少？反过来呢？
    演示 3：多样性——每道题 512 个样本里有几种不同的答案；回答 token 的平均熵。

【运行】
    python3 02_pass_at_k.py           # 约 1.5 分钟（4 个模型 × 3 个测试集 × 200 道题 × 512 次采样，并行）
    python3 02_pass_at_k.py --quick   # 冒烟测试

    需要代码 01 训练好的模型（没有缓存时会先训练，约 1.5 分钟）。

【依赖】PyTorch + 本目录的 rl_lab.py。
"""

import argparse
import os
import sys
import time

HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.join(HERE, "..", "lecture01"))
sys.path.insert(0, os.path.join(HERE, "..", "lecture02"))

import exp_kit as ek                                                     # noqa: E402
import rl_lab as rl                                                      # noqa: E402
import tiny_reasoner as tr                                               # noqa: E402
from console import pct, print_header, print_observations, print_table  # noqa: E402

MODELS = ("base", "sft", "rft", "grpo")
LABEL = {"base": "起点", "sft": "SFT", "rft": "RFT", "grpo": "GRPO"}
NS = (12, 14, 16)
KS = (1, 4, 16, 64, 256, 512)


def main():
    ap = argparse.ArgumentParser(description="pass@k：RL 扩展了能力边界吗？")
    ap.add_argument("--quick", action="store_true", help="冒烟测试")
    args = ap.parse_args()
    t0 = time.perf_counter()

    print_header("准备：读取代码 01 训练好的模型（种子 0）")
    if args.quick:
        N, ks = 8, (1, 4, 8)
        base = tr.get_model("cot_L2", quick=True, verbose=False)[0]
        jobs = [j for j in rl.main_jobs() if j["seed"] == 0]
        models = {"base": base}
        models.update({j["method"]: m for j, (m, _) in zip(jobs, rl.load_runs(jobs, quick=True))})
        res = {}
        for name in MODELS:
            for n in NS:
                probs = rl.eval_set(n, per_k=3)
                curve, counts = rl.pass_curve(models[name], probs, N, ks)
                res[(name, n)] = (curve, counts, 0.0, 0.0)
    else:
        N, ks = 512, KS
        jobs = {j["method"]: j for j in rl.main_jobs() if j["seed"] == 0}
        rl.load_runs(list(jobs.values()))                     # 主进程里确保都已缓存
        tr.get_model("cot_L2", verbose=False)
        tasks = [(None if name == "base" else jobs[name], n, N, ks) for name in MODELS for n in NS]
        print(f"  采样：{len(MODELS)} 个模型 × {len(NS)} 个测试集 × 200 道题 × {N} 次（并行）…", flush=True)
        t1 = time.perf_counter()
        outs = ek.run_parallel(rl.pass_job, tasks)
        print(f"  ⏱ 用时 {time.perf_counter() - t1:.0f} 秒（墙钟）", flush=True)
        res = {(name, n): out for (name, n), out in zip([(a, b) for a in MODELS for b in NS], outs)}

    print_header(f"演示 1：pass@k 曲线（温度 1.0，每道题采样 {N} 次，无偏估计）")
    for n in NS:
        print(f"\n  n = {n}：")
        rows = [[f"k = {k}"] + [pct(res[(name, n)][0][k]) for name in MODELS] for k in ks]
        print_table(["", *[LABEL[m] for m in MODELS]], rows, [10, 8, 8, 8, 8])

    print_header(f"演示 2：能力边界——起点模型采样 {N} 次从没答对的题，训练后呢？")
    rows, bound = [], {}
    for n in NS:
        base_c = res[("base", n)][1]
        for name in ("sft", "rft", "grpo"):
            c = res[(name, n)][1]
            new = sum(1 for b, x in zip(base_c, c) if b == 0 and x > 0)
            lost = sum(1 for b, x in zip(base_c, c) if b > 0 and x == 0)
            never = sum(1 for b in base_c if b == 0)
            bound[(n, name)] = (never, new, lost)
            rows.append([f"n = {n}", LABEL[name], never, new, lost, sum(1 for x in c if x == 0)])
    print_table(["测试集", "模型", "起点从没对过", "其中训练后能对", "起点能对、训练后从没对", "训练后从没对过"],
                rows, [8, 6, 14, 16, 24, 16])

    print_header("演示 3：多样性——每道题的样本里有几种不同的答案？回答 token 的平均熵是多少？")
    rows = [[LABEL[name]] + [f"{res[(name, n)][2]:.2f} / {res[(name, n)][3]:.4f}" for n in NS] for name in MODELS]
    print_table(["模型", *[f"n = {n}：不同答案数 / 熵" for n in NS]], rows, [8, 24, 24, 24])
    print("  （不同答案数：每道题 N 个样本里出现过几种不同的答案字母（无法解析的记为一种）；熵：每个回答 token 的平均熵，单位 nats）")

    if args.quick:
        print("\n（--quick 冒烟测试：数字没有意义，不打印观察。）")
        print(f"\n⏱ 总用时 {time.perf_counter() - t0:.0f} 秒")
        return
    c = {key: res[key][0] for key in res}
    never12, new12, _ = bound[(12, "grpo")]
    _, new16g, lost16g = bound[(16, "grpo")]
    _, new16s, lost16s = bound[(16, "sft")]
    print_observations([
        f"在 RL 训练覆盖的 n = 12 上，起点模型采样 {N} 次仍有 {never12} 道题（共 200 道）一次都没答对（pass@{N} = "
        f"{pct(c[('base', 12)][N])}）；GRPO 训练后其中 {new12} 道至少答对过一次——全部——而且 pass@1 就有 {pct(c[('grpo', 12)][1])}。"
        "在这个世界里，RL 的 pass@1 远远超过了基座的 pass@512——'RL 只是把基座会的东西挑出来'**不成立**。"
        "原因是 RL 从'有时能对'的题（较短的题面）上学到了一条通用的规则（在第 k 步停下），这条规则迁移到了起点从来做不对的题上。",
        f"但在没训练过的更长题面上，pass@k 曲线的形状很不一样：GRPO 从 k = 1 到 k = {N} 只从 {pct(c[('grpo', 16)][1])} 升到 "
        f"{pct(c[('grpo', 16)][N])}（n = 16），SFT 从 {pct(c[('sft', 16)][1])} 升到 {pct(c[('sft', 16)][N])}。"
        f"k = 1 时两者几乎一样，k 越大 SFT 领先越多（n = 14 上 k = {N} 时 {pct(c[('sft', 14)][N])} vs {pct(c[('grpo', 14)][N])}）。"
        "Yue et al. 报告的'两条曲线交叉'在这里出现在 **RL 和 SFT** 之间：RL 让模型更确定，也让它更少尝试别的路径。",
        f"RL 也会**丢掉**一些起点能做对的题：n = 16 上，起点在 {N} 次里至少对过一次、GRPO 训练后一次都没对过的题有 "
        f"{lost16g} 道（SFT {lost16s} 道）；同时 GRPO 新解出 {new16g} 道（SFT {new16s} 道）。'扩展'和'收窄'同时发生，"
        "净效果取决于题目分布——只报告一个 k 的 pass@k 看不出这一点。",
        f"多样性（演示 3）：起点模型每道题平均有 {res[('base', 16)][2]:.2f} 种不同的答案（n = 16），GRPO {res[('grpo', 16)][2]:.2f}、"
        f"RFT {res[('rft', 16)][2]:.2f}、SFT {res[('sft', 16)][2]:.2f}；RFT 的熵最低。只学自己成功样本的 RFT 收窄得最厉害，"
        "pass@k 曲线也最平——和'熵坍缩'（Cui et al. 2025）的描述一致：熵一旦塌下去，探索就停止了，后续训练很难再找到新的解法。",
        f"实验设计上的教训：(1) 报告 pass@1 的同时报告大 k 的 pass@k 和'从没解出'的题数；(2) 起点在 n = 16 上 pass@{N} 只有 "
        f"{pct(c[('base', 16)][N])}、曲线还在快速上升，如果采样更多次（数千、数万），它可能和 RL 交叉——"
        "'RL 是否扩展边界'的结论依赖于你把 k 推到多大；(3) 结论也依赖于测试题是否在 RL 的训练分布里（n = 12 在、n = 16 不在）。",
    ])
    print(f"\n⏱ 总用时 {time.perf_counter() - t0:.0f} 秒")


if __name__ == "__main__":
    main()
