"""
=============================================================================
第 14 讲 · 工具模块 2：gpa.py —— 评估 agent 的工具箱
=============================================================================

对应讲义：lectures/lecture14_evaluating_agents.md §3–§8

-----------------------------------------------------------------------------
【这个模块里有什么 What's inside】
-----------------------------------------------------------------------------
  1. 输出与统计小工具：print_header、pct、wilson_ci、bootstrap_ci、cohen_kappa、
     krippendorff_alpha（多次运行的一致性）、pass_at_k / pass_hat_k、pareto_front。
  2. GPA 的"完美规则读者"（rubric_findings）：把 Agent GPA 每个维度的 rubric 写成确定性的规则，
     只读 trace（GF 可以选择是否用标准答案）。它的作用是证明：agent_world 写下的每一条真实标注，
     都能**从 trace 本身**被看出来 —— 也就是说，一个足够好的 judge 在原则上能发现它。
  3. 模拟的 LLM judge（judge_trace）：在"完美规则读者"之上加入 LLM 的不完美 ——
     注意力有限（trace 越长、要同时检查的 rubric 越多，越容易漏）、低影响错误不显眼、
     定位时可能指向"症状"而不是"根因"、偶尔误报。两种组织方式：
        decomposed=True   GPA 式：每个维度一个专门的 judge，只拿着自己那一份 rubric 读 trace
        decomposed=False  holistic：一个 judge 拿着全部 rubric，一次找出所有错误（像 TRAIL 的基线 judge）
  4. 和"人工标注"比较的指标：错误覆盖率（coverage）、定位准确率（localization）、
     每个维度的 recall / precision、3 分制 / 2 分制的一致率与 Cohen's κ。
  5. 函数调用检查（演示 03）：exact match、BFCL 式 AST match（两种字符串规范化）、执行检查。

【模拟 judge 的参数从哪来】skill（能力）等参数是为了让"强 judge"的数字落在 Agent GPA 论文报告的量级上
    而设定的；但"分解 vs 整体""高影响 vs 低影响""长 trace vs 短 trace""有无架构说明"之间的差别，
    来自上面写明的机制本身，不是逐项调出来的。数字只说明机制，不代表任何真实 judge 的表现。

【注释标签】【思路】【参数】【返回】【边界】【可信联系】【Python 小知识】
只用 Python 标准库。直接运行本文件会做自检：python3 gpa.py
"""

import math
import random
import re
from collections import Counter

from agent_world import (ALL_METRICS, PROCESS_METRICS, TOOL_SCHEMAS, answer_equal, execute_tool,
                         finalize, find_entity, formula_expr, parse_query, safe_eval, same_value)


# =============================================================================
# 1. 输出与统计
# =============================================================================
def print_header(title):
    """打印一个醒目的分隔标题。"""
    print("\n" + "=" * 100)
    print(title)
    print("=" * 100)


def pct(x, digits=1):
    """0.1234 → '12.3%'；None → '—'。"""
    return "—" if x is None else f"{100 * x:.{digits}f}%"


def ratio(k, n):
    """k / n；n = 0 时返回 None（"没有定义"），而不是报错。"""
    return k / n if n else None


def wilson_ci(k, n, z=1.96):
    """
    比例 k / n 的 Wilson 置信区间。

    【思路】正态近似 p ± z√(p(1−p)/n) 在 p 接近 0 或 1、n 很小时会给出 [−0.03, 0.08] 这种荒唐的区间；
        Wilson 区间把"真实比例"放在方差公式里，小样本下表现好得多。
    【返回】(lo, hi)；n = 0 时返回 (None, None)
    【可信联系】agent 的评估集通常只有几十到几百个任务 —— 每个比例都应该带区间（第 1 讲 §8.1）。
    """
    if n == 0:
        return None, None
    p = k / n
    denom = 1 + z * z / n
    center = (p + z * z / (2 * n)) / denom
    half = z * math.sqrt(p * (1 - p) / n + z * z / (4 * n * n)) / denom
    return max(0.0, center - half), min(1.0, center + half)


