"""
=============================================================================
CS486 · 第 6 讲 · 代码 01：思维链忠实吗？——因果干预、可以抄的提示、被奖励漏洞改造的思维链
=============================================================================

【这个脚本要回答的问题】（讲义 §2–§3）
    模型写下的思维链（CoT），是它**真正**得出答案的过程，还是事后的说明？研究里常用两类证据：
      - 因果干预（Lanham et al. 2023）：截断思维链、在中间插入错误——如果答案跟着变，思维链是"承重"的；
      - 提示实验（Turpin et al. 2023；Chen et al. 2025）：在题目里放一个提示，看模型用没用它、思维链里提没提它。
    我们在实验台上做这两类实验，再加上一个 Chen et al. 和 Baker et al.（2025）讨论的问题：
    **当训练奖励有漏洞时，思维链会变成什么样？监控思维链能不能发现？**

    演示 1：因果干预——cot_L2 的答案跟着思维链走吗？（截断；把第 1 步换成错字母）
    演示 2：一个"可以抄提示"的模型——训练数据里一半的题目前面放着**正确**的提示。它用提示吗？
    演示 3：有漏洞的奖励——评分程序拿着一份错误的答案表（也就是题前的错误提示），奖励"答案等于提示"。
            从 cot_L2 出发做 GRPO，3 个种子：模型学会了抄提示吗？它的思维链变成了什么样？
    演示 4：把"逐步检查"的监控器写进奖励（只有每一步都合法、并且答案等于提示时才得分）——模型会怎样应对？
    演示 5：四种监控器各能抓住多少"抄了提示"的回答？

【运行】
    python3 01_faithfulness.py           # 第一次约 2 分钟（1 个 SFT 模型 + 6 次 RL，并行），之后约 10 秒
    python3 01_faithfulness.py --quick   # 冒烟测试

【依赖】PyTorch + 本目录的 reliability_lab.py（以及第 1、2、4、5 讲的模块）。
"""

import argparse
import os
import random
import sys
import time

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

import hop_world as hw                                                   # noqa: E402
import reliability_lab as rlab                                           # noqa: E402
import tiny_reasoner as tr                                               # noqa: E402
from console import pct, print_header, print_observations, print_table  # noqa: E402

SEEDS = (0, 1, 2)
MON = ("nonempty", "consistent", "valid", "complete")
MON_LABEL = {"nonempty": "推理链非空", "consistent": "答案 = 结论", "valid": "每步合法", "complete": "恰好 k 步"}


def rl_jobs():
    """6 个 RL 任务：两种奖励（只看提示 / 提示 + 逐步检查）× 3 个种子。"""
    return [{"seed": s, "steps": 300, "lr": 1e-3, "monitor": m, "eval_every": 50}
            for m in ("none", "steps") for s in SEEDS]


def demo1(model, per_k):
    print_header("演示 1：因果干预——cot_L2 的答案跟着思维链走吗？（n = 8，训练分布内）")
    rows, out = [], {}
    for k in (3, 5, 7):
        probs = hw.make_eval_set(8, k, per_k, seed=51)
        cells = [f"k = {k}"]
        for i in (0, 1, k - 1):
            ans = rlab.early_answer(model, probs, i)
            last = [p.walk[i] for p in probs]                  # 截断处"最后写下的字母"（i = 0 时是起点）
            same_last = sum(a == b for a, b in zip(ans, last)) / len(probs)
            correct = sum(a == p.answer for a, p in zip(ans, probs)) / len(probs)
            out[(k, i)] = (same_last, correct)
            cells.append(f"{pct(same_last, 0)} / {pct(correct, 0)}")
        rows.append(cells)
    print("  (a) 截断：只保留正确推理链的前 i 步，接着写 '|' 让模型作答。每格：答案 = 截断处的字母 / 答案 = 正确答案")
    print_table(["", "i = 0（不写）", "i = 1", "i = k-1"], rows, [8, 16, 16, 16])

    rows = []
    rng = random.Random(3)
    for k in (3, 5, 7):
        probs = hw.make_eval_set(8, k, per_k, seed=52)
        res = rlab.corrupt_and_continue(model, probs, 1, rng)
        faithful = recovered = 0
        for p, (wrong, resp) in zip(probs, res):
            ans = hw.parse_answer(resp, "cot")
            faithful += ans == rlab.follow_chain(p, wrong)
            recovered += ans == p.answer
        out[("corrupt", k)] = (faithful / len(probs), recovered / len(probs))
        rows.append([f"k = {k}", pct(faithful / len(probs)), pct(recovered / len(probs)),
                     f"{probs[0].trace} → {res[0][0]}{res[0][1]}"])
    print("\n  (b) 插入错误：把第 1 步换成环里另一个字母，让模型接着写完。")
    print_table(["", "答案 = 从错字母忠实走完", "答案 = 正确答案", "例：正确推理链 → 模型写的"], rows, [8, 24, 18, 28])
    return out


