"""
=============================================================================
CS486 · 第 10 讲 · 代码 02：草稿 + 逐条修补 vs 完整的求解器——谁能说"这不可能"？
=============================================================================

【这个脚本要回答的问题】（讲义 §4–§5）
    TravelPlanner 上有两条改进路线：
      - LLM-Modulo（Gundawar et al. 2024）：LLM 写计划，一组批评者逐条检查约束、把违反的约束反馈给 LLM，LLM 修改，循环；
        GPT-4-Turbo 的最终通过率提高到原来的 4.6 倍（基线 4.4%），GPT-3.5-Turbo 从 0% 到 5%。
      - 交给求解器（Hao et al. 2024）：LLM 只负责把需求翻译成约束，由 SMT 求解器（Z3）求解；通过率 93.9%，
        而且对无解的请求能给出"不可满足核"——是哪几条要求冲突——并建议怎样修改。
    两条路线的根本区别：修补循环是**不完备的**（找不到解时，不知道是"还没找到"还是"根本没有"）；求解器是**完备的**。

    我们在一个随机的调度问题族上比较它们（schedule_lab）：8 个任务、3 个人、6 条先后约束，时间范围 H 从紧到松。
    修补循环用 min-conflicts 当 LLM 修补的**算法替身**：从一份"大体合理"的草稿出发（照顾先后、不照顾人手冲突），
    批评者列出违反的约束，每轮改一个任务。

    演示 1：H 从紧到松——有解的比例（"相变"）；求解器的工作量，有无约束传播。
    演示 2：在有解的题上：修补循环（批评者列出全部冲突 / 只说第一条）vs 二值反馈（只说"不行"、整个重写）。
    演示 3：在无解的题上：修补循环交出了什么？求解器呢？
    演示 4：无解时的解释——冲突核。

【运行】
    python3 02_repair_vs_solve.py           # 约 20 秒（只用标准库）
    python3 02_repair_vs_solve.py --quick   # 冒烟测试

【依赖】本目录的 schedule_lab.py（这个脚本只用到它的标准库部分）。
"""

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

HS = (6, 7, 8, 9, 10, 12, 14)
ROUNDS = 300


def problems(H, count):
    """时间范围为 H 的 count 道随机调度题（种子由 H 决定，所以各演示用的是同一批题）。"""
    rng = random.Random(1000 + H)
    return [sch.random_problem(rng, n=8, people=3, n_prec=6, horizon=H) for _ in range(count)]


def q(xs, f):
    """分位数（f = 0.5 是中位数，f = 1 是最大值）。"""
    xs = sorted(xs)
    return xs[min(len(xs) - 1, int(f * len(xs)))]