def mean(xs):
    """均值（用 math.fsum：精确求和，不同 Python 版本结果一致）。"""
    return math.fsum(xs) / len(xs) if xs else None


def bootstrap_ci(values, rng, n_boot=1000, alpha=0.05):
    """
    均值的 bootstrap 置信区间：有放回地重抽 values，重算均值，取分位数。

    【参数】values：数值列表（比如每个任务的成功率）；rng：random.Random
    【返回】(lo, hi)
    【边界】values 很少（< 20）时区间偏窄。
    【Python 小知识】sorted(...) 返回排好序的新列表；列表下标 int(...) 取分位数位置。
    """
    n = len(values)
    stats = []
    for _ in range(n_boot):
        stats.append(math.fsum(values[rng.randrange(n)] for _ in range(n)) / n)
    stats.sort()
    return stats[int(alpha / 2 * n_boot)], stats[int((1 - alpha / 2) * n_boot) - 1]


def cohen_kappa(a, b):
    """
    两组类别标签的 Cohen's κ = (p_o − p_e) / (1 − p_e)。

    【思路】p_o 是一致率；p_e 是"两边都按各自的边际分布随机标"时的期望一致率。
        如果 95% 的 trace 都没有某类错误，两个"永远说没问题"的标注者一致率也有 95%，但 κ = 0。
    【返回】κ；p_e = 1（两边都只用了同一个类别）时返回 None
    """
    n = len(a)
    po = sum(1 for x, y in zip(a, b) if x == y) / n
    ca, cb = Counter(a), Counter(b)
    pe = math.fsum(ca[c] * cb[c] for c in sorted(set(a) | set(b))) / (n * n)
    return None if pe >= 1 else (po - pe) / (1 - pe)


def krippendorff_alpha(units):
    """
    Krippendorff's α（名义变量）：同一批对象被"打分"多次时，打分的一致程度。

    【思路】α = 1 − D_o / D_e。D_o 是同一个对象内部，不同次打分之间不一致的比例（observed disagreement）；
        D_e 是把所有打分混在一起随机配对时的不一致比例（expected disagreement）。α = 1 完全一致，
        α = 0 和随机一样。Agent GPA 论文用它衡量同一个 judge 运行 5 次的稳定性。
    【参数】units：列表，每个元素是同一个对象的多次打分（长度 ≥ 2 才计入）
    【返回】α；所有打分都相同（D_e = 0）时返回 None
    【边界】这里只实现名义（类别）版本：任何两个不同的值都算"不一致"，不区分差多少。
    """
    coinc = Counter()
    for vals in units:
        m = len(vals)
        if m < 2:
            continue
        for i in range(m):
            for j in range(m):
                if i != j:
                    coinc[(vals[i], vals[j])] += 1.0 / (m - 1)
    totals = Counter()
    for (c, _), w in coinc.items():
        totals[c] += w
    n = math.fsum(totals.values())
    d_o = math.fsum(w for (c, k), w in coinc.items() if c != k)
    cats = sorted(totals)
    d_e = math.fsum(totals[c] * totals[k] for c in cats for k in cats if c != k) / (n - 1)
    return None if d_e == 0 else 1.0 - d_o / d_e


def pass_at_k(n, c, k):
    """
    pass@k 的无偏估计：n 次运行里成功 c 次时，"随机挑 k 次，至少一次成功"的概率。

    【思路】1 − C(n − c, k) / C(n, k)（Chen et al. 2021 Codex 论文的估计量）。
        它衡量"多给几次机会能不能做出来"—— 适合有可靠验证器、可以挑出正确答案的场景。
    【边界】k > n 没有意义；c = n 时为 1。
    【Python 小知识】math.comb(n, k) = n! / (k!(n − k)!)，Python 3.8+。
    """
    if n - c < k:
        return 1.0
    return 1.0 - math.comb(n - c, k) / math.comb(n, k)


