"""
=============================================================================
CS486 · 第 11 讲 · 代码 02：一个在环境里行动的小智能体——任务越长越难；反馈、记忆、检查点、不可逆
=============================================================================

【这个脚本要回答的问题】（讲义 §3–§5）
    第 8 讲的提议者在这里当"智能体"：每一步只看当前状态和当前子目标，决定一个动作；环境执行它、返回新状态。
    一个长任务 = 依次完成 K 个子目标（agent_lab.make_task）。每个子目标都在提议者的训练分布里（3 块积木），
    所以"任务越长越难"只来自步数的累积——Sinha et al.（2025）把执行和规划分开研究，是同一个思路。

    五种智能体 / 环境的设置（agent_lab.AgentConfig）：
      A 不可逆、贪心：执行了不合法的动作就失败；
      B 不可逆 + 执行前检查：环境仍然不可逆，但智能体执行前用规则检查动作是否合法（一个可靠的验证器）；
      C 环境拒绝非法动作：环境返回错误信息，智能体换一个动作；
      D C + 经验记忆：记住"在哪个状态做什么会到哪里"，不再做会回到走过的状态的动作；
      E D + 检查点重试：子任务失败时退回子任务开始时的状态，最多重试 3 次（重试时温度 1.0）。

    演示 1：单个子任务——成功率、失败原因、花了多少步。
    演示 2：任务长度 K = 1 … 32 个子目标——成功率怎样衰减？和"单个子任务成功率的 K 次方"一致吗？
    演示 3：METR 式的时间跨度——以"最少需要的步数"当"人类用时"，拟合 50% / 80% 跨度。
    演示 4：风险率随任务进行变化吗？——在第 j 个子任务失败的条件概率。
    演示 5：为什么会变？——同一组积木名下的子任务难度相近，不同的积木名组合难度不同（任务之间的异质性）。

【运行】
    python3 02_agent_in_environment.py           # 约 2 秒（需要第 8 讲的提议者缓存）
    python3 02_agent_in_environment.py --quick   # 冒烟测试

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

import argparse
import math
import os
import sys
import time
from collections import Counter

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

import agent_lab as ag                                                   # noqa: E402
from console import pct, print_header, print_observations, print_table  # noqa: E402

CONFIGS = [
    ("A 不可逆、贪心", ag.AgentConfig(illegal="fatal")),
    ("B 不可逆 + 执行前检查", ag.AgentConfig(illegal="verify")),
    ("C 环境拒绝非法动作", ag.AgentConfig(illegal="reject")),
    ("D C + 经验记忆", ag.AgentConfig(illegal="reject", memory=True)),
    ("E D + 检查点重试 3 次", ag.AgentConfig(illegal="reject", memory=True, retries=3)),
]
KS = (1, 2, 4, 8, 16, 32)


def main():
    ap = argparse.ArgumentParser(description="在环境里行动的智能体")
    ap.add_argument("--quick", action="store_true", help="冒烟测试")
    args = ap.parse_args()
    t0 = time.perf_counter()
    count = 10 if args.quick else 100
    ks = (1, 2, 4) if args.quick else KS

    import plan_lab as pl
    print_header("准备：第 8 讲的提议者当智能体；每种任务 %d 个" % count)
    pol = ag.RawPolicy(pl.train_proposer(quick=args.quick))

    # ------------------------------------------------------------------
    print_header("演示 1：单个子任务（K = 1）——成功率、失败原因、平均执行的动作数 / 和环境交互的次数")
    rows, d1 = [], {}
    for n in (3, 4):
        tasks = ag.task_set(n, 1, count)
        for name, cfg in CONFIGS:
            res = ag.run_agent(pol, tasks, cfg)
            reasons = Counter(r["reason"] for r in res if not r["ok"])
            ok = sum(r["ok"] for r in res) / count
            d1[(n, name)] = ok
            rows.append([n, name, pct(ok, 0), reasons["fatal"], reasons["timeout"], reasons["stuck"],
                         f"{sum(r['steps'] for r in res) / count:.1f}", f"{sum(r['env'] for r in res) / count:.1f}",
                         f"{sum(r['checks'] for r in res) / count:.1f}"])
    print_table(["n", "设置", "成功", "致命", "超时", "无路", "动作数", "环境交互", "自己检查"],
                rows, [4, 26, 8, 6, 6, 6, 8, 10, 10])
    print("  （失败原因：致命 = 执行了不合法的动作；超时 = 步数用完（最短步数 × 3 + 2）；无路 = 所有候选动作都被排除）")

    # ------------------------------------------------------------------
    print_header("演示 2：任务长度——K 个子目标（3 块积木），完成全部子目标的比例")
    res_all, rows, d2 = {}, [], {}
    moves = {}
    for K in ks:
        tasks = ag.task_set(3, K, count)
        moves[K] = sum(ag.optimal_moves(t) for t in tasks) / count
        for name, cfg in CONFIGS:
            res_all[(K, name)] = (tasks, ag.run_agent(pol, tasks, cfg))
    for name, _ in CONFIGS:
        s1 = sum(r["ok"] for r in res_all[(1, name)][1]) / count
        d2[name] = [sum(r["ok"] for r in res_all[(K, name)][1]) / count for K in ks]
        rows.append([name, *[pct(x, 0) for x in d2[name]]])
        rows.append(["  预测：K=1 的成功率^K", *[pct(s1 ** K, 0) for K in ks]])
    print_table(["设置", *[f"K={K}" for K in ks]], rows, [26] + [8] * len(ks))
    print("  平均最少步数：" + "，".join(f"K={K}：{moves[K]:.0f}" for K in ks))

    # ------------------------------------------------------------------
    print_header("演示 3：METR 式的时间跨度——把所有长度的任务合在一起，P(成功) 对 log(最少步数) 做 logistic 拟合")
    rows, d3 = [], {}
    for name, _ in CONFIGS:
        xs, ys = [], []
        for K in ks:
            tasks, res = res_all[(K, name)]
            for t, r in zip(tasks, res):
                xs.append(math.log(ag.optimal_moves(t)))
                ys.append(r["ok"])
        a, b = ag.fit_logistic(xs, ys)
        h50, h80 = ag.horizon_from_fit(a, b, 0.5), ag.horizon_from_fit(a, b, 0.8)
        d3[name] = (h50, h80)
        rows.append([name, f"{h50:.1f}" if h50 else "-", f"{h80:.1f}" if h80 else "-",
                     f"{h80 / h50:.2f}" if h50 and h80 else "-"])
    print_table(["设置", "50% 跨度（步）", "80% 跨度（步）", "80% / 50%"], rows, [26, 16, 16, 12])

    # ------------------------------------------------------------------
    Kmax = ks[-1]
    print_header(f"演示 4：风险率随任务进行变化吗？——K = {Kmax}，已经完成 j − 1 个子目标的任务里，在第 j 个子目标失败的比例")
    groups = [(1, 1), (2, 4), (5, 8), (9, 16), (17, 32)] if not args.quick else [(1, 1), (2, 4)]
    rows, d4 = [], {}
    for name, _ in CONFIGS[2:]:
        _, res = res_all[(Kmax, name)]
        cells = [name]
        for lo, hi in groups:
            reached = failed = 0
            for j in range(lo, min(hi, Kmax) + 1):
                reached += sum(r["seg"] >= j - 1 for r in res)
                failed += sum(r["seg"] == j - 1 and not r["ok"] for r in res)
            d4[(name, lo)] = failed / reached if reached else None
            cells.append(pct(failed / reached, 1) if reached else "-")
        rows.append(cells)
    print_table(["设置", *[f"第 {lo}–{hi} 个" if lo != hi else f"第 {lo} 个" for lo, hi in groups]], rows, [26] + [12] * len(groups))

    # ------------------------------------------------------------------
    print_header("演示 5：任务之间的异质性——40 组随机的积木名（3 块），每组 40 个随机子任务，设置 C 的成功率")
    import random
    n_sets, per = (8, 10) if args.quick else (40, 40)
    rates = []
    for i in range(n_sets):
        rng = random.Random(f"letters|{i}")
        blocks = "".join(sorted(rng.sample(pl.LETTERS, 3)))
        tasks = []
        for _ in range(per):
            a = pl.random_state(rng, blocks)
            b = a
            while b == a:
                b = pl.random_state(rng, blocks)
            tasks.append([a, b])
        res = ag.run_agent(pol, tasks, CONFIGS[2][1])
        rates.append((blocks, sum(r["ok"] for r in res) / per))
    vals = [r for _, r in rates]
    mean = sum(vals) / len(vals)
    sd = (sum((v - mean) ** 2 for v in vals) / len(vals)) ** 0.5
    sd_binom = (mean * (1 - mean) / per) ** 0.5
    ranked = sorted(rates, key=lambda x: x[1])
    print(f"  各组成功率：平均 {pct(mean)}，组间标准差 {pct(sd)}；如果所有组一样难，只靠抽样误差的标准差应为 {pct(sd_binom)}")
    print("  最难的 3 组：" + "，".join(f"{b}（{pct(r, 0)}）" for b, r in ranked[:3]) +
          "；最容易的 3 组：" + "，".join(f"{b}（{pct(r, 0)}）" for b, r in ranked[-3:]))

    if args.quick:
        print("\n（--quick 冒烟测试：数字没有意义，不打印观察。）")
        print(f"\n⏱ 总用时 {time.perf_counter() - t0:.0f} 秒")
        return
    A, B, C, D, E = (c[0] for c in CONFIGS)
    print_observations([
        f"单个子任务上，每一层保护都有用（演示 1，n = 3）：不可逆、贪心 {pct(d1[(3, A)], 0)}；环境拒绝非法动作 {pct(d1[(3, C)], 0)}；"
        f"加经验记忆 {pct(d1[(3, D)], 0)}；再加检查点重试 {pct(d1[(3, E)], 0)}。'不可逆 + 执行前检查'和'环境拒绝'完全一样"
        f"（{pct(d1[(3, B)], 0)}），只是不碰环境——在不可逆的环境里，**动作之前的验证**替代了环境的宽容。",
        f"任务越长，差别越大（演示 2）：K = 16 个子目标时，A / C / D / E 分别是 {pct(d2[A][ks.index(16)], 0)} / {pct(d2[C][ks.index(16)], 0)} / "
        f"{pct(d2[D][ks.index(16)], 0)} / {pct(d2[E][ks.index(16)], 0)}；K = 32 时 E 还有 {pct(d2[E][-1], 0)}。"
        f"单个子任务上 D 和 E 只差 {round(100 * (d1[(3, E)] - d1[(3, D)]))} 个点，长任务上差几十个点——这就是演示 1 和代码 01 的"
        "'收益递减的错觉'：单步的小改进在长任务上被放大。",
        f"'单个子任务成功率的 K 次方'在短任务上预测得不错，长任务上系统性地偏低（演示 2）：C 在 K = 16 时预测 "
        f"{pct((sum(r['ok'] for r in res_all[(1, C)][1]) / count) ** 16, 0)}、实际 {pct(d2[C][ks.index(16)], 0)}；E 在 K = 32 时预测 "
        f"{pct((sum(r['ok'] for r in res_all[(1, E)][1]) / count) ** 32, 0)}、实际 {pct(d2[E][-1], 0)}。原因见演示 4、5。",
        f"时间跨度（演示 3，以最少步数计）：50% 跨度 A {d3[A][0]:.1f} 步、C {d3[C][0]:.1f} 步、D {d3[D][0]:.1f} 步、E {d3[E][0]:.1f} 步——"
        "同一个模型，换一个环境（会不会拒绝非法动作）、加一点脚手架（记忆、检查点），'时间跨度'可以差一个数量级。"
        "METR 测的是'模型 + 脚手架 + 环境'这个整体，比较不同模型的跨度时，脚手架必须相同。",
        f"风险率随任务进行而**下降**（演示 4）：设置 C 在第 1 个子目标失败的条件概率是 {pct(d4[(C, 1)])}，"
        f"第 17–32 个只有 {pct(d4[(C, 17)])}。不是智能体越做越好——它每一步只看当前状态，没有'经验'。"
        f"原因是任务之间的异质性（演示 5）：每个任务的积木名是固定的一组字母，不同组合的难度不同（组间标准差 {pct(sd)}，"
        f"只靠抽样误差应为 {pct(sd_binom)}）；难的组合早早失败，活到后面的都是容易的组合。这正是代码 01 演示 4 的'脆弱性'效应，"
        "也提醒我们：METR 数据里风险率的下降，同样可能部分来自任务（或运行）之间的异质性，而不只是'智能体越做越顺'。",
        "我们的智能体看不到自己之前的错误，所以不会'被自己的错误带偏'；Sinha et al.（2025）发现 LLM 会——上下文里有自己"
        "之前的错误时，后面更容易出错（自我条件化），思维模型能缓解这一点。对于把整段历史放进上下文的 LLM 智能体，"
        "风险率可能随任务进行而**上升**，和这里的方向相反；两种效应同时存在时，总体曲线的形状取决于哪个更强。",
    ])
    print(f"\n⏱ 总用时 {time.perf_counter() - t0:.0f} 秒")


if __name__ == "__main__":
    main()
