"""
=============================================================================
CS486 · 第 10 讲 · 代码 04：一次看全部 vs 拆成"只看最小上下文"的几次调用——MACI 的设计原则检验
=============================================================================

【这个脚本要回答的问题】（讲义 §6）
    MACI（Chang 2025）的设计原则之一是"每个智能体只保留最小的上下文"：作者观察到，LLM 在长上下文里会偏重最近的信息、
    忽略早先的约束（论文称为 attention narrowing），所以让每个小智能体只看它需要的那一小部分（论文里约 1k token）。
    这是从案例里得出的诊断，论文没有做受控实验。第 6 讲的实验支持它的一半：无关的陈述（干扰）会让小模型的查找失败。
    但拆分也有代价：一个任务拆成几次调用，**上游的输出就是下游的输入**——下游看到的东西，可能和它训练时见过的不一样。

    任务（NATURAL PLAN 的 Calendar Scheduling 的缩小版）：m 个人各有一份忙碌的时段（16 个半小时时段，a = 9:00 … p = 16:30），
    找所有人都有空、连续 d 个时段（d = 1–3）的最早开始时刻。三种做法，模型都是第 1 讲的 2 层 TinyReasoner：
      - 一次看全部、直接回答（只在 2–4 人的题上训练）；
      - 一次看全部、先写出合并后的忙碌时段再回答（思维链；2–4 人）；
      - 拆开：一个"合并"智能体每次只看两份日程、写出并集（链式调用 m − 1 次），再交给一个"找空档"智能体。
        找空档的智能体有两个版本：在单个人的日程上训练的（gap1），和在 1–10 个人合并后的日程上训练的（gapU）。

    演示 1：人数从 2 到 10（训练只见过 2–4 人的完整题）——四种做法的准确率。
    演示 2：拆开的做法为什么会失败？——下游智能体看到的日程有多"满"。
    演示 3：思维链在哪里失败？

【运行】
    python3 04_context_and_decomposition.py           # 第一次约 1.5 分钟（并行训练 5 个小模型），之后约 6 秒
    python3 04_context_and_decomposition.py --quick   # 冒烟测试

【依赖】PyTorch + 本目录的 schedule_lab.py（第 4 部分）。
"""

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

MS = (2, 3, 4, 6, 8, 10)


def pipeline(models, probs, gap_name):
    """
    拆开的做法：合并智能体链式地把 m 份日程合成一份（每次只看"目前的并集"和下一个人的日程），再交给找空档的智能体。

    【返回】(每道题的答案字母, 每道题合并出的并集是否完全正确, 调用模型的次数)
    【边界】合并智能体的输出里不认识的字符被忽略（sch.dec）。
    """
    cur = [busys[0] for busys, _, _ in probs]
    calls = 0
    for j in range(1, max(len(b) for b, _, _ in probs)):
        outs = sch.greedy(models["merge"], [sch.prompt_merge(c, b[j]) for c, (b, _, _) in zip(cur, probs)], 18)
        cur = [sch.dec(o) for o in outs]
        calls += 1
    answers = sch.greedy(models[gap_name], [sch.prompt_gap(c, d) for c, (_, d, _) in zip(cur, probs)], 2)
    union_ok = [c == set().union(*b) for c, (b, _, _) in zip(cur, probs)]
    return answers, union_ok, calls + 1