def pass_hat_k(n, c, k):
    """
    pass^k 的无偏估计：n 次运行里成功 c 次时，"随机挑 k 次，全部成功"的概率。

    【思路】C(c, k) / C(n, k)（τ-bench, Yao et al. 2024）。它衡量**可靠性**：
        一个面向用户的 agent，每个用户都要它做对，而不是"试几次总有一次对"。
    """
    if c < k:
        return 0.0
    return math.comb(c, k) / math.comb(n, k)


def pareto_front(points):
    """
    成本–准确率的 Pareto 前沿：没有任何别的点"又便宜又准"的那些点。

    【参数】points：[(name, cost, acc), ...]
    【返回】前沿上的 name 列表（按成本从低到高）
    【思路】按成本排序，从便宜到贵扫一遍：只有准确率超过之前所有点的，才在前沿上。
    """
    front, best = [], -1.0
    for name, cost, acc in sorted(points, key=lambda p: (p[1], -p[2])):
        if acc > best:
            front.append(name)
            best = acc
    return front


# =============================================================================
# 2. GPA 的"完美规则读者"
# =============================================================================
def label_counter(trace, metrics=ALL_METRICS):
    """把真实标注展开成 (维度, 步骤) 的多重集合（一个错误映射到几个维度就算几次）。"""
    c = Counter()
    for e in trace["errors"]:
        for m in e["metrics"]:
            if m in metrics:
                c[(m, e["step"])] += 1
    return c


def _current_values(steps, upto):
    """第 upto 步之前，每个变量最后一次被记下的值。"""
    vals = {}
    for s in steps[:upto]:
        if s["kind"] in ("call", "think") and s.get("read") is not None:
            vals[s["var"]] = s["read"]
    return vals


def _fetch_args_ok(spec, call):
    """一次取数调用的参数，在语义上是否就是参考子任务要的参数（实体、年份、k）。"""
    want = spec["args"]
    if call["tool"] == "web_search":
        table, ent, years, top_k = parse_query(call["args"]["query"])
        if spec["tool"] == "db_top_k":
            return top_k == want["k"] and years[:1] == [want["year"]]
        if ent != want["entity"]:
            return False
        if spec["tool"] == "db_series":
            return years[:2] == [want["start_year"], want["end_year"]]
        return years[:1] == [want["year"]]
    for k, v in want.items():
        got = call["args"].get(k)
        if k == "entity":
            if not isinstance(got, str) or find_entity(got) != v:
                return False
        elif got != v or type(got) is not type(v):
            return False
    return True


