"""
=============================================================================
CS486 · 第 9 讲 · 代码 01：把同一个提议者放进不同的搜索算法里
=============================================================================

【这个脚本要回答的问题】（讲义 §2–§4）
    第 8 讲的提议者自己写计划（开环、一口气写完）：5 块积木 44%，7 块 0%。它的主要错误是"跟丢了状态"——
    写到第三四步时，已经不知道每块积木在哪了。

    把它放进搜索里，有好几种做法：
      - Tree of Thoughts（Yao et al. 2023）：每个状态让 LLM 提出几个候选"想法"，再让 LLM 评估每个候选，
        按 BFS（保留最好的 b 个）或 DFS（回溯）搜索。Game of 24 上 GPT-4 + CoT 4%，ToT（b = 5）74%。
      - RAP（Hao et al. 2023）：LLM 同时当世界模型和策略，用蒙特卡洛树搜索（MCTS）。
      - AlphaZero 式的 PUCT：策略给先验、价值网络给评估。
    这一讲先用**真实的模拟器**（每一步合法与否、执行后的状态都由规则算出），只研究"策略 + 评估 + 搜索算法"。

    演示 1：最简单的一步——闭环。每一步把真实状态告诉提议者，它只需要想第一步。有没有"记忆"差别很大。
    演示 2：几种搜索算法的成功率和成本：束搜索（只用策略）、ToT 式 BFS、最佳优先、MCTS（策略 + 学出来的价值函数），
           以及不用任何学习的 BFS 和 A*（人写的启发函数）。
    演示 3：提议是瓶颈——只展开提议者最看好的 b 个动作时，再好的评估器也救不回来。

【运行】
    python3 01_search_with_a_proposer.py           # 第一次约 2.5 分钟（训练价值函数约 40 秒），之后约 2 分钟
    python3 01_search_with_a_proposer.py --quick   # 冒烟测试

【依赖】PyTorch + 本目录的 search_lab.py（第 8 讲的提议者缓存）。
"""

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"))

import search_lab as sl                                                  # noqa: E402
from console import pct, print_header, print_observations, print_table  # noqa: E402

pl = sl.pl
NS = (4, 5, 6, 7)


def run(method, probs):
    """
    对一组题运行一个搜索方法。

    【参数】method：函数 (init, goal) → (计划 或 None, 展开数)
    【返回】(成功率, 计划最短的比例, 平均展开数, 每道题的展开数列表（失败为 None）)
    【边界】"成功"始终用第 8 讲的可靠验证器判定（搜索算法用的是真实模拟器，所以找到的计划应该都合法——这里再查一遍）。
    """
    ok = opt = 0
    exps, solved_at = [], []
    for a, b, best in probs:
        plan, exp = method(a, b)
        exps.append(exp)
        good = plan is not None and pl.validate(a, b, pl.plan_to_str(plan)).ok
        ok += good
        opt += good and len(plan) == len(best)
        solved_at.append(exp if good else None)
    n = len(probs)
    return ok / n, opt / n, sum(exps) / n, solved_at