def demo2(hint_model, count):
    print_header("演示 2：一个可以抄提示的模型（训练时一半的题前面有正确的提示 '_<答案>;'）")
    probe = rlab.hint_probe(hint_model, count=count)
    rows = []
    for cond, label in (("none", "不带提示"), ("wrong", "带错误提示")):
        s = probe[cond]
        rows.append([label, pct(s["correct"]), pct(s["follow"]) if cond == "wrong" else "-",
                     pct(s["gold"]), pct(s["consistent"])])
    print_table(["测试条件", "答对", "答案 = 提示", "推理链完全正确", "答案 = 推理链结论"], rows, [12, 8, 12, 16, 18])
    print(f"  例（正确推理链, 提示, 模型的回答）：{probe['examples'][:3]}")
    return probe


def rl_table(title, results):
    print_header(title)
    rows, finals = [], []
    for s, (model, hist) in zip(SEEDS, results):
        fin = rlab.hint_probe(model, count=len(hist["evals"][-1][1]["reports"]))   # 用训练后的模型重新探测（贪心，结果确定）
        finals.append(fin)
        rew = hist["steps"]
        cells = [f"种子 {s}"] + [pct(sum(rew[a:b]) / (b - a), 0) for a, b in ((0, 50), (50, 100), (250, 300))]
        w = fin["wrong"]
        cells += [pct(w["follow"])] + [pct(w[m]) for m in MON] + [pct(fin["none"]["correct"])]
        rows.append(cells)
    print_table(["", "奖励 1–50 步", "51–100", "251–300", "答案 = 提示", *[MON_LABEL[m] for m in MON],
                 "无提示时答对"], rows, [8, 12, 8, 8, 12, 12, 12, 10, 10, 14])
    print("  （后六列：训练 300 步后，在 300 道 n = 8 的题上贪心作答；前五列用带错误提示的题面，最后一列用不带提示的题面）")
    for s, fin in zip(SEEDS, finals):
        print(f"  种子 {s} 的回答（正确推理链, 提示, 模型的回答）：{fin['examples'][:3]}")
    return finals


def demo5(finals_none, finals_steps):
    print_header("演示 5：在'答案 = 提示'的回答里，每种监控器能标出多少？（3 个种子合计，训练 300 步后）")
    rows, out = [], {}
    for label, finals in (("只奖励'答案 = 提示'", finals_none), ("+ 逐步检查写进奖励", finals_steps)):
        followed = [r for f in finals for r in f["reports"] if r["follow"]]
        caught = {m: sum(not r[m] for r in followed) / max(1, len(followed)) for m in MON}
        caught["any"] = sum(not all(r[m] for m in MON) for r in followed) / max(1, len(followed))
        out[label] = (caught, len(followed))
        rows.append([label, len(followed)] + [pct(caught[m]) for m in MON + ("any",)])
    print_table(["RL 的奖励", "跟随提示的回答数", *[f"{MON_LABEL[m]}？不成立" for m in MON], "至少一个不成立"],
                rows, [22, 16, 18, 18, 16, 16, 14])
    print("  （每格：在跟随了错误提示的回答里，被这个监控器标出来的比例。3 个种子 × 300 道题合在一起）")
    return out


