"""
=============================================================================
第 3 讲 · 01：检查结果（ORM）还是检查过程（PRM）？
=============================================================================

对应讲义：lectures/lecture03_verification.md 第 2、3 节（Cobbe et al. 2021；Lightman et al. 2023）

-----------------------------------------------------------------------------
【三个演示】
-----------------------------------------------------------------------------
演示 1  best-of-N 选择：10B 生成器，比较 majority vote、"自己检查自己"的 ORM / PRM、
        用更强的 100B checker 的 ORM / PRM，以及 weighted 与"只看最高分"两种挑法。
        问题：verifier 的强弱、类型、挑法，哪个最重要？
演示 2  "答案对、过程错"：弱模型答对的解答里，有多少其实中间算错了（错误碰巧抵消）？
        ORM 只看答案，会把它们当作好解答；PRM 能把它们找出来吗？PRM 能定位第一个错误吗？
演示 3  PRM 引导的 beam search：PRM 能给**不完整**的解答打分，所以可以一步一步地搜索；
        和同等预算的 best-of-N 比较。ORM 只能给完整的解答打分，做不了这件事。

【成本的约定】演示 1、3 的"预算"只算生成器的生成次数，不算 verifier 的成本。
真实的 PRM 一次前向传播就能给所有步骤打分，而 toy world 里的 ORM 要让 checker 重解 4 遍，
所以 ORM 实际上贵得多（讲义第 3.4 节）。

运行时间：约 5 秒（只用标准库）。
"""

import random

from verifier_tools import (ToyLLM, aggregate, first_error, majority_vote, make_problem_set, mean, orm_score,
                            pad, prm_step_scores, sample_step, select_by_scores, step_prob)

TEMPERATURE = 0.7


# =============================================================================
# 演示 1：best-of-N 选择
# =============================================================================
def demo_selection(problems, gen, big, rng):
    print("=" * 78)
    print(f"演示 1：best-of-N —— {gen.name} 生成器，不同的 verifier 和挑法（200 道 3~8 步题）")
    print("=" * 78)
    methods = ["coverage", "vote", f"ORM({gen.name})", f"PRM({gen.name})", f"ORM({big.name})",
               f"PRM({big.name})", f"PRM({big.name}) weighted"]
    print(pad("N", 5) + "".join(pad(m, 20 if m.endswith("weighted") else 13, "right") for m in methods))
    for n in (4, 16, 64):
        hits = {m: [] for m in methods}
        for p in problems:
            cands = [gen.generate(p, rng, TEMPERATURE) for _ in range(n)]
            hits["coverage"].append(any(c.correct for c in cands))
            hits["vote"].append(majority_vote([c.answer for c in cands])[0] == p.answer())
            for checker in (gen, big):
                orm = [orm_score(checker, p, c, rng) for c in cands]
                prm = [aggregate(prm_step_scores(checker, p, c)) for c in cands]
                hits[f"ORM({checker.name})"].append(select_by_scores(cands, orm, weighted=False).correct)
                hits[f"PRM({checker.name})"].append(select_by_scores(cands, prm, weighted=False).correct)
                if checker is big:
                    hits[f"PRM({big.name}) weighted"].append(select_by_scores(cands, prm, weighted=True).correct)
        print(pad(str(n), 5) + "".join(pad(f"{mean(hits[m]):.3f}", 20 if m.endswith("weighted") else 13, "right")
                                       for m in methods))
    print("\n观察：")
    print(f"  · 自己检查自己（{gen.name} 当 checker）：ORM 和 PRM 都只比 majority vote 好一点点。")
    print("    checker 和生成者有同样的盲点：它\"最相信\"的错误套路，检查时也会被判成对的。")
    print(f"  · 换成更强的 {big.name} checker，挑选准确率几乎追上 coverage（N = 64 时接近 1）。")
    print("    verifier 的**强弱**比它的类型重要得多：generation–verification gap 要靠一个\"比生成者更懂\"的检查者来缩小。")
    print(f"  · 同样用 {big.name} 当 checker，ORM 和 PRM 在这里挑得一样好：只比较完整的答案时，PRM 并不占优。")
    print("    PRM 的优势在别处（演示 2、3）。")
    print("  · verifier 很可靠时，weighted（同一答案的分数相加）反而更差：频繁出现的错误答案被\"人多势众\"地加了分。")
    print("    weighted 适合噪声大的 verifier（第 2 讲），可靠的 verifier 应该直接选最高分。\n")


