"""
=============================================================================
第 1 讲 · 03：从 chatbot 到 agent —— 反馈的质量决定"自我改进"的上限
=============================================================================

对应讲义：lectures/lecture01_overview.md 第 6 节（agents 与 agentic workflows）

-----------------------------------------------------------------------------
【这个文件回答三个问题】
-----------------------------------------------------------------------------
演示 1  为什么 agent 难做可靠？—— 可靠性的算术（reliability arithmetic）。
        一个要走 n 步的任务，每步成功率 p，全对的概率是 p^n。99% 听起来很高，
        但 100 步之后只剩 37%。这是"最后 10% 是 90% 的工作量"的数学版本。

演示 2  agent 和 chatbot 最大的区别是 **feedback loop**：做完 → 得到反馈 → 不对就再来。
        我们比较四种反馈来源的"生成–检查–重试"循环（evaluator–optimizer 模式）：
            · 没有反馈：第一次的答案就交上去（= chatbot）
            · self-check：让同一个模型再独立做一遍，两次答案一致就接受
            · 外部 judge：一个独立的、有噪声的评委（会漏判，也会误判）
            · 工具检查：用计算器把每一步重算一遍（像代码的 unit test）
        你会看到：**反馈越可靠，循环能把准确率推得越高**；self-check 的问题在于它和生成者
        犯同样的错（correlated errors）；有误判的 judge 有一个算得出来的天花板。

演示 3  tool use：让模型在算乘法时调用计算器。工具把"模型容易错的步骤"换成"不会错的程序"，
        而调用工具本身也可能失败。

运行时间：不到 1 秒（只用标准库）。
"""

import random

from toy_llm import ToyLLM, make_problem, make_problem_set, mean, pad


# =============================================================================
# 演示 1：可靠性的算术
# =============================================================================
def demo_reliability_arithmetic():
    print("=" * 78)
    print("演示 1：每步成功率 p，走 n 步全对的概率 p^n")
    print("=" * 78)
    ns = (1, 5, 10, 20, 50, 100)
    print(pad("每步 p", 10) + "".join(pad(f"n = {n}", 10, "right") for n in ns))
    for p in (0.90, 0.95, 0.99, 0.999):
        print(pad(f"{p:.3f}", 10) + "".join(pad(f"{p ** n:.3f}", 10, "right") for n in ns))

    print("\n想让 n 步全对的概率达到 80%，每一步至少要多可靠？（p = 0.8^(1/n)）")
    for n in (10, 50, 100, 1000):
        print(f"  n = {n:>4d}：p ≥ {0.8 ** (1 / n):.5f}")

    print("\n观察：")
    print("  · 错误会累积（error compounding）：步数一多，\"每步都挺准\"的系统整体上也很不可靠。")
    print("  · 要支撑上百步的长程任务，每一步需要 99.8% 以上的可靠性 —— 靠单次生成几乎做不到。")
    print("    出路有两条：让每一步更准（更好的模型、训练），或者在过程中发现并纠正错误（反馈、验证、重试）。")
    print("    后一条就是 agent 的 feedback loop，也是这门课\"self-improvement\"的主线。\n")


# =============================================================================
# 演示 2：生成–检查–重试循环，四种反馈来源
# =============================================================================
JUDGE_TPR = 0.90   # 外部 judge 的 true positive rate：答案对时，判"对"的概率
JUDGE_FPR = 0.15   # 外部 judge 的 false positive rate：答案错时，误判成"对"的概率


def check(source, model, problem, sample, rng):
    """
    让某个反馈来源检查一个样本，返回 (是否接受, 这次检查额外花了几次生成)。

    【参数】
        source  ："none" / "self" / "judge" / "tool"
        model   ：生成者（self-check 时用它再做一遍）
        problem ：题目
        sample  ：待检查的 Sample
        rng     ：random.Random
    【返回】(bool, int)
    【思路】四种来源的差别，就在"它和生成者的错误有多相关、它自己有多准"：
        · none ：什么都不检查，永远接受。
        · self ：同一个模型独立再做一遍（多花 1 次生成），答案一致就接受。
                 问题：如果模型在某一步"最相信"错误套路，两次很可能犯同一个错、得到同一个错误答案，
                 于是互相"确认"—— 错误是相关的（correlated），自己检查自己很难发现。
        · judge：独立的评委，以 JUDGE_TPR 接受正确答案、以 JUDGE_FPR 接受错误答案。它的错误和生成者无关。
        · tool ：用计算器逐步重算（不花模型的生成次数）。在算术这个领域它是完美的，
                 就像一套完整的 unit test 之于代码。
    【边界】这里的 judge 偷看了 sample.correct 来决定"判对的概率"—— 这是模拟一个"有一定准确率的评委"的
        方式，不代表真实的 judge 知道标准答案。
    """
    if source == "none":
        return True, 0
    if source == "self":
        again = model.generate(problem, rng)
        return again.answer == sample.answer, 1
    if source == "judge":
        accept_prob = JUDGE_TPR if sample.correct else JUDGE_FPR
        return rng.random() < accept_prob, 0
    if source == "tool":
        return sample.answer == problem.answer(), 0   # 计算器重算一遍：这就等于和正确答案比较
    raise ValueError(f"未知的反馈来源：{source!r}")