def rubric_findings(trace, with_reference=True):
    """
    "完美的规则读者"：按 Agent GPA 各维度的 rubric，从 trace 里找出所有违反之处。

    【思路】每个维度的规则（和 Jia 的 rubric 一一对应，只读 trace）：
        PQ  计划是否覆盖了任务需要的每个子任务、且每一步的输入都由前面的步骤产生（只看计划，不看执行结果）
        TS  计划（或 ReAct 的第一次调用）给每个取数子任务选的工具是不是最合适的
        PA  计划里的步骤有没有被跳过（executor 写"我已经知道了"而不调用工具）
        TC  调用是否报错；参数在语义上是否正确；工具输出是否被如实记下
        LC  每个用到的值是否都来自之前的工具输出（凭记忆 / 估计的值 = 没有依据）；是否遵守系统指令（<end_plan>）
        EE  是否重复做了同一个调用；是否取了之后根本没用上的数
        GF  有标准答案时：最终答案对不对；没有标准答案时：最终答案是否存在、是否和 trace 里算出的值一致
    【参数】trace；with_reference：GF 是否可以使用标准答案
    【返回】[(维度, 步骤), ...]
    【边界】这是"完美读者"，没有遗漏也没有误报；真实 LLM judge 的不完美由 judge_trace 模拟。
    【可信联系】自检会验证：对 agent_world 生成的每一条 trace，这些规则找出的恰好就是真实标注
        （with_reference=True 时）。所以 judge 的任何遗漏都是"读的人"的问题，不是"信息不在 trace 里"。
    """
    steps, task = trace["steps"], trace["task"]
    subs = task["subtasks"]
    out = []
    plan = steps[0]["plan"] if steps and steps[0]["kind"] == "plan" else None
    if plan is not None:
        produced, bad = set(), False
        if any(s["sub"] not in [p["sub"] for p in plan] for s in subs):
            bad = True
        for p in plan:
            if any(v not in produced for v in p["inputs"]):
                bad = True
            produced.add(p["var"])
        if bad:
            out.append(("PQ", 0))
        for p in plan:
            if p["kind"] == "fetch" and p["tool"] != subs[p["sub"]]["tool"]:
                out.append(("TS", 0))
        if not steps[0]["end_tag"]:
            out.append(("LC", 0))
    first_call, primary = {}, {}
    for s in steps:
        if s["kind"] == "call":
            first_call.setdefault(s["sub"], s)
            if s["output"]["ok"]:
                primary.setdefault(s["sub"], s)
    if plan is None:
        for sub, s in sorted(first_call.items()):
            if subs[sub]["kind"] == "fetch" and s["tool"] != subs[sub]["tool"]:
                out.append(("TS", s["i"]))
    for s in steps:
        i = s["i"]
        if s["kind"] == "think":
            out.append(("LC", i))
            if plan is not None and s["why"] == "skip":
                out.append(("PA", i))
        if s["kind"] != "call":
            continue
        spec = subs[s["sub"]]
        if not s["output"]["ok"]:
            out.append(("TC", i))
            continue
        if primary[s["sub"]] is s:
            value = s["output"]["value"]
            if spec["kind"] == "fetch":
                if not _fetch_args_ok(spec, s):
                    out.append(("TC", i))
                if not any(s["var"] in t.get("inputs", []) for t in steps[i + 1:]):
                    out.append(("EE", i))                       # 取了数，之后没人用
            else:
                vals = _current_values(steps, i)
                if spec["kind"] == "compute":
                    a, b = (vals[v] for v in spec["inputs"])
                    if not same_value(value, safe_eval(formula_expr(spec["formula"], a, b))):
                        out.append(("TC", i))
                else:
                    series = vals[spec["inputs"][0]]
                    if list(s["args"]["labels"]) != [str(x[0]) for x in series] or \
                            not same_value(list(s["args"]["values"]), [x[1] for x in series]):
                        out.append(("TC", i))
            if s["read"] is not None and not same_value(s["read"], value):
                out.append(("TC", i))
                out.append(("LC", i))
        else:
            earlier = [t for t in steps[:i] if t["kind"] == "call" and t["output"]["ok"]
                       and t["sub"] == s["sub"] and t["tool"] == s["tool"] and t["args"] == s["args"]]
            if earlier:
                out.append(("EE", i))                           # 同一个调用做了两次
    final = steps[-1]
    if with_reference:
        if not answer_equal(trace["final"], task["answer"]):
            out.append(("GF", final["i"]))
    else:
        vals = _current_values(steps, final["i"])
        derived = finalize(task, vals.get(task["final_var"]))
        if trace["final"] is None or not answer_equal(trace["final"], derived):
            out.append(("GF", final["i"]))
    return out


def check_labels(trace):
    """
    比较"完美规则读者"的发现和真实标注。

    【返回】(漏掉的, 多出的)：两个 Counter；两者都为空说明标注恰好都能从 trace 里读出来。
    """
    found = Counter(rubric_findings(trace, with_reference=True))
    labels = label_counter(trace)
    return labels - found, found - labels