# =============================================================================
# 演示 2：答案对、过程错
# =============================================================================
def demo_lucky_answers(problems, big, rng):
    print("=" * 78)
    print("演示 2：\"答案对、过程错\"的解答有多常见？PRM 能找出它们、定位第一个错误吗？")
    print("=" * 78)
    for gen in (ToyLLM(1e9), ToyLLM(1e10)):
        lucky, n_correct = [], 0
        wrong_hits, n_wrong = 0, 0
        for p in problems:
            for _ in range(30):
                s = gen.generate(p, rng, TEMPERATURE)
                scores = prm_step_scores(big, p, s)
                if s.correct:
                    n_correct += 1
                    if not all(s.local_ok):
                        lucky.append(min(scores))
                else:
                    n_wrong += 1
                    worst = min(range(len(scores)), key=lambda i: scores[i])
                    wrong_hits += (worst == first_error(p, s))
        flagged = sum(1 for m in lucky if m < 0.2)
        print(f"  {gen.name} 生成器：答对的 {n_correct} 个解答里，有 {len(lucky)} 个（{len(lucky) / n_correct:.1%}）"
              f"中间算错了、答案却碰巧对了；")
        print(f"      其中 {flagged} 个（{flagged / max(1, len(lucky)):.0%}）被 PRM({big.name}) 发现"
              f"（最可疑一步的分数 < 0.2）。ORM 只看答案，会全部放过。")
        print(f"      答错的 {n_wrong} 个解答里，PRM 分数最低的一步恰好是第一个错误步骤的比例："
              f"{wrong_hits / n_wrong:.1%}")
    print("\n观察：")
    print("  · 弱模型的\"正确答案\"里混着相当比例的错误推理：错误互相抵消（例如一步少算 10、后一步多算 10）。")
    print("    在真实任务里，这对应\"蒙对了\"——例如选择题、或者答案只有少数几种可能的题。")
    print("  · 用结果（答案对不对）当训练标签时，这些解答会被当成好样本：ORM 学会奖励错误的推理，")
    print("    用自己的正确解答训练自己（第 6 讲的 STaR）也会把错误的推理学进去。")
    print("  · 足够强的 PRM 逐步检查，在这里把这样的解答全部找了出来；对答错的解答，它多数时候能指出第一个错误")
    print("    在哪一步（生成器越强、错误越少越集中，定位越准）。这正是\"过程监督\"在可解释性和对齐上的价值")
    print("    （Lightman et al. 2023）：它奖励的是\"正确的推理\"，而不只是\"正确的答案\"。\n")


# =============================================================================
# 演示 3：PRM 引导的 beam search
# =============================================================================
def beam_search(gen, checker, problem, rng, beam_width, expand):
    """
    PRM 引导的 beam search：一步一步地生成，每一步只保留 PRM 分数最高的 beam_width 个部分解答。

    【思路】
        1. 开始时只有一个空的部分解答。
        2. 对第 i 步：每个保留下来的部分解答，用生成器采样 expand 个"下一步"；
           新部分解答的分数 = 旧分数 × checker 给这一步的分数（step_prob），即 PRM 的乘积聚合。
        3. 按分数排序，去掉重复的前缀，只保留前 beam_width 个。
        4. 走完所有步骤后，返回分数最高的完整解答是否正确。
    【参数】gen：生成器；checker：PRM 用的模型；problem；rng；beam_width：B；expand：E
    【返回】bool
    【边界】每一步要生成 B × E 个"下一步"，总的生成量 ≈ B × E 个完整解答，和 best-of-(B × E) 相当。
        只有能给**部分解答**打分的 verifier（PRM）才能这样搜索；ORM 必须等解答写完才能打分。
    """
    beams = [([], 1.0)]
    for i, step in enumerate(problem.steps):
        candidates = []
        for values, score in beams:
            x = values[-1] if values else problem.start
            for _ in range(expand):
                v, _proc = sample_step(gen, step, x, i, rng, TEMPERATURE)
                candidates.append((values + [v], score * step_prob(checker, step, x, v, i)))
        candidates.sort(key=lambda t: -t[1])          # 【Python 小知识】list.sort 原地排序，key 指定比较的依据
        beams, seen = [], set()
        for values, score in candidates:
            key = tuple(values)                       # list 不能放进 set，先转成 tuple
            if key not in seen:
                seen.add(key)
                beams.append((values, score))
            if len(beams) == beam_width:
                break
    best_values, _ = max(beams, key=lambda t: t[1])
    return best_values[-1] == problem.answer()


def demo_beam_search(problems, gen, big, rng):
    print("=" * 78)
    print("演示 3：PRM 引导的 beam search vs best-of-N（同等的生成量）")
    print("=" * 78)
    print(pad("每步生成量", 12) + pad("checker", 10) + pad("best-of-N", 12, "right")
          + pad("beam search", 14, "right") + "   （beam 的配置）")
    for budget, (b, e) in ((4, (1, 4)), (16, (2, 8)), (64, (4, 16))):
        for checker in (gen, big):
            bon = mean(select_by_scores(cands, [aggregate(prm_step_scores(checker, p, c)) for c in cands],
                                        weighted=False).correct
                       for p in problems for cands in [[gen.generate(p, rng, TEMPERATURE) for _ in range(budget)]])
            beam = mean(beam_search(gen, checker, p, rng, b, e) for p in problems)
            print(pad(str(budget), 12) + pad(checker.name, 10) + pad(f"{bon:.3f}", 12, "right")
                  + pad(f"{beam:.3f}", 14, "right") + f"   B = {b}，E = {e}")
    print("\n观察：")
    print(f"  · 用强的 PRM（{big.name}）时，beam search 在同样的生成量下明显好于 best-of-N，预算越小差距越大：")
    print("    best-of-N 要等整道题写完才能挑，一步写错整份解答就作废；beam search 每一步都把明显错误的分支剪掉，")
    print("    把生成量集中在\"还在正确的路上\"的前缀上。")
    print(f"  · 用自己当 PRM（{gen.name}）时，beam search 几乎没有优势：PRM 看不出模型自己的误解，剪枝剪错了方向。")
    print("  · Snell et al. 2024 在 MATH 上也发现：预算小、题目难时 beam search 更好；预算大、题目简单时，")
    print("    beam search 会\"过度优化\" PRM 的分数，反而不如 best-of-N（第 2 讲第 4.2 节）。\n")


def main():
    problems = make_problem_set(random.Random(60), n_problems=200, min_steps=3, max_steps=8)
    gen, big = ToyLLM(1e10), ToyLLM(1e11)
    demo_selection(problems, gen, big, random.Random(61))
    demo_lucky_answers(problems, big, random.Random(62))
    demo_beam_search(problems, gen, big, random.Random(63))


if __name__ == "__main__":
    main()