def solve_with_feedback(model, problem, rng, source, max_attempts):
    """
    evaluator–optimizer 循环：生成 → 检查 → 不通过就重来，最多 max_attempts 次。

    【参数】model；problem；rng；source：反馈来源；max_attempts：最多尝试次数（≥ 1）
    【返回】(最终的 Sample, 总共花了几次生成)
    【边界】
        · 所有尝试都没通过时，交出最后一次的答案（总得交点什么）。
        · 这里每次重试都是"从头再来"，没有利用反馈的内容（例如"第 3 步错了"）。真实 agent 会根据
          反馈修改（revise）答案；第 3 讲的 process verification 和第 4 讲的执行反馈会讨论怎样利用反馈的细节。
    """
    if max_attempts < 1:
        raise ValueError("max_attempts 必须 ≥ 1")
    generations = 0
    sample = None
    for _ in range(max_attempts):
        sample = model.generate(problem, rng)
        generations += 1
        accepted, extra = check(source, model, problem, sample, rng)
        generations += extra
        if accepted:
            break
    return sample, generations


def theoretical_ceilings(model, problems, rng, n=200):
    """
    允许**无限次**重试时，外部 judge 和 self-check 两种循环的准确率上限（对所有题目取平均）。

    【思路】每一轮有三种结局：接受了正确答案、接受了错误答案、拒绝（拒绝了就再来一轮）。
        所以最终答案正确的概率 = "接受正确答案"在"接受"里所占的比例。
        · 外部 judge：接受正确答案的概率 p·TPR，接受错误答案的概率 (1−p)·FPR，
              上限 = p·TPR / (p·TPR + (1−p)·FPR)        ← 就是这个 judge 在这道题上的 precision
          p 很小的难题，上限就很低：错误答案太多，总会被误放进来一个。
        · self-check：先生成答案 a（概率 q_a），再独立生成一次也得到 a（概率 q_a），才接受。
              接受 a 的概率 = q_a²，上限 = q_正确² / Σ_a q_a²
          平方会放大"最常见的答案"的优势 —— self-check 实际上是一种"软的 majority vote"，
          所以它和 majority vote 有同样的毛病：最常见的答案是错的，它就倾向于接受错的。
    【参数】model；problems；rng；n：估计每道题答案分布时用的采样次数（≥ 2）
    【返回】(judge 上限, self-check 上限)
    【边界】q 是用 n 次采样估计的。q_a² 用无偏估计 c_a(c_a − 1) / (n(n − 1))（c_a 是答案 a 出现的次数），
        直接用 (c_a/n)² 会系统性地偏大（把"同一个样本和自己相同"也算了进去）。
        某道题所有答案都只出现一次时，分母为 0，这道题的 self-check 上限记为 0。
    """
    judge, self_check = [], []
    for prob in problems:
        counts = {}
        for _ in range(n):
            a = model.generate(prob, rng).answer
            counts[a] = counts.get(a, 0) + 1
        c_right = counts.get(prob.answer(), 0)
        p = c_right / n
        accept_right, accept_wrong = p * JUDGE_TPR, (1 - p) * JUDGE_FPR
        judge.append(accept_right / (accept_right + accept_wrong) if accept_right > 0 else 0.0)
        sq_total = sum(c * (c - 1) for c in counts.values())
        self_check.append(c_right * (c_right - 1) / sq_total if sq_total > 0 else 0.0)
    return mean(judge), mean(self_check)