# =============================================================================
# 3. 模拟的 LLM judge
# =============================================================================
class JudgeConfig:
    """
    一个模拟 LLM judge 的参数。

        skill        能力：越大越不容易漏、越不容易指错位置、误报越少
        decomposed   True：GPA 式（每个维度一个 judge，只拿一份 rubric）；False：一个 holistic judge 找所有错误
        arch_context 是否在 prompt 里描述了 agent 的架构（planner / executor、span 的父子关系）——
                     对应 Jia 讲座里的 "custom instructions"。没有它时，根因在计划里的错误更容易被定位到"症状"那一步
        L0           trace 长度的尺度：注意力随 1 / (1 + 步数 / L0) 衰减
        sal_low      低影响错误的"显眼程度"（高影响 = 1）
        focus_holistic  holistic judge 分给每一类错误的注意力（GPA judge 是 1）
        confusion    holistic judge 把找到的错误归错维度的概率
        fa_base      误报率的基数（每一步、每个 judge 的误报概率 = fa_base / skill）
    """

    def __init__(self, name, skill, decomposed=True, arch_context=True, L0=6.0, sal_low=0.5,
                 c_loc=0.6, focus_holistic=0.35, confusion=0.3, fa_base=0.04):
        self.name, self.skill, self.decomposed, self.arch_context = name, skill, decomposed, arch_context
        self.L0, self.sal_low, self.c_loc = L0, sal_low, c_loc
        self.focus_holistic, self.confusion, self.fa_base = focus_holistic, confusion, fa_base


def p_detect(jc, error, n_steps, focus):
    """
    judge 注意到一个错误的概率：1 − exp(−skill · focus · salience / (1 + n_steps / L0))。

    【思路】把 LLM judge 想成一个注意力有限的读者：
        · focus：它同时要检查几类问题。只拿一份 rubric 的 GPA judge focus = 1；拿着全部 rubric 的
          holistic judge 把注意力分给 6 类问题（focus_holistic）。
        · salience：高影响错误（数字错了、步骤缺了）显眼；低影响错误（多调用了一次、格式不合规）不显眼。
        · n_steps：trace 越长，分到每一步的注意力越少（"lost in the middle"式的稀释）。
    """
    sal = 1.0 if error["impact"] == "high" else jc.sal_low
    return 1.0 - math.exp(-jc.skill * focus * sal / (1.0 + n_steps / jc.L0))


def _localize(jc, error, n_steps, focus, rng):
    """judge 报告的错误位置：大概率是根因所在的步骤；否则是症状所在的步骤或相邻的一步。"""
    p_root = 1.0 - math.exp(-jc.c_loc * jc.skill * focus)
    if error["symptom"] != error["step"] and not jc.arch_context:
        p_root *= 0.5
    if rng.random() < p_root:
        return error["step"]
    if error["symptom"] != error["step"]:
        return error["symptom"]
    options = [s for s in (error["step"] - 1, error["step"] + 1) if 0 <= s < n_steps]
    return options[rng.randrange(len(options))]