def main():
    ap = argparse.ArgumentParser(description="修补 vs 求解")
    ap.add_argument("--quick", action="store_true", help="冒烟测试")
    args = ap.parse_args()
    t0 = time.perf_counter()
    count = 20 if args.quick else 200
    hs = (7, 10) if args.quick else HS

    # ------------------------------------------------------------------
    print_header(f"演示 1：时间范围 H 从紧到松（每个 H {count} 道题）——有解的比例；求解器的工作量（尝试的取值次数）")
    rows, data, d1 = [], {}, {}
    for H in hs:
        ps = problems(H, count)
        res = [sch.solve(p) for p in ps]
        res_np = [sch.solve(p, node_limit=200000, propagate=False) for p in ps]
        assert all(a[2] == b[2] for a, b in zip(res, res_np) if b[2] != "limit")
        data[H] = (ps, res)
        sat = sum(r[2] == "sat" for r in res)
        d1[H] = (q([r[1] for r in res], 1.0), q([r[1] for r in res_np], 1.0), sum(r[2] == "limit" for r in res_np))
        rows.append([H, pct(sat / count, 0), q([r[1] for r in res], 0.5), q([r[1] for r in res], 1.0),
                     q([r[1] for r in res_np], 0.5), q([r[1] for r in res_np], 1.0), sum(r[2] == "limit" for r in res_np)])
    print_table(["H", "有解", "传播：中位数", "传播：最多", "不传播：中位数", "不传播：最多", "不传播：超过 20 万次"],
                rows, [4, 8, 14, 12, 16, 14, 22])
    print("  （两种求解器在都给出结论的题上，结论完全一致——都是完备的；区别只在工作量）")

    # ------------------------------------------------------------------
    print_header(f"演示 2：有解的题上——修补循环（最多 {ROUNDS} 轮）能找到无冲突的计划吗？")
    rows, d2 = [], {}
    for H in hs:
        ps, res = data[H]
        sat_ps = [p for p, r in zip(ps, res) if r[2] == "sat"]
        if not sat_ps:
            continue
        stats = {}
        for fb in ("all", "first"):
            out = [sch.repair_loop(p, sch.greedy_draft(p), random.Random(i), ROUNDS, fb) for i, p in enumerate(sat_ps)]
            stats[fb] = (sum(o[2] for o in out) / len(sat_ps), sum(o[1] for o in out if o[2]) / max(1, sum(o[2] for o in out)))
        gt = [sch.generate_and_test(p, random.Random(i), ROUNDS) for i, p in enumerate(sat_ps)]
        draft_ok = sum(not sch.violations(p, sch.greedy_draft(p)) for p in sat_ps) / len(sat_ps)
        d2[H] = (len(sat_ps), draft_ok, stats, sum(g[0] for g in gt) / len(sat_ps))
        rows.append([H, len(sat_ps), pct(draft_ok, 0), pct(stats["all"][0], 0), f"{stats['all'][1]:.0f}",
                     pct(stats["first"][0], 0), pct(d2[H][3], 0), "100%"])
    print_table(["H", "有解的题", "草稿就对", "修补：全部冲突", "平均轮数", "修补：只说第一条", "二值：整个重写", "求解器"],
                rows, [4, 10, 10, 16, 10, 18, 16, 8])

    # ------------------------------------------------------------------
    print_header(f"演示 3：无解的题上——修补循环（最多 {ROUNDS} 轮）交出了什么？")
    rows, d3 = [], {}
    for H in hs:
        ps, res = data[H]
        bad = [(p, r) for p, r in zip(ps, res) if r[2] == "unsat"]
        if not bad:
            continue
        left = [len(sch.violations(p, sch.repair_loop(p, sch.greedy_draft(p), random.Random(i), ROUNDS)[0]))
                for i, (p, _) in enumerate(bad)]
        draft_left = [len(sch.violations(p, sch.greedy_draft(p))) for p, _ in bad]
        root = sum(r[1] == 0 for _, r in bad)
        d3[H] = (len(bad), sum(draft_left) / len(bad), sum(left) / len(bad), min(left), root, sum(r[1] for _, r in bad) / len(bad))
        rows.append([H, len(bad), f"{d3[H][1]:.1f}", f"{d3[H][2]:.1f}", d3[H][3], f"{d3[H][5]:.0f}", f"{root}"])
    print_table(["H", "无解的题", "草稿的冲突数", f"修补 {ROUNDS} 轮后的冲突数", "最少", "求解器：平均尝试次数", "其中传播直接判定"],
                rows, [4, 10, 14, 24, 6, 22, 18])
    print("  （修补循环在无解的题上永远停不下来，只能在轮数用完时交出一个仍有冲突的计划；求解器每道题都给出'无解'的证明）")

    # ------------------------------------------------------------------
    print_header("演示 4：无解时的解释——冲突核（一个极小的先后约束子集：只保留它们，问题仍然无解）")
    H = 8
    ps, res = data[H] if H in data else data[hs[0]]
    kinds = {"chain": [], "load": [], "mixed": []}
    for p, r in zip(ps, res):
        if r[2] != "unsat":
            continue
        core, no_prec_sat = sch.unsat_core(p)
        if not core:
            kind = "load"
        else:
            # 只看时长、不考虑人手（每个任务谁都能做、人手无限）时，冲突核里的先后约束是否已经放不下？
            free_people = [sch.Task(t.name, t.dur, tuple(range(len(p.tasks))), t.release, t.deadline) for t in p.tasks]
            kind = "chain" if sch.solve(sch.Problem(free_people, core, p.horizon))[2] == "unsat" else "mixed"
        kinds[kind].append((p, core))
    examples = [("chain", x) for x in kinds["chain"][:1]] + [("mixed", x) for x in kinds["mixed"][:2]]
    for i, (kind, (p, core)) in enumerate(examples, 1):
        T = {t.name: t for t in p.tasks}
        desc = "；".join(f"{a} 做完才能做 {b}" for a, b, _ in core)
        tasks = sorted({x for a, b, _ in core for x in (a, b)})
        print(f"  例 {i}（H = {p.horizon}）：冲突核 = {desc}")
        print("        涉及的任务：" + "，".join(f"{x}（{T[x].dur} 个时段，可由 {'/'.join(str(w) for w in T[x].people)} 号做）" for x in tasks))
        if kind == "chain":
            print("        → 只看时长就放不下：这几条先后关系连成的链，总时长超过了时间范围。")
        else:
            print("        → 人手无限时放得下；加上'同一个人同一时间只能做一件事'就放不下——先后关系和人手合起来冲突。")
    kinds = {k: len(v) for k, v in kinds.items()}
    total = sum(kinds.values())
    print(f"\n  H = {H} 的 {total} 道无解题：只靠时长链就放不下 {kinds['chain']} 道；先后 + 人手合起来冲突 {kinds['mixed']} 道；"
          f"去掉所有先后约束仍然无解（人手和时间根本不够）{kinds['load']} 道")

    if args.quick:
        print("\n（--quick 冒烟测试：数字没有意义，不打印观察。）")
        print(f"\n⏱ 总用时 {time.perf_counter() - t0:.0f} 秒")
        return
    tight, mid, loose = 7, 9, 14
    print_observations([
        f"有解的比例随时间范围陡峭地变化（演示 1）：H = 6 / 8 / 10 / 14 时有解 "
        f"{' / '.join(pct(sum(r[2] == 'sat' for r in data[H][1]) / count, 0) for H in (6, 8, 10, 14)) }。"
        f"按'越紧越难'做复杂度扫描时要小心：H = {tight} 时 {pct(1 - sum(r[2] == 'sat' for r in data[tight][1]) / count, 0)} 的题根本无解，"
        "一个不会说'不可能'的方法（演示 3）在这些题上只会被记成'失败'——第 6 讲'思考的幻觉'之争里，扫描混进了无解题正是同一个问题。",
        f"约束传播让完备求解几乎不需要搜索（演示 1）：加上先后约束的边界传播（STN 式的最早 / 最晚时间推理），"
        f"每道题最多尝试 {max(d1[H][0] for H in hs)} 次取值；不传播时中位数也只有十几次，但最多要 {max(d1[H][1] for H in hs)} 次"
        "——工作量有很长的尾巴。好的求解器靠推理，而不是靠蛮力。",
        f"修补循环在有解的题上表现不错，但不是 100%（演示 2）：草稿本身几乎从来不对（H = {mid}：{pct(d2[mid][1], 0)}），"
        f"批评者列出全部冲突时，{ROUNDS} 轮内修好 {pct(d2[tight][2]['all'][0], 0)}（H = {tight}）、{pct(d2[mid][2]['all'][0], 0)}（H = {mid}）、"
        f"{pct(d2[loose][2]['all'][0], 0)}（H = {loose}）——紧的时候更难修。列出全部冲突还是只说第一条，差别不大也不一致"
        f"（H = {tight}：{pct(d2[tight][2]['all'][0], 0)} vs {pct(d2[tight][2]['first'][0], 0)}；H = {loose}：{pct(d2[loose][2]['all'][0], 0)} vs "
        f"{pct(d2[loose][2]['first'][0], 0)}）；二值反馈、整个重写几乎没用（H = {mid}：{pct(d2[mid][3], 0)}）。"
        "和第 7、8 讲一致：反馈至少要指出错在哪里。",
        f"修补循环不会说'不可能'（演示 3）：H = {tight} 的 {d3[tight][0]} 道无解题上，它修补 {ROUNDS} 轮后平均还剩 "
        f"{d3[tight][2]:.1f} 条冲突（草稿是 {d3[tight][1]:.1f} 条），然后交出这个仍有冲突的计划。"
        "它没有办法区分'还没找到'和'根本没有'——这正是 LLM 在无解题上的表现（第 6、8 讲）。"
        f"完备的求解器每道都给出无解的证明，平均只尝试 {d3[tight][5]:.0f} 次取值，其中 {d3[tight][4]} 道靠传播直接判定。",
        f"无解时，求解器还能解释（演示 4）：冲突核指出是哪几条先后约束合起来做不到。H = 8 的 {total} 道无解题里，"
        f"{kinds['chain']} 道是一条时长链超过了时间范围，{kinds['mixed']} 道是先后关系和人手合起来冲突，"
        f"{kinds['load']} 道去掉所有先后约束仍然无解（人手和时间根本不够）——三种原因需要用户做三种不同的让步。这就是 Hao et al.（2024）对无解的旅行请求给出'不可满足核'和修改建议的思路；"
        "把它交给用户（'这几条要求不能同时满足，你愿意放松哪一条？'），比交出一个悄悄违反约束的计划好得多。",
    ])
    print(f"\n⏱ 总用时 {time.perf_counter() - t0:.0f} 秒")


if __name__ == "__main__":
    main()
