"""
=============================================================================
CS486 · 第 8 讲 · 代码 02：一个"会说计划"的模型——学出来的提议者能规划吗？
=============================================================================

【这个脚本要回答的问题】（讲义 §3–§4）
    PlanBench（Valmeekam et al. 2023）发现，LLM 自己生成可执行计划的能力很有限（GPT-4 在几个领域上平均约 12%）；
    把积木和动作的名字全部换成无意义的词（"神秘积木世界"），准确率几乎归零（2024 年零样本评测里没有一个 LLM 超过 5%）——
    而对经典规划器来说，换名字毫无影响。推理模型 o1-preview 把积木世界提到 97.8%、神秘积木世界提到 52.8%，
    但 6–20 块积木的大题只有 23.6%，而且不保证正确。

    我们的"LLM"是一个 2 层的 TinyReasoner，在 3–5 块积木的题上学"输出最短计划"（plan_lab.train_proposer）——
    它见过大量正确的计划，但从来不搜索。

    演示 1：按积木数看——计划合法的比例、最短的比例、出错的类型（训练只见过 3–5 块）。
    演示 2：它写出的错误计划长什么样？
    演示 3：换一种写法（每摞从上往下写，而不是从下往上）——内容完全相同，只是表面形式变了。
    演示 4：它知道自己的计划对不对吗？（第 7 讲：用它自己的对数概率当置信度）

【运行】
    python3 02_learned_planner.py           # 第一次约 1 分钟（训练提议者），之后约 5 秒
    python3 02_learned_planner.py --quick   # 冒烟测试

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

import argparse
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 plan_lab as pl                                                    # noqa: E402
from console import pct, print_header, print_observations, print_table  # noqa: E402

NS = (3, 4, 5, 6, 7)


def evaluate(model, probs, top_down=False):
    """贪心作答并验证。【返回】(计划列表, 判决列表)"""
    max_moves = 2 * max(len({b for t in p[0] for b in t}) for p in probs) + 2
    plans = pl.propose(model, [pl.prompt(a, b, top_down) for a, b, _ in probs], max_moves)
    return plans, [pl.validate(a, b, s) for (a, b, _), s in zip(probs, plans)]


def auc(pos, neg):
    """AUC：随机挑一个正例、一个负例，正例得分更高的概率（相等算一半）。"""
    if not pos or not neg:
        return None
    return sum((p > q) + 0.5 * (p == q) for p in pos for q in neg) / (len(pos) * len(neg))


def main():
    ap = argparse.ArgumentParser(description="学出来的提议者")
    ap.add_argument("--quick", action="store_true", help="冒烟测试")
    args = ap.parse_args()
    t0 = time.perf_counter()
    count = 20 if args.quick else 200

    print_header("准备：提议者（2 层，3000 步，3–5 块积木的最短计划）")
    model = pl.train_proposer(quick=args.quick)

    print_header(f"演示 1：按积木数看（每个 n {count} 道题，贪心解码；训练只见过 3–5 块）")
    rows, res = [], {}
    for n in NS:
        probs = pl.test_problems(n, count)
        plans, vs = evaluate(model, probs)
        kinds = Counter(v.kind for v in vs)
        opt = sum(v.ok and len(s) // 2 == len(p[2]) for v, s, p in zip(vs, plans, probs))
        res[n] = (probs, plans, vs, kinds, opt)
        rows.append([n, pct(kinds["ok"] / count), pct(opt / count), kinds["precondition"], kinds["goal"], kinds["format"]])
    print_table(["积木数 n", "合法并到达目标", "而且最短", "前提不满足", "没到目标", "格式坏"], rows, [10, 16, 10, 12, 10, 8])

    print_header("演示 2：错误的计划长什么样？（n = 5 和 n = 6 各举 3 个）")
    for n in (5, 6):
        probs, plans, vs, _, _ = res[n]
        shown = 0
        for (a, b, opt), s, v in zip(probs, plans, vs):
            if v.ok or shown >= 3:
                continue
            shown += 1
            print(f"  n = {n}：{pl.state_str(a)} → {pl.state_str(b)}；最短计划 {pl.plan_to_str(opt)!r}；"
                  f"提议 {s!r}；验证器：第 {v.step} 步，{v.reason}")
        step_hist = Counter(v.step for v in vs if v.kind == "precondition")
        print(f"  n = {n} 的前提错误出现在第几步（从 0 数）：{dict(sorted(step_hist.items()))}")

    print_header("演示 3：换一种写法——每摞从上往下写（例如 'ac;b' 写成 'ca;b'），题目完全相同")
    rows, flip = [], {}
    for n in (3, 4, 5):
        probs = res[n][0]
        _, vs_td = evaluate(model, probs, top_down=True)
        ok_td = sum(v.ok for v in vs_td) / count
        sym = sum(pl.validate(a, b, pl.plan_to_str(pl.astar(pl.parse_state(pl.state_str(a, True), True),
                                                             pl.parse_state(pl.state_str(b, True), True))[0])).ok
                  for a, b, _ in probs) / count
        flip[n] = (res[n][3]["ok"] / count, ok_td, sym)
        rows.append([n, pct(res[n][3]["ok"] / count), pct(ok_td), pct(sym)])
    print_table(["n", "提议者：原写法", "提议者：新写法", "A*（换一个解析函数）"], rows, [4, 16, 16, 22])

    print_header("演示 4：提议者知道自己的计划对不对吗？（它给自己计划的对数概率，区分合法 / 不合法的 AUC）")
    rows, self_auc = [], {}
    for n in (4, 5, 6):
        probs, plans, vs, _, _ = res[n]
        lps = pl.plan_logprob(model, [pl.prompt(a, b) for a, b, _ in probs], plans)
        pos = [x for x, v in zip(lps, vs) if v.ok]
        neg = [x for x, v in zip(lps, vs) if not v.ok]
        lens = [len(s) for s in plans]
        a_len = auc([-L for L, v in zip(lens, vs) if v.ok], [-L for L, v in zip(lens, vs) if not v.ok])
        self_auc[n] = (auc(pos, neg), a_len)
        rows.append([n, len(pos), len(neg), "-" if self_auc[n][0] is None else f"{self_auc[n][0]:.3f}",
                     "-" if a_len is None else f"{a_len:.3f}"])
    print_table(["n", "合法的计划", "不合法的计划", "对数概率的 AUC", "'计划越短'的 AUC（对照）"], rows, [4, 12, 14, 16, 26])

    if args.quick:
        print("\n（--quick 冒烟测试：数字没有意义，不打印观察。）")
        print(f"\n⏱ 总用时 {time.perf_counter() - t0:.0f} 秒")
        return
    ok = {n: res[n][3]["ok"] / count for n in NS}
    print_observations([
        f"'会说计划'不等于'会规划'（演示 1）：提议者见过上万个正确的最短计划，在训练分布内也只有 {pct(ok[3])} / {pct(ok[4])} / "
        f"{pct(ok[5])}（3 / 4 / 5 块）的计划合法并到达目标；6 块时 {pct(ok[6])}，7 块时 {pct(ok[7])}。"
        "它写出的计划格式几乎总是对的（'格式坏'几乎为 0）——**看上去像计划，执行起来不合法**。这和 PlanBench 上 LLM 的表现同一个形状："
        "在一项 100 道积木世界题的实验里，GPT-4 单独只做对 40 道；o1-preview 在 3–5 块积木上 97.8%，到 6–20 块只剩 23.6%。",
        "错误主要是前提不满足（搬了一块上面还压着东西的积木），其次是每步都合法、但没到目标（演示 2）。错误常常出现在计划的中段："
        "提议者能写出'先把挡路的积木搬到桌上'这种开头，却在之后跟丢了状态——它没有在内部维护'现在每块积木在哪'，"
        "只是在模仿计划的样子。这正是经典规划器用显式状态和搜索解决的问题。",
        f"换一种写法就彻底失败（演示 3）：同样的题，只是每摞从上往下写，提议者从 {pct(flip[3][0])} / {pct(flip[4][0])} / {pct(flip[5][0])} "
        f"掉到 {pct(flip[3][1])} / {pct(flip[4][1])} / {pct(flip[5][1])}；A* 只要换一个解析函数，就是 {pct(flip[5][2])}。"
        "这是'神秘积木世界'（Mystery Blocksworld）的一个缩小版：规划问题的结构没变，变的只是表面形式，"
        "依赖表面模式的模型就失去了能力，而搜索不受影响。（区别：LLM 在提示里能读到新的领域描述，却用不上；"
        "我们的小模型根本读不懂描述——它只认识训练时的那一种写法。）",
        f"提议者对自己计划的把握有一定区分度（演示 4）：对数概率区分合法 / 不合法的 AUC 在 n = 4、5 时是 {self_auc[4][0]:.3f} / "
        f"{self_auc[5][0]:.3f}，n = 6 时降到 {self_auc[6][0]:.3f}。这一次它不是长度带来的假象（'计划越短越可能对'的 AUC 低于 0.5："
        "太短的计划常常没到目标），和第 7 讲的情形不同——评估置信度时，每次都要重新检查表面特征。代码 03 会看到，"
        "即使有这样的区分度，用它来代替可靠的验证器仍然不够。",
    ])
    print(f"\n⏱ 总用时 {time.perf_counter() - t0:.0f} 秒")


if __name__ == "__main__":
    main()