def main():
    ap = argparse.ArgumentParser(description="上下文与拆分")
    ap.add_argument("--quick", action="store_true", help="冒烟测试")
    args = ap.parse_args()
    t0 = time.perf_counter()
    count = 30 if args.quick else 300

    print_header("准备：5 个日程小模型（2 层，各 3000 步）")
    models = sch.calendar_models(quick=args.quick)
    sets = {}
    for m in MS:
        rng = random.Random(500 + m)
        sets[m] = [sch.meeting_problem(rng, m) for _ in range(count)]

    # ------------------------------------------------------------------
    print_header(f"演示 1：人数 m 从 2 到 10（每个 m {count} 道题；完整的题只在 m = 2–4 上训练过）")
    rows, d1, cot_out = [], {}, {}
    for m in MS:
        probs = sets[m]
        truth = [sch.SLOTS[a] for _, _, a in probs]
        prompts = [sch.prompt_all(b, d) for b, d, _ in probs]
        direct = sch.greedy(models["direct"], prompts, 2)
        cot = sch.greedy(models["cot"], prompts, 20)
        cot_out[m] = cot
        a1, u1, calls = pipeline(models, probs, "gap1")
        aU, _, _ = pipeline(models, probs, "gapU")
        acc = lambda outs: sum(o == t for o, t in zip(outs, truth)) / count
        d1[m] = (acc(direct), acc([c.split("|")[-1] for c in cot]), acc(a1), acc(aU), sum(u1) / count, calls)
        rows.append([m, pct(d1[m][0], 0), pct(d1[m][1], 0), pct(d1[m][2], 0), pct(d1[m][3], 0), pct(d1[m][4], 0), calls])
    print_table(["m", "一次看全部", "一次看全部 + 思维链", "拆开（gap1）", "拆开（gapU）", "合并出的并集全对", "拆开的调用次数"],
                rows, [4, 12, 22, 14, 14, 18, 16])

    # ------------------------------------------------------------------
    print_header("演示 2：拆开的做法为什么会失败？——找空档的智能体看到的日程有多'满'（忙碌时段数）")
    rng = random.Random(9)
    single = [len(sch.busy_slots(rng, rng.choice((0.25, 0.5, 0.75)))) for _ in range(3000)]
    rows = [["gap1 的训练数据（单个人）", f"{sum(single) / len(single):.1f}", f"{max(single)}"]]
    unions = {}
    for m in MS:
        u = [len(set().union(*b)) for b, _, _ in sets[m]]
        unions[m] = u
        rows.append([f"m = {m} 时合并后的日程", f"{sum(u) / len(u):.1f}", f"{max(u)}"])
    print_table(["日程来自", "平均忙碌时段数", "最多"], rows, [28, 16, 8])
    print("\n  按合并后日程的忙碌时段数分组（把所有 m 的题合在一起；并集用正确答案，只看找空档这一步）：")
    buckets = {}
    for m in MS:
        probs = sets[m]
        unions_true = [set().union(*b) for b, _, _ in probs]
        o1 = sch.greedy(models["gap1"], [sch.prompt_gap(u, d) for u, (_, d, _) in zip(unions_true, probs)], 2)
        oU = sch.greedy(models["gapU"], [sch.prompt_gap(u, d) for u, (_, d, _) in zip(unions_true, probs)], 2)
        for u, (_, _, a), x1, xU in zip(unions_true, probs, o1, oU):
            k = "≤ 5" if len(u) <= 5 else ("6–8" if len(u) <= 8 else ("9–11" if len(u) <= 11 else "≥ 12"))
            b = buckets.setdefault(k, [0, 0, 0])
            b[0] += 1
            b[1] += x1 == sch.SLOTS[a]
            b[2] += xU == sch.SLOTS[a]
    keys = [k for k in ("≤ 5", "6–8", "9–11", "≥ 12") if k in buckets]
    print_table(["忙碌时段数", "题数", "gap1 准确率", "gapU 准确率"],
                [[k, buckets[k][0], pct(buckets[k][1] / buckets[k][0], 0), pct(buckets[k][2] / buckets[k][0], 0)] for k in keys],
                [12, 8, 12, 12])

    # ------------------------------------------------------------------
    print_header("演示 3：思维链在哪里失败？（m = 8、10：先写的'合并后的日程'对不对，答案对不对）")
    rows = []
    for m in (8, 10):
        probs = sets[m]
        u_ok = [c.split("|")[0] == sch.enc(set().union(*b)) for c, (b, _, _) in zip(cot_out[m], probs)]
        a_ok = [c.split("|")[-1] == sch.SLOTS[a] for c, (_, _, a) in zip(cot_out[m], probs)]
        rows.append([m, pct(sum(u_ok) / count, 0), pct(sum(x and y for x, y in zip(u_ok, a_ok)) / max(1, sum(u_ok)), 0),
                     pct(sum(y for x, y in zip(u_ok, a_ok) if not x) / max(1, count - sum(u_ok)), 0)])
    print_table(["m", "合并后的日程写对了", "写对时答案对", "写错时答案对"], rows, [4, 20, 16, 16])
    lens = {m: sum(len(sch.enc(set().union(*b))) for b, _, _ in sets[m]) / count for m in MS}
    print("  合并后日程的平均长度（字符数 = 忙碌时段数）：" + "，".join(f"m={m}：{lens[m]:.1f}" for m in MS))

    if args.quick:
        print("\n（--quick 冒烟测试：数字没有意义，不打印观察。）")
        print(f"\n⏱ 总用时 {time.perf_counter() - t0:.0f} 秒")
        return
    tr_max = max(sum(unions[m]) / len(unions[m]) for m in (2, 3, 4))
    print_observations([
        f"'一次看全部'比预想的稳（演示 1）：直接回答从 m = 2 的 {pct(d1[2][0], 0)} 降到 m = 10 的 {pct(d1[10][0], 0)}，"
        "m = 6–10 都没在训练中见过，也只是缓慢下降。'这个时段有没有人忙'是一个与位置无关的查找，人多了只是查找范围变大。"
        "所以'上下文越长越糟'不是普遍规律——取决于任务需要模型在上下文里做什么（第 6 讲：多跳查找被干扰陈述严重破坏）。",
        f"拆开、但下游智能体没见过上游的输出：反而最差（演示 1）。合并智能体每次只看两份日程，合并出的并集"
        f"{'全部正确' if min(d1[m][4] for m in MS) == 1 else f'至少 {pct(min(d1[m][4] for m in MS), 0)} 正确'}；可是在单个人的日程上训练的找空档智能体（gap1），m = 10 时只有 {pct(d1[10][2], 0)}。"
        f"原因在演示 2：单个人的日程平均 {sum(single) / len(single):.1f} 个忙碌时段，10 个人合并后平均 {sum(unions[10]) / len(unions[10]):.1f} 个——"
        "**拆分改变了下游看到的分布**。每个智能体'只看最小的上下文'，但这份上下文是上游写的，不是训练数据。",
        f"让下游在'上游会交给它的东西'上训练，拆分就和一次看全部一样好（演示 1、2）：gapU（在 1–10 人合并后的日程上训练）"
        f"在 m = 2 / 6 / 10 时 {pct(d1[2][3], 0)} / {pct(d1[6][3], 0)} / {pct(d1[10][3], 0)}，和一次看全部差不多（{pct(d1[2][0], 0)} / "
        f"{pct(d1[6][0], 0)} / {pct(d1[10][0], 0)}），代价是 m 次调用。很满的日程上找空档本身更难（演示 2 的分组），两种找空档的智能体都在下降。",
        f"思维链在训练范围内最好、超出之后最先崩（演示 1、3）：m ≤ 6 时全部 {pct(min(d1[m][1] for m in (2, 3, 4, 6)), 0)}，"
        f"m = 10 降到 {pct(d1[10][1], 0)}。我原以为失败来自先写的'合并后的日程'变长写错了；演示 3 说明不是：m = 10 时并集写对了 "
        f"{rows[1][1]}，但写对之后答案只对 {rows[1][2]}。错在最后一步——在一份很满的日程里找空档：训练题（m = 2–4）的并集平均"
        f"最多约 {tr_max:.1f} 个忙碌时段（m = 4），m = 10 时平均 {sum(unions[10]) / len(unions[10]):.1f} 个。思维链和 gap1 栽在同一个地方："
        "中间结果落在训练分布之外。",
        "对 MACI 式设计的启示：'每个智能体只看最小上下文'是一个合理的工程原则，但它不是免费的——(1) 它把一次调用变成多次，"
        "上游的错误会传给下游（这里合并智能体恰好不出错，所以没有累积）；(2) 下游的输入分布由上游决定，要在那个分布上训练和测试。"
        "要证明拆分有帮助，需要和'一次看全部'做受控的对比，而不只是案例。",
    ])
    print(f"\n⏱ 总用时 {time.perf_counter() - t0:.0f} 秒")


if __name__ == "__main__":
    main()