def judge_trace(trace, jc, rng):
    """
    让一个模拟 LLM judge（或一组 GPA judge）读一条 trace，返回它的发现。

    【思路】
        GPA 式（decomposed）：对每个适用的维度 m（没有显式计划时 PQ、PA 不适用 —— Agent GPA 论文里
            Snowflake Intelligence 的 agent 就是这种情况），一个专门的 judge 逐个检查属于 m 的真实错误，
            以 p_detect(focus = 1) 的概率发现它，再用 _localize 报告位置；然后每一步以 fa_base / skill 的概率误报。
            一个映射到多个维度的错误（比如"跳步"= PA + LC）可以被几个 judge 分别发现。
        holistic：一个 judge 对每个真实错误以 p_detect(focus = focus_holistic) 发现，以 confusion 的概率
            归到错误的维度；误报率按"一个 judge 看所有问题"取 GPA 单个 judge 的 2 倍。
    【参数】trace；jc：JudgeConfig；rng：random.Random（judge 的随机性；同一个 judge 运行多次用不同的 rng 状态）
    【返回】[{"metric", "step", "key"（对应的真实错误 id；误报为 None）, "impact"}, ...]
    【边界】只模拟过程维度（GF 见 judge_goal）。judge 对发现的真实错误，影响大小判断正确；误报的影响随机。
    【可信联系】这就是 §5 要验证的东西：judge 的发现和人工标注一致吗？位置对吗？
    """
    errors = [e for e in trace["errors"] if "GF" not in e["metrics"]]
    n_steps = len(trace["steps"])
    fa = jc.fa_base / jc.skill
    metrics = [m for m in PROCESS_METRICS if trace["has_plan"] or m not in ("PQ", "PA")]
    out = []
    if jc.decomposed:
        for m in metrics:
            for e in errors:
                if m in e["metrics"] and rng.random() < p_detect(jc, e, n_steps, 1.0):
                    out.append({"metric": m, "step": _localize(jc, e, n_steps, 1.0, rng), "key": e["id"],
                                "impact": e["impact"]})
            for s in range(n_steps):
                if rng.random() < fa:
                    out.append({"metric": m, "step": s, "key": None,
                                "impact": "low" if rng.random() < 0.7 else "high"})
    else:
        f = jc.focus_holistic
        for e in errors:
            if rng.random() < p_detect(jc, e, n_steps, f):
                m = e["metrics"][0]
                if rng.random() < jc.confusion:
                    others = [x for x in metrics if x != m]
                    m = others[rng.randrange(len(others))]
                out.append({"metric": m, "step": _localize(jc, e, n_steps, f, rng), "key": e["id"],
                            "impact": e["impact"]})
        for s in range(n_steps):
            if rng.random() < 2 * fa:
                out.append({"metric": metrics[rng.randrange(len(metrics))], "step": s, "key": None,
                            "impact": "low" if rng.random() < 0.7 else "high"})
    return out


def judge_goal(trace, with_reference, skill, rng):
    """
    Goal fulfillment judge：最终答案是否完成了目标。

    【思路】有标准答案时，这只是一次比较（我们把它当作确定性的）；没有标准答案时，judge 只能检查
        "答案是否存在、是否回答了问题、是否和 trace 里得到的值一致"（rubric_findings 的 GF 规则），
        并以 p_detect（高影响、focus = 1）的概率注意到不一致。
    【返回】True = judge 认为没有完成目标
    """
    flagged = ("GF", trace["steps"][-1]["i"]) in rubric_findings(trace, with_reference)
    if with_reference:
        return flagged
    if not flagged:
        return False
    fake = {"impact": "high"}
    jc = JudgeConfig("gf", skill)
    return rng.random() < p_detect(jc, fake, len(trace["steps"]), 1.0)


def annotate(trace, rng, recall_high=0.97, recall_low=0.8):
    """
    模拟人工标注：人也会漏掉一些错误，尤其是低影响的。

    【返回】被人工标注出来的错误 id 集合（不含 GF）
    【可信联系】Agent GPA 论文说 TRAIL 的标注应被看作"下界"：judge 发现了人没标的真实错误时，
        会被算成误报，precision 因此被低估。演示 02 用"真实标注"和"人工标注"两种口径算 precision 来展示这一点。
    """
    keep = set()
    for e in trace["errors"]:
        if "GF" in e["metrics"]:
            continue
        r = recall_high if e["impact"] == "high" else recall_low
        if rng.random() < r:
            keep.add(e["id"])
    return keep


def score_3pt(impacts):
    """把一组错误的影响大小变成 3 分制：没有错误 = 3，只有低影响错误 = 2，有高影响错误 = 1。"""
    if not impacts:
        return 3
    return 1 if "high" in impacts else 2