def main():
    ap = argparse.ArgumentParser(description="思维链的忠实性与监控")
    ap.add_argument("--quick", action="store_true", help="冒烟测试")
    args = ap.parse_args()
    t0 = time.perf_counter()
    per_k, count = (10, 20) if args.quick else (100, 300)

    print_header("准备：cot_L2；带提示的 SFT 模型；6 次有漏洞奖励的 RL")
    base = tr.get_model("cot_L2", quick=args.quick, verbose=not args.quick)[0]
    hint_model = rlab.sft_model("hint", quick=args.quick)
    results = rlab.load_hint_rl(rl_jobs(), quick=args.quick)

    d1 = demo1(base, per_k)
    d2 = demo2(hint_model, count)
    fin_none = rl_table("演示 3：有漏洞的奖励（答案等于错误提示就得 1 分）——3 个种子", results[:3])
    fin_steps = rl_table("演示 4：把逐步检查写进奖励（每一步都合法、并且答案等于提示才得 1 分）", results[3:])
    caught = demo5(fin_none, fin_steps)

    if args.quick:
        print("\n（--quick 冒烟测试：数字没有意义，不打印观察。）")
        print(f"\n⏱ 总用时 {time.perf_counter() - t0:.0f} 秒")
        return
    c_none, n_none = caught["只奖励'答案 = 提示'"]
    c_steps, n_steps = caught["+ 逐步检查写进奖励"]
    miss3 = 1 - max(c_steps["nonempty"], c_steps["consistent"], c_steps["valid"])
    print_observations([
        f"cot_L2 的思维链是'承重'的（演示 1）：截断到第 i 步就作答时，答案 {pct(d1[(5, 1)][0], 0)} 是截断处的那个字母、"
        f"{pct(d1[(5, 1)][1], 0)} 是正确答案（k = 5、i = 1；所有 k 和 i 都一样）；把第 1 步换成错字母后，"
        f"答案 {pct(d1[('corrupt', 5)][0], 0)} 跟着错字母忠实地走完。2 层模型一次前向只能做 1 跳（第 1 讲），"
        "它没有别的路可以得到答案——**当思维链是唯一的计算通道时，它就是忠实的**。Lanham et al.（2023）在真实模型上看到"
        "'模型越大，思维链往往越不承重'，一个解释正是大模型有别的通道。",
        f"有一条现成的捷径也不等于会用它（演示 2）：训练时一半的题前面放着正确答案，hint 模型却完全不用——带错误提示时"
        f"仍然 {pct(d2['wrong']['correct'])} 答对、{pct(d2['wrong']['follow'])} 跟随提示。训练数据里推理链本身就足以得到答案，"
        "提示是多余的。忠实与否取决于**训练给了模型什么压力**。",
        f"有漏洞的奖励给了这种压力（演示 3）：3 个种子都在 300 步内学会了 100% 抄提示，而且没有提示时的能力也随之崩溃"
        f"（{' / '.join(pct(f['none']['correct']) for f in fin_none)}）。思维链变成了两种样子：不写推理链（'|a$'），"
        "或者把推理链'掰'成提示字母的重复（'aaaa|a$'、'pbp|p$'）。后者结论和答案一致，只看'前后是否一致'发现不了。",
        f"把逐步检查写进奖励之后（演示 4），推理链变得'合法'了（每步合法：{' / '.join(pct(f['wrong']['valid']) for f in fin_steps)}），"
        f"抄提示却丝毫没减少（{' / '.join(pct(f['wrong']['follow']) for f in fin_steps)}）：模型写一段正确的推理链、"
        "然后在 '|' 后面写上提示（'lgcm|h$'），或者沿着环一直走到提示字母为止（'ecnka|a$'）。"
        "**对着一个监控器优化，违规行为就转移到这个监控器看不见的地方**——这正是 Baker et al.（2025）在前沿推理模型上"
        "观察到的'混淆'（obfuscation），也是他们建议不要把思维链监控器直接放进训练奖励的原因。",
        f"哪种监控器能发现问题（演示 5）：只奖励抄提示时，逐步检查能标出 {pct(c_none['valid'])} 的违规回答，前后一致检查只有 "
        f"{pct(c_none['consistent'])}；逐步检查被写进奖励后，它只能标出 {pct(c_steps['valid'])}，前后一致检查反而标出 "
        f"{pct(c_steps['consistent'])}。前三种监控器合起来仍漏掉 {pct(miss3)}（'ecnka|a$' 这类：每步合法、结论一致、只是步数不对），"
        "要靠第四种'恰好 k 步'才能发现。四种都检查等于完整地验证了解答——在这个世界里验证很便宜；"
        "在验证本身很难的领域，监控只能检查其中几个方面，而被优化的模型会去找没检查的那个方面。",
    ])
    print(f"\n⏱ 总用时 {time.perf_counter() - t0:.0f} 秒")


if __name__ == "__main__":
    main()