def demo_feedback_sources(rng):
    print("=" * 78)
    print("演示 2：生成–检查–重试，四种反馈来源（10B 模型，200 道 4~8 步题）")
    print("=" * 78)
    model = ToyLLM(1e10)
    problems = make_problem_set(rng, n_problems=200, min_steps=4, max_steps=8)
    sources = (("none", "没有反馈"), ("self", "self-check"), ("judge", "外部 judge"), ("tool", "工具检查"))
    attempts_list = (1, 2, 4, 8, 16)
    n_repeats = 3      # 每道题跑 3 次循环，减小随机波动

    print(pad("最多尝试", 10) + "".join(pad(label, 22, "right") for _, label in sources))
    print(pad("", 10) + "".join(pad("准确率 / 平均生成次数", 22, "right") for _ in sources))
    for max_attempts in attempts_list:
        row = pad(str(max_attempts), 10)
        for source, _ in sources:
            correct, gens = [], []
            for prob in problems:
                for _ in range(n_repeats):
                    s, g = solve_with_feedback(model, prob, rng, source, max_attempts)
                    correct.append(s.correct)
                    gens.append(g)
            row += pad(f"{mean(correct):.3f} / {mean(gens):5.2f}", 22, "right")
        print(row)

    judge_cap, self_cap = theoretical_ceilings(model, problems, rng)
    print(f"\n无限次重试下的理论上限：外部 judge（TPR = {JUDGE_TPR}，FPR = {JUDGE_FPR}）{judge_cap:.3f}；"
          f"self-check {self_cap:.3f}；工具检查 1.000")

    print("\n观察：")
    print("  · 没有反馈时，再多的\"尝试机会\"也没用：chatbot 交出的永远是第一次的答案（各行的差别只是随机波动）。")
    print("  · 工具检查（完美的 verifier）让准确率随尝试次数持续上升，等于第 02 号文件的 coverage。")
    print("  · 外部 judge 也随尝试次数上升，而且很省（平均不到 3 次生成），但它停在理论上限附近：")
    print("    judge 每次有 15% 的概率放过错误答案，难题上错误答案多，总会被放进来一个。")
    print("  · self-check 最贵（每次检查都要多花一次生成），提升也最慢，上限还低于外部 judge。")
    print("    模型对同一道题的错误是相关的：两次犯同一个 systematic error，就会\"互相确认\"。")
    print("    数学上它是一种软的 majority vote，继承了 majority vote 的毛病。自己检查自己，很难发现自己的盲点。")
    print("  · 结论：self-improvement 需要一个比生成者\"更可靠\"或\"错得不一样\"的信号 ——")
    print("    执行结果、unit test、计算器、定理证明器、独立的 verifier。这是整门课反复出现的主题。\n")


# =============================================================================
# 演示 3：tool use
# =============================================================================
def demo_tool_use(rng):
    print("=" * 78)
    print("演示 3：乘法交给计算器（工具调用失败率 5%，失败时模型只能自己算）")
    print("=" * 78)
    sizes = (1e9, 1e10)
    header = pad("步数 k", 8)
    for size in sizes:
        name = ToyLLM(size).name
        header += pad(f"{name} 不用工具", 14, "right") + pad(f"{name} 用计算器", 14, "right")
    print(header + pad("平均工具调用次数", 18, "right"))
    for k in (2, 4, 6, 8):
        problems = [make_problem(rng, k, pid=i) for i in range(200)]
        row = pad(str(k), 8)
        calls = []
        for size in sizes:
            model = ToyLLM(size)
            plain, tool = [], []
            for prob in problems:
                for _ in range(4):
                    plain.append(model.generate(prob, rng).correct)
                    s = model.generate(prob, rng, tool_ops=("mul",), tool_fail_prob=0.05)
                    tool.append(s.correct)
                    calls.append(s.n_tool_calls)
            row += pad(f"{mean(plain):.3f}", 14, "right") + pad(f"{mean(tool):.3f}", 14, "right")
        print(row + pad(f"{mean(calls):.2f}", 18, "right"))

    print("\n观察：")
    print("  · 乘法是模型最容易错的运算（两位数乘法甚至会让小模型\"更相信\"错误套路），交给计算器后准确率大幅提高。")
    print("    步数越多，相对提升越大：每个被工具替代的步骤都不再贡献错误，而错误是逐步累积的。")
    print("  · 用了计算器的 1B 模型补上了它和 10B 模型之间的大部分差距（但在这个设定里还没有超过）：")
    print("    一个便宜、可靠的工具，效果接近把模型放大 10 倍。")
    print("  · 工具没有消除所有错误：加减法仍由模型自己算，工具调用也有 5% 会失败。")
    print("    第 4 讲会讨论怎样用工具和代码执行的反馈来训练模型（ReAct、RLEF）。\n")


def main():
    demo_reliability_arithmetic()
    demo_feedback_sources(random.Random(20))
    demo_tool_use(random.Random(21))


if __name__ == "__main__":
    main()