def main():
    ap = argparse.ArgumentParser(description="把提议者放进搜索")
    ap.add_argument("--quick", action="store_true", help="冒烟测试")
    args = ap.parse_args()
    t0 = time.perf_counter()
    count = 8 if args.quick else 100
    ns = (4, 5) if args.quick else NS

    print_header("准备：第 8 讲的提议者 + 学出来的价值函数；每个 n %d 道题（和第 8 讲代码 03 相同的题）" % count)
    proposer = pl.train_proposer(quick=args.quick)
    value_model = sl.train_value(quick=args.quick)
    sets = {n: pl.test_problems(n, count, seed=1) for n in ns}

    # ------------------------------------------------------------------
    print_header("演示 1：开环 vs 闭环（每一步把真实状态告诉提议者，执行它最想做的合法动作）")
    rows, d1 = [], {}
    for n in ns:
        probs = sets[n]
        plans = pl.propose(proposer, [pl.prompt(a, b) for a, b, _ in probs], 2 * n + 2)
        open_ok = sum(pl.validate(a, b, s).ok for (a, b, _), s in zip(probs, plans)) / count
        pol = sl.Policy(proposer)
        nomem = run(lambda a, b: sl.greedy_closed(pol, a, b, 4 * n, memory=False), probs)
        mem = run(lambda a, b: sl.greedy_closed(pol, a, b, 4 * n, memory=True), probs)
        loops = 0
        for a, b, _ in probs:                     # 没有记忆的闭环贪心：失败的题里，有多少是"回到了走过的状态"
            plan, _ = sl.greedy_closed(pol, a, b, 4 * n, memory=False)
            if plan is None:
                s, seen = a, {a}
                for _ in range(4 * n):
                    s, _ = pl.apply_move(s, sl.ranked_moves(pol, s, b)[0])
                    if s in seen:
                        loops += 1
                        break
                    seen.add(s)
        mass = sum(pol([a], b)[0][1] for a, b, _ in probs) / count
        d1[n] = (open_ok, nomem[0], mem[0], loops, mass)
        rows.append([n, pct(open_ok, 0), pct(nomem[0], 0), f"{loops}", pct(mem[0], 0), f"{mass:.2f}"])
    print_table(["n", "开环（第 8 讲）", "闭环、无记忆", "失败中兜圈子", "闭环、记住走过的状态", "合法动作上的概率"],
                rows, [4, 16, 14, 12, 22, 18])
    print("  （'失败中兜圈子' = 无记忆的闭环里，失败的题中回到了走过的状态的题数；"
          "'合法动作上的概率' = 初始状态下提议者放在合法动作上的总概率，平均）")

    # ------------------------------------------------------------------
    print_header("演示 2：几种搜索算法（真实模拟器；'价值' = 学出来的价值函数；A* 用人写的启发函数 misplaced）")
    budget = 60 if args.quick else 300
    methods = [
        ("闭环贪心 + 记忆", "策略", lambda pol, V, n: (lambda a, b: sl.greedy_closed(pol, a, b, 4 * n))),
        ("束搜索 W=4", "策略", lambda pol, V, n: (lambda a, b: sl.beam_search(pol, a, b, 4, 3 * n))),
        ("束搜索 W=16", "策略", lambda pol, V, n: (lambda a, b: sl.beam_search(pol, a, b, 16, 3 * n))),
        ("ToT 式 BFS W=4", "策略 + 价值", lambda pol, V, n: (lambda a, b: sl.beam_search(pol, a, b, 4, 3 * n, value=V))),
        (f"最佳优先（≤{budget}）", "策略 + 价值", lambda pol, V, n: (lambda a, b: sl.best_first(pol, V, a, b, budget))),
        (f"MCTS（≤{budget}）", "策略 + 价值", lambda pol, V, n: (lambda a, b: sl.mcts(pol, V, a, b, budget))),
        ("BFS（盲目）", "—", lambda pol, V, n: (lambda a, b: pl.bfs(a, b))),
        ("A*（misplaced）", "人写的启发", lambda pol, V, n: (lambda a, b: pl.astar(a, b))),
    ]
    res = {}
    for name, uses, make in methods:
        for n in ns:
            pol, V = sl.Policy(proposer), sl.ValueNet(value_model)
            ok, opt, exp, solved_at = run(make(pol, V, n), sets[n])
            res[(name, n)] = (ok, opt, exp, solved_at, pol.calls + V.calls)
    print_table(["方法", "用到", *[f"n={n} 成功" for n in ns]],
                [[name, uses, *[pct(res[(name, n)][0], 0) for n in ns]] for name, uses, _ in methods],
                [22, 12] + [10] * len(ns))
    print("\n  成本：平均展开的状态数 / 平均模型调用次数（策略 + 价值，同一状态只算一次；BFS、A* 不调用模型）")
    print_table(["方法", *[f"n={n}" for n in ns]],
                [[name, *[f"{res[(name, n)][2]:.0f} / {res[(name, n)][4] / count:.0f}" for n in ns]]
                 for name, _, _ in methods], [22] + [14] * len(ns))
    print("\n  同一个预算下：最佳优先和 MCTS 在展开 ≤ 30 / ≤ 100 / ≤ %d 个状态时的成功率" % budget)
    rows = []
    for name in (f"最佳优先（≤{budget}）", f"MCTS（≤{budget}）"):
        for n in ns:
            sa = res[(name, n)][3]
            rows.append([name, n, *[pct(sum(x is not None and x <= B for x in sa) / count, 0) for B in (30, 100, budget)]])
    print_table(["方法", "n", "≤ 30", "≤ 100", f"≤ {budget}"], rows, [22, 4, 8, 8, 8])
    opt_rows = [[name, *[pct(res[(name, n)][1] / max(res[(name, n)][0], 1e-9), 0) if res[(name, n)][0] else "-" for n in ns]]
                for name, _, _ in methods]
    print("\n  找到的计划里，最短的比例")
    print_table(["方法", *[f"n={n}" for n in ns]], opt_rows, [22] + [10] * len(ns))

    # ------------------------------------------------------------------
    print_header("演示 3：提议是瓶颈——每个状态只展开提议者最看好的 b 个动作（评估器用 misplaced，它几乎从不误导）")
    rows, d3 = [], {}
    for n in (5, 6) if not args.quick else (5,):
        for b in (1, 2, 3, 5, None):
            pol = sl.Policy(proposer)
            ok, _, exp, _ = run(lambda a, g: sl.beam_search(pol, a, g, 4, 3 * n, value=sl.misplaced_value, topb=b), sets[n])
            # 提议的召回率：沿着 A* 的最短路径，每个状态上"提议者的前 b 个动作里至少有一个是最优动作"的比例
            hit = tot = 0
            for a, g, best in sets[n]:
                s = a
                for m in best:
                    d = sl.true_distance(s, g)
                    moves = sl.ranked_moves(pol, s, g)
                    top = moves[:b] if b else moves
                    hit += any(sl.true_distance(pl.apply_move(s, mm)[0], g) == d - 1 for mm in top)
                    tot += 1
                    s = pl.apply_move(s, m)[0]
            d3[(n, b)] = (ok, hit / tot)
            rows.append([n, b if b else "全部", pct(hit / tot, 0), pct(ok, 0), f"{exp:.0f}"])
    print_table(["n", "每个状态展开 b 个", "前 b 个里有最优动作", "成功（W=4）", "平均展开"], rows, [4, 18, 20, 12, 10])

    if args.quick:
        print("\n（--quick 冒烟测试：数字没有意义，不打印观察。）")
        print(f"\n⏱ 总用时 {time.perf_counter() - t0:.0f} 秒")
        return

    r = lambda name, n: res[(name, n)][0]
    bf, mc = f"最佳优先（≤{budget}）", f"MCTS（≤{budget}）"
    sa_bf, sa_mc = res[(bf, 5)][3], res[(mc, 5)][3]
    print_observations([
        f"闭环要有记忆（演示 1）：每一步由模拟器告诉提议者真实状态，它不再需要在'脑子里'推演整个计划；"
        f"可是没有记忆时，n = 5 只有 {pct(d1[5][1], 0)}（开环 {pct(d1[5][0], 0)}），失败的 {round((1 - d1[5][1]) * count)} 道"
        f"{'全部' if d1[5][3] == round((1 - d1[5][1]) * count) else f'里有 {d1[5][3]} 道'}是回到了走过的状态、来回打转——"
        "提议者每次只看眼前，在两个状态之间反复横跳。记住走过的状态之后，n = 4 / 5 / 6 / 7 是 "
        f"{pct(d1[4][2], 0)} / {pct(d1[5][2], 0)} / {pct(d1[6][2], 0)} / {pct(d1[7][2], 0)}（开环 {pct(d1[4][0], 0)} / "
        f"{pct(d1[5][0], 0)} / {pct(d1[6][0], 0)} / {pct(d1[7][0], 0)}）：n = 6、7 上明显更好，n = 4 反而略差——"
        "一口气写出的计划前后连贯，每步重新问一次的'第一步'不一定。智能体的'兜圈子'（第 11 讲）是同一个现象。",
        f"搜索把提议者的'第二、第三选择'变成了成功（演示 2）：只用策略的束搜索 W = 16 在 n = 5 / 6 / 7 上 "
        f"{pct(r('束搜索 W=16', 5), 0)} / {pct(r('束搜索 W=16', 6), 0)} / {pct(r('束搜索 W=16', 7), 0)}——同一个提议者，"
        f"第 8 讲的 LLM-Modulo（10 轮反馈）是 74% / 16% / 4%。代价是模型调用：n = 5 平均 {res[('束搜索 W=16', 5)][4] / count:.0f} 次，"
        "LLM-Modulo 是 4.5 次。",
        f"加上学出来的价值函数并没有带来预想的提高（演示 2）：ToT 式 BFS（W = 4）n = 5 / 6 是 "
        f"{pct(r('ToT 式 BFS W=4', 5), 0)} / {pct(r('ToT 式 BFS W=4', 6), 0)}，还不如只按策略概率的束搜索 W = 4"
        f"（{pct(r('束搜索 W=4', 5), 0)} / {pct(r('束搜索 W=4', 6), 0)}）；最佳优先 {pct(r(bf, 5), 0)} / {pct(r(bf, 6), 0)}，"
        f"而且模型调用多得多（n = 6：最佳优先 {res[(bf, 6)][4] / count:.0f} 次，大部分花在给所有子状态打分上）。"
        f"而换成人写的启发函数，A* 全部 100%、n = 7 平均只展开 {res[('A*（misplaced）', 7)][2]:.0f} 个状态。"
        "价值函数为什么帮不上忙，是代码 02 的问题。",
        f"MCTS 在这里最慢（演示 2）：n = 5 时展开 ≤ 30 / ≤ 100 / ≤ {budget} 个状态的成功率 "
        f"{pct(sum(x is not None and x <= 30 for x in sa_mc) / count, 0)} / {pct(sum(x is not None and x <= 100 for x in sa_mc) / count, 0)} / "
        f"{pct(r(mc, 5), 0)}，最佳优先是 {pct(sum(x is not None and x <= 30 for x in sa_bf) / count, 0)} / "
        f"{pct(sum(x is not None and x <= 100 for x in sa_bf) / count, 0)} / {pct(r(bf, 5), 0)}。"
        "一个解释：MCTS 的设计目标是估计每个动作的'平均'价值（对手、随机性），把预算分散在很多分支上；"
        "在确定性、目标可检查的问题里，一碰到目标就可以停，往深处钻的方法更快。选搜索算法要看问题的结构，不是越'高级'越好。",
        f"提议是瓶颈（演示 3）：评估器几乎完美时，每个状态只展开提议者的前 2 个动作，n = 5 成功 {pct(d3[(5, 2)][0], 0)}、"
        f"前 3 个 {pct(d3[(5, 3)][0], 0)}、全部 {pct(d3[(5, None)][0], 0)}——因为提议者的前 2 个动作里只有 "
        f"{pct(d3[(5, 2)][1], 0)} 的时候包含最优动作。评估器只能在**提出来的**候选里挑；第 4 讲的'覆盖率'在搜索里同样是上限。",
    ])
    print(f"\n⏱ 总用时 {time.perf_counter() - t0:.0f} 秒")


if __name__ == "__main__":
    main()