def coverage(traces, findings, impact=None):
    """
    错误覆盖率与定位准确率（"across all outputs"：只要有一个 judge 的任何输出命中这个错误就算发现）。

    【参数】traces；findings：与 traces 一一对应的 judge_trace 输出；impact：只统计某种影响（None = 全部）
    【返回】(错误总数, 被发现的个数, 被正确定位的个数)
    """
    n = found = loc = 0
    for tr, fs in zip(traces, findings):
        for e in tr["errors"]:
            if "GF" in e["metrics"] or (impact and e["impact"] != impact):
                continue
            n += 1
            hits = [f for f in fs if f["key"] == e["id"]]
            if hits:
                found += 1
                if any(f["step"] == e["step"] for f in hits):
                    loc += 1
    return n, found, loc


def per_metric(traces, findings, human_sets):
    """
    每个维度的 recall、precision（两种口径）和定位准确率。

    【返回】{metric: dict(n_true, recall, prec_truth, prec_human, loc)}
        recall      该维度的真实错误中，被该维度的 judge 发现的比例
        prec_truth  该维度 judge 的发现中，确实是该维度真实错误的比例
        prec_human  同上，但"真实"只算人工标注出来的那些（人漏标的真实错误被算成误报）
        loc         被发现的错误中，位置报对的比例
    """
    res = {}
    for m in PROCESS_METRICS:
        n_true = tp_r = n_find = tp_t = tp_h = loc = 0
        for tr, fs, hs in zip(traces, findings, human_sets):
            emap = {e["id"]: e for e in tr["errors"]}
            mine = [f for f in fs if f["metric"] == m]
            for e in tr["errors"]:
                if m in e["metrics"]:
                    n_true += 1
                    hits = [f for f in mine if f["key"] == e["id"]]
                    if hits:
                        tp_r += 1
                        loc += any(f["step"] == e["step"] for f in hits)
            for f in mine:
                n_find += 1
                ok = f["key"] is not None and m in emap[f["key"]]["metrics"]
                tp_t += ok
                tp_h += ok and f["key"] in hs
        res[m] = {"n_true": n_true, "recall": ratio(tp_r, n_true), "prec_truth": ratio(tp_t, n_find),
                  "prec_human": ratio(tp_h, n_find), "loc": ratio(loc, tp_r), "n_find": n_find}
    return res


# =============================================================================
# 4. 函数调用检查（演示 03）
# =============================================================================
def call_string(call):
    """把一次调用写成字符串 name(k=v, ...)，参数按调用里的顺序。exact match 就是比较这个字符串。"""
    args = ", ".join(f"{k}={v!r}" for k, v in call["args"].items())
    return f"{call['name']}({args})"


def exact_match(call, ref):
    """最朴素的检查：调用字符串逐字相同。"""
    return call_string(call) == call_string(ref)


_STRICT = re.compile(r"\s+")
_LOOSE = re.compile(r"[\s,./\-_*^]")


def _norm(s, loose):
    """字符串规范化：strict 只去空白、转小写；loose 还去掉 , . / - _ * ^（BFCL 风格，为日期、名字设计）。"""
    return (_LOOSE if loose else _STRICT).sub("", s).lower()


