"""
=============================================================================
CS486 · 第 9 讲 · 代码 02：价值函数决定搜索的成败——但"准确率"说明不了全部
=============================================================================

【这个脚本要回答的问题】（讲义 §5）
    代码 01 发现：学出来的价值函数没有让搜索变好——ToT 式 BFS 还不如只按策略概率的束搜索。
    Chen et al.（2024，"When is Tree Search Useful for LLM Planning? It Depends on the Discriminator"）在文本到 SQL 和数学题上
    得到类似的结论：判别器（价值函数）的准确率要到约 90%，树搜索才明显好过简单的重排序（reranking），
    而当时的 LLM 判别器达不到；树搜索还慢得多。

    我们的设置可以做 Chen et al. 做不了的控制实验：真实距离是已知的（A* 算得出来），所以可以造出
    "准确率任意可调"的价值函数，也可以把学出来的价值函数的误差**原样**拿出来、随机重新分配给各个状态。

    演示 1：几种价值函数的"静态"质量——在测试题的初始状态的子状态上，排序对不对、和真实距离的相关。
    演示 2：静态质量 vs 搜索的成败——带独立噪声的神谕（σ 可调）、学出来的价值函数、人写的 misplaced。
    演示 3：控制实验——误差的大小分布完全相同、只是随机重新分配给各个状态，搜索还会失败吗？
    演示 4：学出来的价值函数错在哪里？——搜索真正比较的那些状态上的准确率；按真实距离看预测。

【运行】
    python3 02_value_functions.py           # 约 1–1.5 分钟（价值函数的缓存来自代码 01；没有时先训练约 40 秒）
    python3 02_value_functions.py --quick   # 冒烟测试

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

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
SIGMAS = (0.0, 0.5, 1.0, 2.0, 4.0)


def sibling_pairs(V, probs):
    """
    "静态"排序质量：对每道题的初始状态，看它所有子状态（走一步能到的状态）之间的两两比较。

    【思路】搜索每一步做的事，就是在一组兄弟状态里挑"离目标更近"的。对真实距离不同的每一对 (i, j)：
        排对了（V 认为更近的那个确实更近）、打平（V 给了相同的值，搜索只能靠运气）、排反了（V 把更远的当成更近——误导）。
    【返回】(排对的比例, 打平的比例, 排反的比例)
    """
    good = tie = bad = 0
    for a, g, _ in probs:
        kids = [pl.apply_move(a, m)[0] for m in pl.legal_moves(a)]
        dv = V(kids, g)
        dt = [sl.true_distance(k, g) for k in kids]
        for i in range(len(kids)):
            for j in range(len(kids)):
                if dt[i] < dt[j]:
                    if abs(dv[i] - dv[j]) < 1e-9:
                        tie += 1
                    elif dv[i] < dv[j]:
                        good += 1
                    else:
                        bad += 1
    tot = max(1, good + tie + bad)
    return good / tot, tie / tot, bad / tot


def path_states(probs):
    """每道题沿着 A* 最短路径的每个状态的所有子状态——"搜索很可能会比较的状态"。【返回】[(状态, 目标), …]"""
    out = []
    for a, g, best in probs:
        s = a
        for m in best:
            out += [(pl.apply_move(s, mm)[0], g) for mm in pl.legal_moves(s)]
            s = pl.apply_move(s, m)[0]
    return out


def corr(x, y):
    """皮尔逊相关系数。【边界】某一列是常数时返回 0。"""
    mx, my = sum(x) / len(x), sum(y) / len(y)
    sxy = sum((a - mx) * (b - my) for a, b in zip(x, y))
    sx = sum((a - mx) ** 2 for a in x) ** 0.5
    sy = sum((b - my) ** 2 for b in y) ** 0.5
    return sxy / (sx * sy) if sx and sy else 0.0


def search(policy, V, probs, n, stats=None):
    """ToT 式 BFS（W = 4，展开全部合法动作，按 V 保留最好的 4 个）的成功率。stats 见 sl.beam_search。"""
    ok = 0
    for a, g, _ in probs:
        plan, _ = sl.beam_search(policy, a, g, 4, 3 * n, value=V, stats=stats)
        ok += plan is not None and pl.validate(a, g, pl.plan_to_str(plan)).ok
    return ok / len(probs)


def best_first_ok(policy, V, probs, budget):
    """最佳优先（g + V，最多展开 budget 个状态）的成功率。"""
    ok = 0
    for a, g, _ in probs:
        plan, _ = sl.best_first(policy, V, a, g, budget)
        ok += plan is not None and pl.validate(a, g, pl.plan_to_str(plan)).ok
    return ok / len(probs)


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 = (5,) if args.quick else (5, 6)
    budget = 300

    print_header("准备：提议者、学出来的价值函数；n = 5、6 各 %d 道题（和代码 01 相同）" % 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}
    policy = sl.Policy(proposer)

    def candidates():
        """要比较的价值函数：名字 → 函数。每次调用都重新创建（学出来的价值函数的缓存不跨题集共享也没关系）。"""
        out = {"学出来的价值函数": sl.ValueNet(value_model), "misplaced（人写）": sl.misplaced_value}
        for sg in SIGMAS:
            out[f"神谕 + 独立噪声 σ={sg}"] = sl.noisy_oracle(sg)
        return out

    # ------------------------------------------------------------------
    print_header("演示 1：静态质量——初始状态的子状态之间两两比较：排对 / 打平 / 排反；沿最短路径的状态上和真实距离的相关")
    rows, d1 = [], {}
    for n in ns:
        ps = path_states(sets[n])
        td = [sl.true_distance(s, g) for s, g in ps]
        for name, V in candidates().items():
            good, tie, bad = sibling_pairs(V, sets[n])
            pv = [V([s], g)[0] for s, g in ps]
            exact = sum(round(v) == min(t, 9) for v, t in zip(pv, td)) / len(td)
            d1[(n, name)] = (good, tie, bad, corr(pv, td), exact)
            rows.append([n, name, pct(good, 0), pct(tie, 0), pct(bad, 0), f"{corr(pv, td):.2f}", pct(exact, 0)])
    print_table(["n", "价值函数", "排对", "打平", "排反", "相关（路径上）", "四舍五入等于真实距离"],
                rows, [4, 24, 8, 8, 8, 16, 22])

    # ------------------------------------------------------------------
    print_header(f"演示 2：静态质量 vs 搜索的成败（ToT 式 BFS W=4；最佳优先 ≤{budget}；对照：只按策略概率的束搜索 W=4）")
    rows, d2 = [], {}
    for n in ns:
        base = sum(sl.beam_search(policy, a, g, 4, 3 * n)[0] is not None for a, g, _ in sets[n]) / count
        d2[(n, "策略")] = base
        rows.append([n, "（不用价值函数：按策略概率）", "-", pct(base, 0), "-"])
        for name, V in candidates().items():
            tot = search(policy, V, sets[n], n)
            bfo = best_first_ok(policy, V, sets[n], budget)
            d2[(n, name)] = (tot, bfo)
            rows.append([n, name, pct(d1[(n, name)][0], 0), pct(tot, 0), pct(bfo, 0)])
    print_table(["n", "价值函数", "静态排对", "ToT 式 BFS", "最佳优先"], rows, [4, 30, 10, 12, 10])

    # ------------------------------------------------------------------
    print_header("演示 3：控制实验——把学出来的价值函数在搜索中的误差原样收集起来，随机重新分配给各个状态")
    rows, d3 = [], {}
    for n in ns:
        st = {}
        learned_ok = search(policy, sl.ValueNet(value_model), sets[n], n, stats=st)
        errors = st["errors"]
        mean_e = sum(errors) / len(errors)
        sd_e = (sum((e - mean_e) ** 2 for e in errors) / len(errors)) ** 0.5
        d3[(n, "learned")] = (learned_ok, st["kept_best"] / st["layers"], mean_e, sd_e)
        rows.append([n, "学出来的价值函数", f"{mean_e:+.2f} ± {sd_e:.2f}", pct(st["kept_best"] / st["layers"], 0), pct(learned_ok, 0)])
        for seed in (0, 1, 2):
            st2 = {}
            ok = search(policy, sl.shuffled_oracle(errors, seed), sets[n], n, stats=st2)
            d3[(n, seed)] = (ok, st2["kept_best"] / st2["layers"])
            rows.append([n, f"真实距离 + 打乱的同一组误差（种子 {seed}）", "同上", pct(st2["kept_best"] / st2["layers"], 0), pct(ok, 0)])
    print_table(["n", "价值函数", "误差（均值 ± 标准差）", "每层留住了最近的状态", "ToT 式 BFS 成功"], rows, [4, 40, 22, 22, 16])
    print("  （'每层留住了最近的状态' = 束搜索的每一层，保留下来的 4 个状态里包含所有候选中离目标最近的那个的比例）")

    # ------------------------------------------------------------------
    print_header("演示 4：学出来的价值函数错在哪里？")
    rows = []
    d4 = {}
    for n in ns:
        # (a) 搜索真正比较的状态：ToT 式 BFS 每一层的全部候选，两两比较的排对率（排除打平）
        good = tot = 0
        V = sl.ValueNet(value_model)
        for a, g, _ in sets[n]:
            beam = [a]
            kept = {a}
            for _ in range(3 * n):
                cand = []
                for s in beam:
                    for m in pl.legal_moves(s):
                        t = pl.apply_move(s, m)[0]
                        if t not in kept:
                            cand.append(t)
                if not cand or g in cand:
                    break
                vs = V(cand, g)
                ds = [sl.true_distance(c, g) for c in cand]
                for i in range(len(cand)):
                    for j in range(len(cand)):
                        if ds[i] < ds[j]:
                            tot += 1
                            good += vs[i] < vs[j]
                beam = []
                for i in sorted(range(len(cand)), key=lambda i: vs[i]):
                    if cand[i] not in kept:
                        kept.add(cand[i])
                        beam.append(cand[i])
                    if len(beam) == 4:
                        break
        d4[n] = good / max(1, tot)
        rows.append([n, pct(d1[(n, "学出来的价值函数")][0], 0), pct(d4[n], 0)])
    print_table(["n", "初始状态的子状态上排对", "搜索中实际比较的状态上排对"], rows, [4, 24, 28])
    print("\n  按真实距离分组：学出来的价值函数的平均预测（沿最短路径的状态及其子状态）")
    for n in ns:
        ps = path_states(sets[n])
        V = sl.ValueNet(value_model)
        groups = {}
        for s, g in ps:
            groups.setdefault(sl.true_distance(s, g), []).append(V([s], g)[0])
        ks = sorted(k for k in groups if len(groups[k]) >= 20)
        print_table([f"n={n} 真实距离", *[str(k) for k in ks]],
                    [["平均预测", *[f"{sum(groups[k]) / len(groups[k]):.1f}" for k in ks]],
                     ["状态数", *[str(len(groups[k])) for k in ks]]], [16] + [7] * len(ks))
        d4[(n, "groups")] = {k: sum(v) / len(v) for k, v in groups.items()}
    rng = sl.random.Random(0)
    dist = [len(pl.make_problem(rng, 5)[2]) for _ in range(300 if args.quick else 3000)]
    d4["train"] = {k: dist.count(k) / len(dist) for k in (1, 2, 3)}
    print(f"\n  训练数据的生成方式下，5 块积木的随机题离目标 1 / 2 / 3 步的比例："
          f"{pct(d4['train'][1])} / {pct(d4['train'][2])} / {pct(d4['train'][3])}（价值函数在 3–5 块积木上训练）")

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

    L, M = "学出来的价值函数", "misplaced（人写）"
    o = lambda sg: f"神谕 + 独立噪声 σ={sg}"
    print_observations([
        f"按'静态'指标，学出来的价值函数并不差（演示 1）：初始状态的子状态两两比较，n = 5 时排对 {pct(d1[(5, L)][0], 0)}、"
        f"排反 {pct(d1[(5, L)][2], 0)}，介于 σ = 0.5 和 σ = 1 的噪声神谕之间（排对 {pct(d1[(5, o(0.5))][0], 0)} / {pct(d1[(5, o(1.0))][0], 0)}）。"
        f"人写的 misplaced 排对只有 {pct(d1[(5, M)][0], 0)}，但它的错全是'打平'（{pct(d1[(5, M)][1], 0)}），从不排反（{pct(d1[(5, M)][2], 0)}）；"
        f"沿最短路径和真实距离的相关，misplaced {d1[(5, M)][3]:.2f}、学出来的 {d1[(5, L)][3]:.2f}。",
        f"静态准确率预测不了搜索的成败（演示 2）：独立噪声 σ = 2 的神谕静态只排对 {pct(d1[(5, o(2.0))][0], 0)}，ToT 式 BFS 在 n = 5 / 6 上 "
        f"{pct(d2[(5, o(2.0))][0], 0)} / {pct(d2[(6, o(2.0))][0], 0)}；学出来的价值函数排对 {pct(d1[(5, L)][0], 0)}，却只有 "
        f"{pct(d2[(5, L)][0], 0)} / {pct(d2[(6, L)][0], 0)}——比不用价值函数、只按策略概率的束搜索（{pct(d2[(5, '策略')], 0)} / "
        f"{pct(d2[(6, '策略')], 0)}）还差。Chen et al. 的'约 90% 准确率才值得做树搜索'是在真实 LLM 判别器上测的；"
        "这里说明，同样的准确率，误差的**结构**不同，结论可以完全不同。",
        f"控制实验把原因定位到'误差落在哪些状态上'（演示 3）：把学出来的价值函数在搜索中的误差原样收集起来"
        f"（n = 5：均值 {d3[(5, 'learned')][2]:+.2f}、标准差 {d3[(5, 'learned')][3]:.2f}），随机重新分配给各个状态，"
        f"ToT 式 BFS 在 n = 5 上从 {pct(d3[(5, 'learned')][0], 0)} 变成 {pct(d3[(5, 0)][0], 0)} / {pct(d3[(5, 1)][0], 0)} / {pct(d3[(5, 2)][0], 0)}（三个种子），"
        f"n = 6 从 {pct(d3[(6, 'learned')][0], 0)} 变成 {pct(d3[(6, 0)][0], 0)} / {pct(d3[(6, 1)][0], 0)} / {pct(d3[(6, 2)][0], 0)}。"
        f"每一层留住最近状态的比例：学出来的 {pct(d3[(5, 'learned')][1], 0)}，打乱后 {pct(d3[(5, 0)][1], 0)}。"
        "误差大小完全一样；区别是学出来的价值函数的误差是状态的确定函数——它在某一类状态上**每次**都错（演示 4），"
        "搜索每次走到那里都被同样地误导；随机分配的误差在下一层就和上一层无关，搜索能绕回来。",
        f"它在搜索真正比较的状态上更差（演示 4）：初始状态的子状态上排对 {pct(d1[(5, L)][0], 0)}（n = 5），"
        f"搜索中实际比较的状态上只有 {pct(d4[5], 0)}（n = 6：{pct(d1[(6, L)][0], 0)} → {pct(d4[6], 0)}）。"
        f"具体错在哪里：离目标 1 / 2 / 3 步的状态，它的平均预测是 {d4[(5, 'groups')][1]:.1f} / {d4[(5, 'groups')][2]:.1f} / "
        f"{d4[(5, 'groups')][3]:.1f}（n = 5）——它分不清'只差一步'和'还差三步'，而搜索的最后几步恰恰全是在这种状态之间做选择。"
        f"一个自然的猜测是训练数据里这种状态太少（5 块积木的随机题只有 {pct(d4['train'][1])} 离目标 1 步），"
        "讲义练习 2 检验了这个猜测。不管原因是什么，评估一个价值函数（或 PRM、LLM 裁判），都要在**搜索会访问的状态分布**上测——"
        "搜索本身会把你带到评估器最不熟悉的地方（第 4 讲的离策略盲区、第 5 讲的奖励黑客是同一类问题）。",
        "人写的启发函数之所以好用（演示 1、2），不是因为它'准'（它有大量打平），而是因为它的错误**不误导**：从不把更远的状态排在前面。"
        "对搜索来说，'不知道'比'自信地错'好得多。这也是 Thought of Search（Katz et al. 2024）的思路：让 LLM 写出后继函数和"
        "目标检查的代码、交给经典搜索，而不是在每个节点调用 LLM 打分。",
    ])
    print(f"\n⏱ 总用时 {time.perf_counter() - t0:.0f} 秒")


if __name__ == "__main__":
    main()
