"""
=============================================================================
第 14 讲 · 01：METR 的"时间跨度" —— 用人类完成任务的时间来衡量 agent
=============================================================================

对应讲义：lectures/lecture14_agent_evaluation.md 第 2 节（Kwa et al. 2025, Measuring AI Ability to Complete Long Tasks）

-----------------------------------------------------------------------------
【METR 的方法】
-----------------------------------------------------------------------------
    1. 一组任务，每个任务有"熟练的人完成它需要多久"（human time，从几秒到十几个小时）。
    2. 让一个模型在每个任务上跑若干次，记录成功率。
    3. 用逻辑斯蒂回归拟合"成功率 vs log₂(人类时间)"：
           P(成功) = sigmoid(a + b × log₂(人类时间))，b < 0（任务越长越难）
    4. 50% 时间跨度（50% time horizon）= 成功率恰好是 50% 的那个人类时间 = 2^(−a/b)。
       80% 时间跨度同理（成功率 80% 的那个时间），一定更短。
    5. 对很多模型都这样做，把时间跨度画在"发布日期"的横轴上，拟合指数增长，得到翻倍时间。

-----------------------------------------------------------------------------
【toy 版本】
-----------------------------------------------------------------------------
    · 任务：仿照 METR 的组成 —— 66 个 2~30 秒的短任务、97 个 1 分钟 ~ 16 小时的任务、7 个 8 小时的任务。
    · 8 个模拟的模型，发布日期从 2019 年初到 2025 年初；趋势线上的 50% 时间跨度每 7 个月翻一倍
      （2019 年初约 2.4 秒，2025 年初约 50 分钟 —— 和 METR 报告的量级相同）。
      每个模型还有自己的偏差：有的比同期的趋势强，有的弱（log₂ 跨度上加一个标准差 0.6 的随机偏差）。
    · 每个模型的真实成功率：sigmoid(0.6 × (log₂ 真实时间跨度 − log₂ 人类时间))；每个任务跑 8 次。
    · 我们只看到成功次数，用逻辑斯蒂回归估计时间跨度；用自助法（bootstrap，对任务重抽样）给出置信区间。
    · 最后看两件事：趋势的置信区间该怎样算；任务集里没有长任务时会怎样。
【重要】模型、真实时间跨度、斜率都是**设定**的。演示的是"怎样从成功率估计时间跨度、不确定性有多大"。

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

import math
import random

SLOPE = 0.6                 # 真实的逻辑斯蒂斜率：人类时间每翻一倍，logit 下降 0.6
RUNS_PER_TASK = 8
N_BOOT = 200                # 自助法的重复次数
DOUBLING_MONTHS = 7.0       # 真实的翻倍时间（设定）
H0_MINUTES, T0 = 0.04, 2019.2   # 2019.2 时真实的 50% 时间跨度：0.04 分钟（2.4 秒）
RELEASES = (2019.2, 2020.5, 2022.2, 2023.2, 2024.0, 2024.5, 2024.75, 2025.15)
MODEL_SPREAD = 0.6          # 每个模型偏离趋势线的程度：log₂(真实跨度) 加上 N(0, 0.6²) 的偏差
ONE_MONTH_MINUTES = 167 * 60    # METR 的"一个月"：167 个工作小时


def sigmoid(z):
    if z >= 0:
        return 1.0 / (1.0 + math.exp(-z))
    e = math.exp(z)
    return e / (1.0 + e)


def make_tasks(rng, max_minutes=None):
    """
    生成任务的人类时间（分钟）。

    【参数】max_minutes：如果给出，只保留人类时间不超过它的任务（模拟"任务集里没有长任务"）
    【返回】list of float
    【思路】log-uniform：在 log 尺度上均匀，所以短任务和长任务的"数量级"覆盖得一样多。
    """
    tasks = [2 ** rng.uniform(math.log2(2 / 60), math.log2(30 / 60)) for _ in range(66)]
    tasks += [2 ** rng.uniform(0, math.log2(16 * 60)) for _ in range(97)]
    tasks += [8 * 60.0] * 7
    if max_minutes is not None:
        tasks = [t for t in tasks if t <= max_minutes]
    return tasks


def trend_horizon(year):
    """趋势线上的 50% 时间跨度（分钟）：每 DOUBLING_MONTHS 个月翻一倍。"""
    return H0_MINUTES * 2 ** ((year - T0) * 12 / DOUBLING_MONTHS)


def run_model(horizon, tasks, rng):
    """每个任务跑 RUNS_PER_TASK 次，返回每个任务的成功次数。"""
    out = []
    for t in tasks:
        p = sigmoid(SLOPE * (math.log2(horizon) - math.log2(t)))
        out.append(sum(rng.random() < p for _ in range(RUNS_PER_TASK)))
    return out


def log_likelihood(a, b, xs, ks, n, ridge):
    """二项对数似然（去掉和参数无关的常数），减去 ridge 惩罚。用 log(sigmoid) 的稳定写法，避免 log(0)。"""
    total = 0.0
    for x, k in zip(xs, ks):
        z = a + b * x
        log_p = -math.log1p(math.exp(-z)) if z >= 0 else z - math.log1p(math.exp(z))       # log sigmoid(z)
        log_q = -math.log1p(math.exp(z)) if z <= 0 else -z - math.log1p(math.exp(-z))      # log sigmoid(−z)
        total += k * log_p + (n - k) * log_q
    return total - 0.5 * ridge * (a * a + b * b)


def fit_logistic(xs, ks, n, ridge=1e-3, iters=50):
    """
    二项逻辑斯蒂回归：k_i ~ Binomial(n, sigmoid(a + b x_i))，用牛顿法求最大似然的 (a, b)。

    【参数】xs：log₂(人类时间)；ks：成功次数；n：每个任务的尝试次数；ridge：很小的 L2 惩罚
    【返回】(a, b)
    【思路】对数似然 ℓ(a, b) 是凹函数。牛顿法每一步：
            梯度 g = Σ (k_i − n p_i) · (1, x_i)
            Hessian H = −Σ n p_i (1 − p_i) · (1, x_i)(1, x_i)ᵀ
            (a, b) ← (a, b) − H⁻¹ g
        2 × 2 的线性方程组可以直接写出解。
    【边界】
        · 起点离答案很远时，完整的牛顿步可能"跳过头"，让似然变小。所以每一步都检查似然有没有变大，
          没有就把步长减半（回溯线搜索，backtracking line search）。
        · 如果模型在所有任务上都成功（或都失败），最大似然估计会跑到无穷大（"完全分离"）。
          ridge 给参数加一个很小的惩罚，让估计保持有限。
    """
    a, b = 0.0, -0.5
    current = log_likelihood(a, b, xs, ks, n, ridge)
    for _ in range(iters):
        g0, g1 = -ridge * a, -ridge * b
        h00 = h11 = ridge
        h01 = 0.0
        for x, k in zip(xs, ks):
            p = sigmoid(a + b * x)
            w = n * p * (1 - p)
            r = k - n * p
            g0 += r
            g1 += r * x
            h00 += w
            h01 += w * x
            h11 += w * x * x
        det = h00 * h11 - h01 * h01
        da = (h11 * g0 - h01 * g1) / det
        db = (h00 * g1 - h01 * g0) / det
        step = 1.0
        while step > 1e-6:
            candidate = log_likelihood(a + step * da, b + step * db, xs, ks, n, ridge)
            if candidate >= current:
                break
            step /= 2
        a, b, current = a + step * da, b + step * db, candidate
        if step * (abs(da) + abs(db)) < 1e-9:
            break
    return a, b


def horizon_at(a, b, level=0.5):
    """成功率为 level 时的人类时间（分钟）：a + b x = logit(level) → x = (logit(level) − a) / b。"""
    logit = math.log(level / (1 - level))
    return 2 ** ((logit - a) / b)


def percentile(xs, q):
    """分位数（线性插值）。xs 不需要事先排序。"""
    s = sorted(xs)
    pos = q * (len(s) - 1)
    lo = int(math.floor(pos))
    hi = min(lo + 1, len(s) - 1)
    return s[lo] + (s[hi] - s[lo]) * (pos - lo)


def linear_fit(xs, ys):
    """最小二乘直线 y = c + m x，返回 (c, m)。"""
    mx, my = math.fsum(xs) / len(xs), math.fsum(ys) / len(ys)
    sxy = math.fsum((x - mx) * (y - my) for x, y in zip(xs, ys))
    sxx = math.fsum((x - mx) ** 2 for x in xs)
    m = sxy / sxx
    return my - m * mx, m


def fmt_minutes(m):
    """把分钟写成人类习惯的形式。"""
    if m < 1:
        return f"{m * 60:.1f} 秒"
    if m < 60:
        return f"{m:.1f} 分钟"
    return f"{m / 60:.1f} 小时"


def bootstrap_horizons(tasks, counts, rng):
    """
    对任务做自助法重抽样，每次重新拟合，返回 N_BOOT 个 50% 时间跨度（分钟）。

    【思路】我们只有这一组任务；如果换一组"差不多"的任务，估计会变多少？自助法用"从原任务里有放回地抽"
        来模拟"换一组任务"。METR 也对任务（和任务族）做了层次化的自助法。
    """
    xs = [math.log2(t) for t in tasks]
    out = []
    for _ in range(N_BOOT):
        idx = [rng.randrange(len(tasks)) for _ in range(len(tasks))]
        a, b = fit_logistic([xs[i] for i in idx], [counts[i] for i in idx], RUNS_PER_TASK)
        out.append(horizon_at(a, b))
    return out


def main():
    rng = random.Random(1400)
    tasks = make_tasks(rng)
    xs = [math.log2(t) for t in tasks]
    truths = [trend_horizon(y) * 2 ** rng.gauss(0, MODEL_SPREAD) for y in RELEASES]
    print("=" * 78)
    print(f"{len(tasks)} 个任务（人类时间 {fmt_minutes(min(tasks))} ~ {fmt_minutes(max(tasks))}），每个任务跑 {RUNS_PER_TASK} 次")
    print("=" * 78)
    print(f"{'发布':>8}{'真实 50% 跨度':>14}{'估计 50% 跨度':>14}{'95% 区间':>24}{'估计 80% 跨度':>14}{'50%/80%':>9}")
    boot_logh = []
    for year, h in zip(RELEASES, truths):
        counts = run_model(h, tasks, rng)
        a, b = fit_logistic(xs, counts, RUNS_PER_TASK)
        boots = bootstrap_horizons(tasks, counts, rng)
        boot_logh.append([math.log2(x) for x in boots])
        h50, h80 = horizon_at(a, b), horizon_at(a, b, 0.8)
        ci = f"[{fmt_minutes(percentile(boots, 0.025))}, {fmt_minutes(percentile(boots, 0.975))}]"
        print(f"{year:8.2f}{fmt_minutes(h):>14}{fmt_minutes(h50):>14}{ci:>24}{fmt_minutes(h80):>14}{h50 / h80:9.1f}")

    print("\n趋势：log₂(50% 跨度) 对发布日期做直线拟合，翻倍时间 = 12 / 斜率（月）")
    print(f"  （设定的趋势：翻倍时间 {DOUBLING_MONTHS:.1f} 个月；50% 跨度达到\"一个月\"（167 小时）的时间 "
          f"{T0 + math.log2(ONE_MONTH_MINUTES / H0_MINUTES) * DOUBLING_MONTHS / 12:.1f}）")
    trend_rng = random.Random(1402)
    for resample_models, label in ((False, "只对任务重抽样"), (True, "对任务和模型都重抽样")):
        doubling, month_date = [], []
        for r in range(N_BOOT):
            idx = list(range(len(RELEASES)))
            if resample_models:
                idx = [trend_rng.randrange(len(RELEASES)) for _ in RELEASES]
                if len({RELEASES[i] for i in idx}) < 3:
                    continue                                   # 至少要 3 个不同的发布日期才能拟合直线
            c, m = linear_fit([RELEASES[i] for i in idx], [boot_logh[i][r] for i in idx])
            doubling.append(12 / m)
            month_date.append((math.log2(ONE_MONTH_MINUTES) - c) / m)
        print(f"  {label}：翻倍时间 {percentile(doubling, 0.5):.1f} 个月 [{percentile(doubling, 0.025):.1f}, "
              f"{percentile(doubling, 0.975):.1f}]；达到一个月的时间 {percentile(month_date, 0.5):.1f} "
              f"[{percentile(month_date, 0.025):.1f}, {percentile(month_date, 0.975):.1f}]")

    print(f"\n任务集里没有长任务时：最新的模型（真实跨度 {fmt_minutes(truths[-1])}），重新跑一次")
    rng2 = random.Random(1401)
    for cap, label in ((None, "完整的任务集"), (10.0, "只保留人类时间 ≤ 10 分钟的任务")):
        sub = make_tasks(random.Random(1400), cap)
        counts = run_model(truths[-1], sub, rng2)
        a, b = fit_logistic([math.log2(t) for t in sub], counts, RUNS_PER_TASK)
        boots = bootstrap_horizons(sub, counts, rng2)
        print(f"  {label}（{len(sub)} 个任务）：估计 {fmt_minutes(horizon_at(a, b))}，95% 区间 "
              f"[{fmt_minutes(percentile(boots, 0.025))}, {fmt_minutes(percentile(boots, 0.975))}]")

    print("\n观察：")
    print("  · 逻辑斯蒂回归能从\"每个任务成功了几次\"里相当准确地估计出 50% 时间跨度；80% 时间跨度短 4~6 倍")
    print("    （toy 的斜率是按 METR 报告的 4~6 倍设定的）：同一个模型，要求的可靠性越高，能交给它的任务就越短。")
    print("  · 只对任务重抽样，翻倍时间的区间很窄 —— 但这低估了不确定性：每个模型都会偏离趋势线")
    print("    （有的模型比同期强，有的弱），模型只有 8 个。把模型也重抽样，区间明显变宽，")
    print("    \"达到一个月\"的外推日期的区间从不到 1 年扩大到 2 年多。METR 报告的翻倍时间区间是 166~240 天。")
    print("  · 任务集必须覆盖模型的能力范围：只保留 ≤ 10 分钟的任务时，对一个跨度约 30 分钟的模型，")
    print("    估计偏低，区间宽了好几倍 —— 时间跨度要靠外推得到。所以 benchmark 需要随着模型变强不断加入更长的任务。\n")


if __name__ == "__main__":
    main()