def ast_match(call, possible, loose=False):
    """
    BFCL 式的 AST 检查：不执行，只比较"调用的结构"和一张"可接受答案表"。

    【思路】（Berkeley Function Calling Leaderboard 的做法）
        1. 函数名必须相同；
        2. schema 里没有的参数 = 幻觉参数 → 错；必填参数必须都在；
        3. 类型必须对（integer 不接受字符串 "2023"；number 接受整数）；
        4. 每个参数的值必须在可接受答案列表里（字符串先规范化再比较）；
        5. 可选参数不传时，可接受列表里必须有 ""（表示"可以不传"）。
        参数顺序无关（比较的是 dict，不是字符串）。
    【参数】call：{"name", "args"}；possible：{"name", "params": {参数: [可接受的值, ...]}}；
        loose：字符串规范化方式（见 _norm）
    【返回】True / False
    【边界】可接受答案表只能**列举**：没列进去的等价写法（另一种代数形式、另一种措辞的查询）会被判错（假阴性）；
        loose 规范化会把 "a - b" 和 "a / b" 变成同一个字符串（假阳性）。
    【可信联系】§6.5：AST 检查便宜、没有副作用，但它检查的是"像不像参考答案"，不是"能不能完成任务"。
    """
    if call["name"] != possible["name"]:
        return False
    schema = TOOL_SCHEMAS[call["name"]]
    props = schema["properties"]
    for k in call["args"]:
        if k not in props:
            return False
    for k in schema["required"]:
        if k not in call["args"]:
            return False
    for k, v in call["args"].items():
        typ = props[k]["type"]
        if typ == "integer" and (not isinstance(v, int) or isinstance(v, bool)):
            return False
        if typ == "string" and not isinstance(v, str):
            return False
        allowed = possible["params"].get(k, [])
        if isinstance(v, str):
            if _norm(v, loose) not in [_norm(a, loose) for a in allowed if isinstance(a, str)]:
                return False
        elif v not in allowed:
            return False
    for k, allowed in possible["params"].items():
        if k not in call["args"] and "" not in allowed:
            return False
    return True


def exec_match(world, call, expected, structural=False):
    """
    执行检查：真的调用工具，把输出和期望输出比较。

    【思路】exact：输出值必须相同（数值允许 1e-9 的相对误差）—— 不同但等价的写法都能通过。
        structural：只比较输出的"形状"（成功与否、标量还是列表、列表长度）—— BFCL 对结果随时间变化的
        实时 API（REST）只能这样做，因为录下来的期望输出很快就过期了。
    【参数】world；call：{"name", "args"}；expected：参考调用的输出 dict；structural：是否只比形状
    【返回】True / False
    【边界】有副作用的工具（发邮件、删文件、下单）不能在评估时真的执行，需要沙箱或 mock。
    """
    out = execute_tool(world, call["name"], call["args"])
    if not out["ok"]:
        return False
    if structural:
        a, b = out["value"], expected["value"]
        if isinstance(a, list) and isinstance(b, list):
            return len(a) == len(b)
        return isinstance(a, (int, float)) == isinstance(b, (int, float)) and (a is None) == (b is None)
    return same_value(out["value"], expected["value"])


if __name__ == "__main__":
    import agent_world as aw

    w = aw.make_world()
    tasks = aw.make_tasks(w, 400, seed=2)
    noisy = aw.BASE.replace("noisy", p_plan=0.15, p_tool=0.12, p_args=0.12, p_skip=0.1, p_ignore=0.08,
                            p_loop=0.12, p_slip=0.08, p_format=0.2)
    react = noisy.replace("noisy_react", planner=False)
    rng = random.Random(0)
    bad = 0
    n_err = 0
    for cfg in (noisy, react):
        for t in tasks:
            tr = aw.run_agent(w, t, cfg, rng)
            miss, extra = check_labels(tr)
            n_err += len(tr["errors"])
            if miss or extra:
                bad += 1
                if bad <= 3:
                    print("MISMATCH", dict(miss), dict(extra))
                    print(aw.format_trace(tr))
    print("自检 self-check")
    print(f"  {2 * len(tasks)} 条 trace、{n_err} 条真实标注；完美规则读者与标注不一致的 trace 数 = {bad}（应为 0）")
    print(f"  pass@k(10, 3, 1) = {pass_at_k(10, 3, 1):.3f}（应为 0.3）；pass^k(10, 3, 2) = {pass_hat_k(10, 3, 2):.4f}"
          f"（= C(3,2)/C(10,2) = 3/45）")
    print(f"  Cohen κ（完全一致）= {cohen_kappa([1, 2, 3, 3], [1, 2, 3, 3]):.3f}；"
          f"α（完全一致）= {krippendorff_alpha([[1, 1], [2, 2], [1, 1]]):.3f}")
    print(f"  Wilson 95% CI for 0/20 = ({wilson_ci(0, 20)[0]:.3f}, {wilson_ci(0, 20)[1]:.3f})")
