第 32 章

第 32 章:JevBench:LLM 评测体系

第 32 章:JevBench:LLM 评测体系

本文整理自 Datawhale 开源项目 datawhalechina/jev-cookbook(CC BY-NC-SA 4.0),源文件:main/06_模型评测/llm_eval/notebooks/jevbench_intro.ipynb。

# Cell 0 — Platform detection & dependency install
import sys, os, subprocess, importlib
from pathlib import Path

def detect_platform() -> str:
    if 'google.colab' in sys.modules:
        return 'colab'
    if 'PAI' in os.environ or 'aliyun' in os.environ.get('HOSTNAME', ''):
        return 'aliyun_pai'
    img = os.environ.get('JUPYTER_IMAGE', '').lower()
    if 'modelscope' in img:
        return 'modelscope'
    if 'bml' in os.environ.get('HOSTNAME', '').lower():
        return 'baidu_bml'
    if 'tione' in os.environ.get('HOSTNAME', '').lower():
        return 'tencent_tione'
    return 'jupyter_local'

PLATFORM = detect_platform()
print(f"platform: {PLATFORM}")
print(f"python:   {sys.version.split()[0]}")

# Make sure we run from repo root (notebook lives under notebooks/, but tasks/ is at root).
NOTEBOOK_DIR = Path.cwd().resolve()
for ancestor in [NOTEBOOK_DIR, *NOTEBOOK_DIR.parents]:
    if (ancestor / "pyproject.toml").exists() and (ancestor / "llm_eval").is_dir():
        if ancestor != NOTEBOOK_DIR:
            os.chdir(ancestor)
            print(f"cd -> {ancestor}")
        break

def install(pkg: str, quiet: bool = True) -> None:
    flag = '-q' if quiet else ''
    if PLATFORM == 'colab':
        subprocess.check_call([sys.executable, '-m', 'pip', 'install', flag, pkg])
    else:
        from IPython import get_ipython
        get_ipython().run_line_magic('pip', f'install {flag} {pkg}')

# Install llm_eval from this checkout, and matplotlib for charts.
for pkg, name in [('-e .', 'llm_eval'), ('matplotlib', 'matplotlib')]:
    try:
        m = importlib.import_module(name)
        print(f"  ok: {name} already installed")
    except ImportError:
        print(f"  installing {name}...")
        install(pkg, quiet=True)
        importlib.import_module(name)
        print(f"  ok: {name} installed")

# Matplotlib backend-safe: avoid font warnings on minimal containers.
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
matplotlib.rcParams['font.sans-serif'] = ['DejaVu Sans']
print("matplotlib backend:", matplotlib.get_backend())

Cell 1 — JevBench 入门

JevBench 入门:结构化决策模型评测

25 分钟从"听过 JevBench"走到"我的 API 已经跑出第一份结果并读懂"。

JevBench 是一个专门评测结构化决策模型的基准测试,对应 "typed decision model" 这种用法: 给定一段事实 (state) 和受限于固定选项集 (rubric + label set),模型必须返回对精确选项集的概率分布—— 不写自由文本,不做文字解释。

这个 notebook 给谁看

你想做的事 你应该看
理解 JevBench 是什么 / 不是什么 Cell 1–3, 5, 7
不消耗 API 跑通流程 Cell 0–9, 13
用自己的 API 实测 Cell 10–12
同时测多个模型对比 Cell 12 + 14
接入 Colab / 魔搭 / 阿里云 PAI Cell 0 (auto-detect)

默认 zero-cost

  • 所有示例用 mock adapter,不需要任何 API key。
  • Cell 12 才是真实 API 调用——它会自动检测你 export 了哪些 key,没 key 时优雅跳过。
  • 不会写你的 key 进任何文件,也不会 echo 到日志。

按 Shift+Enter 一路往下跑就行。

Cell 2 — 什么是 JevBench

什么是 JevBench

JevBench 评测的不是"模型有多能说",而是"模型能不能稳定地做对固定选项集的判定"。 它围绕五条互相约束的轴:

轴 关心什么 典型问题
smart 答得对不对 accuracy、Brier、ECE、calibration
cheap 跑一次判定多少钱 $/1k decisions (按 provider 自报 tariff × 实际 token)
fast 多快 p50 / p95 端到端延迟(含网络)
reliable 输出能不能信任 schema_validity、paraphrase consistency、置信度可不可信
open 权重 / 代码能不能用 三项独立事实:代码 license、权重是否可下、权重 license

合成方式(v1.3):JevBench Score = 几何平均(Intelligence 30%, Calibration 25%, Speed 25%, Cost 25%),Intelligence < 50 时按 (I/50)² 折扣。

六族题

族 任务
routing 客服请求分发
adequacy 答案合格度判定
policy yes/no 合规检查
intent 意图分类
ordinal 严重度评分
extraction 字段抽取

它跟 MMLU / LMSYS / HELM 的区别

  • MMLU: 多选题,正确率单维度。
  • LMSYS Arena: 聊天偏好,人类投票。
  • HELM: 多维度横扫,但偏学术 benchmark。
  • JevBench: 唯一专为"输出必须是结构化概率分布"的模型设计,并把延迟和成本作为一等公民。

当你的业务需要的是"判定 + 概率"而不是"对话 + 推理"时,JevBench 是更直接的对照表。

Cell 3 — 为什么需要专门的决策模型评测

为什么需要专门的决策模型评测

很多生产场景要的不是聊天,而是判定:

  • 客服请求路由到哪个部门
  • 答案合格度评分(生成式模型的输出质量门控)
  • 内容是否合规的 yes/no 判定
  • 用户的真实意图分类
  • 工单严重度 0–3 打分
  • 字段抽取(订单号 / 地址 / 金额)

把这些场景硬塞给通用 LLM 的"自由文本回答",下游就得做正则解析、做边界 case、做错误恢复——而且得不到 calibration。 {A: 0.7, B: 0.2, C: 0.1} 这种概率分布天然适合:

  • 阈值化决策(argmax)
  • 拒绝 / 转人工(低置信度)
  • 集成 / 串行 / 多模型仲裁

但**"让通用 LLM 写出一个合法的概率分布"和"模型原生支持这种 API"是两件事**。 JevBench 把这件事显式分开:

  • native 分布:模型 API 本身就给概率分布(TypeSafe 的 Jev、djev、kev 等)
  • verbalized 分布:让通用 LLM 用 JSON schema 写出来(精度和稳定性都吃亏)

跑同一份题,两类模型得分能直接对比。

# Cell 4 — 环境自检
import sys, importlib

print("Python:", sys.version.split()[0])

required = [
    ("llm_eval", "the benchmark harness we built"),
    ("matplotlib", "for charts"),
    ("json", "stdlib, but just confirming"),
]
missing = []
for name, desc in required:
    try:
        m = importlib.import_module(name)
        v = getattr(m, "__version__", "stdlib")
        print(f"  ✓ {name:12s} {v}  ({desc})")
    except ImportError:
        missing.append(name)
        print(f"  ✗ {name:12s} MISSING  ({desc})")

if missing:
    print("\nFix: re-run cell 0 (it installs llm_eval and matplotlib from this checkout).")
else:
    print("\nAll required packages present.")

Cell 5 — 本框架的结构

本框架 (llm_eval) 的结构

llm_eval/
├── task.py          题目 dataclass + 校验 + 哈希
├── scorer.py        分布校验 + argmax + Brier + MAE(fail-closed)
├── metrics.py       aggregate:accuracy / brier / ece / 延迟 / cost / paraphrase
├── ledger.py        fcntl 文件锁 + append-only 预算 ledger
├── runner.py        串行驱动 adapter;401/429/连续错停跑
├── summarize.py     public_export() 重算 + 白名单
├── multi_config.py  JSON 多 runner 配置加载
├── multi_runner.py  多 runner 编排
├── compare.py       跨模型汇总表
├── cli.py           run / summarize / multi-run / compare 4 个子命令
└── adapters/        各家 API 的薄壳
        base.py            AdapterError + HTTP helper
        openai_compat.py   OpenAI 兼容 API 的通用实现
        deepseek.py / qwen.py / glm.py / moonshot.py / doubao.py / stepfun.py / xiaomi.py
        mock.py            不消耗 API 的伪响应(always right / uniform)
tasks/
├── smoke.jsonl                      6 题(1 paraphrase 对)
└── public/{easy,original,hard}.jsonl   JevBench v1.2 MIT 公开题 231 道
multi_run.example.json               多 runner 配置模板
tests/                              63 个单元测试

设计纪律(也写在 IMPLEMENTATION.md 里):

  • 不重试、不重映射、不修补坏的响应
  • reserve-before / settle-after 真实计费
  • 公共导出 = 白名单 + 重算,永不 echo raw request / response
  • adapter 内部串行——并发只在 notebook 这层做(cell 12)
# Cell 6 — 浏览题库
from pathlib import Path
from collections import Counter

import llm_eval
from llm_eval.task import load_tasks

repo = Path.cwd()
print(f"repo root: {repo}\n")

print("=== task files ===")
for p in sorted((repo / "tasks").rglob("*.jsonl")):
    rel = p.relative_to(repo)
    n = sum(1 for _ in p.open())
    print(f"  {rel}  ({n} tasks)")

print("\n=== load public_all.jsonl (231 tasks) ===")
tasks = load_tasks(repo / "tasks" / "public_all.jsonl")
print(f"loaded: {len(tasks)}")

by_family = Counter(t.family for t in tasks)
by_topic = Counter(t.topic for t in tasks)
by_qtype = Counter(t.question_type for t in tasks)

print("\nby family:")
for k, v in sorted(by_family.items(), key=lambda kv: -kv[1]):
    print(f"  {k:14s} {v:3d}")

print("\nby topic:")
for k, v in sorted(by_topic.items(), key=lambda kv: -kv[1]):
    print(f"  {k:18s} {v:3d}")

print("\nby question_type:")
for k, v in sorted(by_qtype.items(), key=lambda kv: -kv[1]):
    print(f"  {k:8s} {v:3d}")

# Show 2 full records: one easy + one hard (per PRD §C)
print("\n=== sample 1 (easy: intent classification) ===")
easy = next(t for t in tasks if t.family == "intent" and t.question_type == "choice")
import json
print(json.dumps(easy.to_dict(), indent=2, ensure_ascii=False))

print("\n=== sample 2 (hard: long policy reasoning) ===")
hard = next(t for t in tasks if "long_policy" in t.id)
sample = hard.to_dict()
sample["state"] = {**sample["state"], "text": sample["state"].get("text", "")[:200] + "...[truncated for display]"}
print(json.dumps(sample, indent=2, ensure_ascii=False))

Cell 7 — 9 个指标

JevBench 评测什么 — 9 个指标

指标 范围 一句话解释
accuracy [0, 1] 模型 argmax 答对的比例。对照 majority_class_accuracy 才有意义(不然 60% 看起来不错,但永远选 yes 就是 82%)。
majority_class_accuracy [0, 1] 永远选最高频 label 能拿多少分。每个 family 都有偏斜(adequacy 是 82%),accuracy 必须对照这个下限读。
schema_validity [0, 1] 响应能被解析成合法概率分布的比例。headline 容忍度 2%(允许 0.999 这种舍入误差)。
schema_validity_strict [0, 1] 冻结的 1e-3 容忍度。两个都报:headline 容忍度 vs 严格数字。
Brier [0, 2] 多类 Σ (p_k − y_k)²;二元题用 2-class 约定让数字可比。越低越好。
ECE [0, 1] 期望校准误差。10 个等宽 bin 按 top-label 置信度分;空 bin 缺席而非 0。越低越好。
ordinal_mae [0, N−1] 序数题上概率加权期望值 vs 参考 level 的 MAE。和 argmax accuracy 是两个不同预测,同时报。
paraphrase_consistency [0, 1] 同一题的两种问法:(a) 答得一样、(b) 都答对。两个都报——只报"答得一样"会让"两个都错"的高分。
latency p50 / p95 seconds 端到端延迟(含网络)。首个请求单独报,scale-to-zero 端点把冷启动算在第一个头上。
cost USD / 1k decisions derived_usage_times_tariff(实测 token × provider 自报 tariff)。无计费账户写 null + 显式 basis 标签(如 no_billable_account_public_endpoint)。单位是"每千次判定",不是每千 token。

每个指标的代码实现在:

  • llm_eval/scorer.py — 单题打分(含分布校验、argmax、Brier、MAE)
  • llm_eval/metrics.py — 聚合(ECE 10-bin、paraphrase consistency、latency p50/p95、cost)
  • llm_eval/summarize.py — 公共导出白名单 + 重算
# Cell 8 — 第一次跑:mock + smoke
import os, json
from pathlib import Path

import llm_eval
from llm_eval.adapters import get_adapter
from llm_eval.runner import run
from llm_eval.summarize import public_export
from llm_eval.task import load_tasks

# 输出统一落在 runs/notebook-demo/
OUT_ROOT = Path("runs/notebook-demo/smoke")
OUT_ROOT.mkdir(parents=True, exist_ok=True)

tasks = load_tasks("tasks/smoke.jsonl")
adapter = get_adapter(name="mock")  # default: 永远答对,confident

results = run(
    tasks=tasks,
    adapter=adapter,
    ledger_path=str(OUT_ROOT / "ledger.jsonl"),
    cap_usd=1.0,
    out_path=str(OUT_ROOT / "results.jsonl"),
    raw_dir=str(OUT_ROOT / "raw"),
    price_in_per_m=0.0,    # mock 免费
    price_out_per_m=0.0,
    delay_s=0.0,
    max_input_tokens=2000,
    max_output_tokens=200,
)

summary = public_export(results, tasks, run_meta={"cell": 8, "adapter": "mock"})
with open(OUT_ROOT / "summary.json", "w") as f:
    json.dump(summary, f, ensure_ascii=False, indent=2)

print(f"completed: {len(results)} tasks")
print(f"output:    {OUT_ROOT}")
print(f"  - results.jsonl   ({len(results)} records)")
print(f"  - summary.json    (public-safe aggregate)")
print(f"  - ledger.jsonl    (cost events)")
print(f"  - raw/*.json      ({len(list((OUT_ROOT/'raw').iterdir()))} raw req/resp bodies)")
# Cell 9 — 读懂 summary.json
import json

summary = json.load(open("runs/notebook-demo/smoke/summary.json"))

# 白话注释
field_notes = {
    "n_tasks": "题目总数",
    "n_scorable": "有 expected label 可打分的题数(= n_tasks 当全部题都有 expected)",
    "accuracy": "模型答对的比例",
    "majority_class_accuracy": "永远选最高频 label 的下限(baseline)",
    "schema_validity": "headline 容忍度(2%)下合法的比例",
    "schema_validity_strict": "严格容忍度(1e-3)下合法的比例",
    "brier": "校准损失,越低越好",
    "ece": "期望校准误差,越低越好",
    "ordinal_mae": "序数题的概率加权 MAE",
    "p50_s": "中位数延迟",
    "p95_s": "P95 延迟",
    "cost_usd_total": "总成本(USD)",
    "paraphrase_consistency": "paraphrase 对的同答 / 都对率",
    "per_family": "按 family 拆分的 accuracy",
    "per_topic": "按 topic 拆分的 accuracy",
    "run_meta": "本轮运行的元数据",
}

print(f"{'key':30s} {'value':20s}  note")
print("-" * 90)
for k in ["n_tasks", "n_scorable", "accuracy", "majority_class_accuracy",
         "schema_validity", "schema_validity_strict", "brier", "ece",
         "ordinal_mae", "p50_s", "p95_s", "cost_usd_total"]:
    v = summary.get(k)
    if isinstance(v, float):
        vstr = f"{v:.4f}"
    else:
        vstr = str(v)
    note = field_notes.get(k, "")
    print(f"{k:30s} {vstr:20s}  {note}")

print()
print("per_family:")
for fam, v in sorted(summary.get("per_family", {}).items()):
    print(f"  {fam:14s} n={v['n']:3d}  acc={v['accuracy']:.4f}")

print()
print("per_topic:")
for top, v in sorted(summary.get("per_topic", {}).items()):
    print(f"  {top:18s} n={v['n']:3d}  acc={v['accuracy']:.4f}")

print()
print("paraphrase_consistency:", summary.get("paraphrase_consistency"))

Cell 10 — 接真实模型:环境准备

接真实模型:环境准备

两种方式提供 key(任选其一)

A. 直接在 cell 11 粘贴(仅学习用,最方便)

打开 cell 11,把你的 key 填进 direct_keys 字典。注意:填了的 key 会保存到 .ipynb 文件里,请勿把含 key 的 notebook 提交到 git 或分享给他人。

B. 用环境变量(生产 / 共享场景)

export DEEPSEEK_API_KEY="sk-..."          # DeepSeek
export DASHSCOPE_API_KEY="sk-..."         # 阿里百炼 / Qwen
export ZHIPU_API_KEY="..."                # 智谱 GLM
export MOONSHOT_API_KEY="sk-..."          # Moonshot Kimi
export ARK_API_KEY="..."                  # 字节豆包 / 火山方舟
export STEPFUN_API_KEY="..."              # 阶跃星辰
export XIAOMI_API_KEY="..."               # 小米 MiMo

Cell 11 自动检测两路来源,A 优先于 B。

安全纪律

  • Cell 11 顶部有红色警告——粘贴 key 前请先读。
  • Cell 11 / 12 不 echo key——检测结果只显示"set / not set"。
  • cap_usd 限制:LBEVAL_CAP_USD(默认 0.30/provider)防止意外爆额度。
  • 并发上限:LBEVAL_MAX_WORKERS(默认 5);超过 5 个 key 时 cell 12 会分批提示。
  • 如果用了 A 方式,运行前 git diff notebooks/ 应该没有新增内容(notebooks/*_executed.ipynb 已在 .gitignore 里,但 canonical .ipynb 没有)。

跑哪一档

cell 12 默认跑 tasks/public/easy.jsonl(48 题),成本约 $0.001–$0.05/provider。 如果你想跑全部 231 题,改 cell 12 里的 tasks_path 到 tasks/public_all.jsonl。

# Cell 11 — 检查 & 配置 API key

# ┌─────────────────────────────────────────────────────────────────────────┐
# │ ⚠️  DANGER ZONE — 明文 key 粘贴区(仅学习用!)                          │
# ├─────────────────────────────────────────────────────────────────────────┤
# │ 在这里直接填 key 会保存到 .ipynb 文件里。请勿:                          │
# │   ✗ 把含 key 的 notebook commit 到 git                                  │
# │   ✗ 把含 key 的 notebook 截图 / 拷贝给他人                              │
# │   ✗ 把 notebook 上传到任何公开 / 第三方平台                              │
# │                                                                          │
# │ 学习完成后请把这里填的 key 删干净,并清空 outputs:                       │
# │   菜单 → Cell → All Output → Clear  (Jupyter)                          │
# │   菜单 → Edit → Clear All Outputs  (VS Code)                           │
# └─────────────────────────────────────────────────────────────────────────┘

# 8 个当前可用的 provider — Jev (native System One) + 7 个国内模型 (verbalized)
direct_keys = {
    "TYPESAFE_API_KEY": "",   # Jev (TypeSafe System One, native)
    "MOONSHOT_API_KEY": "",   # Kimi K3 (Moonshot)
    "DEEPSEEK_API_KEY": "",   # DeepSeek V4.1
    "DASHSCOPE_API_KEY": "",   # 阿里百炼 / Qwen3.8-Max (空 = 该 provider 跳过)
    "ZHIPU_API_KEY": "",   # 智谱 GLM-5.3 codeplan
    "ARK_API_KEY": "",   # 字节豆包 2.1 Pro (火山方舟) (空 = 该 provider 跳过)
    "STEPFUN_API_KEY": "",   # 阶跃星辰 Step-5 codingplan
    "XIAOMI_API_KEY": "",   # 小米 MiMo 2.6 Pro (公网 endpoint 待发布)
}

# ┌──────────────────────────────────────────────────────────────────────────┐
# │ ↑ 替换上面的空字符串为你的 key,例如:                                    │
# │    "TYPESAFE_API_KEY":  "tsk_...",                                       │
# │    "MOONSHOT_API_KEY":  "sk-...",                                        │
# │    "DEEPSEEK_API_KEY":  "sk-...",                                        │
# │    "DASHSCOPE_API_KEY": "sk-...",                                        │
# │    "ZHIPU_API_KEY":     "...",   # codeplan 的 key 通常和官方 key 同形    │
# │    "ARK_API_KEY":       "...",   # 火山方舟 key                           │
# │    "STEPFUN_API_KEY":   "...",   # 阶跃星辰 codingplan key               │
# │    "XIAOMI_API_KEY":    "...",                                          │
# └──────────────────────────────────────────────────────────────────────────┘

# ┌──────────────────────────────────────────────────────────────────────────┐
# │ base_url 覆盖区(直连 / Coding Plan 任选其一)───                       │
# ├──────────────────────────────────────────────────────────────────────────┤
# │ 留空 = 用 provider 官方默认地址                                          │
# │ Coding Plan / 中转站用户:在对应 key_env 的位置同时填 base_url          │
# │                                                                          │
# │ ⚠️  ZHIPU_API_KEY 和 STEPFUN_API_KEY 走 codingplan 时,                  │
# │     端点 URL 与"直连"不同。把下面两行取消注释,并填你的 codingplan 端点 │
# └──────────────────────────────────────────────────────────────────────────┘

base_url_overrides = {
    # 官方直连 URL(默认值,可不填):
    "TYPESAFE_API_KEY":  "https://api.typesafe.ai",          # Jev (native)
    "MOONSHOT_API_KEY":  "https://api.moonshot.cn/v1",          # Kimi K3
    "DEEPSEEK_API_KEY":  "https://api.deepseek.com/v1",         # DeepSeek V4.1
    "DASHSCOPE_API_KEY": "https://dashscope.aliyuncs.com/compatible-mode/v1",  # Qwen
    "ARK_API_KEY":       "https://ark.cn-beijing.volces.com/api/v3",  # 豆包
    "XIAOMI_API_KEY":    "https://api.xiaomi.com/v1",           # placeholder
    # ↓ 如果你的 codingplan 端点和直连不同,把下面这两行的 # 去掉并填:
    # "ZHIPU_API_KEY":  "https://your-glm-codeplan-endpoint.com/v1",
    # "STEPFUN_API_KEY": "https://your-step-codeplan-endpoint.com/v1",
}

import os

# 合并 key:direct_keys > env var
effective = {}
for k, v in direct_keys.items():
    effective[k] = v if v else os.environ.get(k, "")

# 合并 base_url:base_url_overrides > env var _BASE_URL > 默认
def resolve_base_url(key_env: str, default_url: str) -> str:
    override = base_url_overrides.get(key_env, "")
    if override:
        return override
    env_url = os.environ.get(key_env + "_BASE_URL", "")
    if env_url:
        return env_url
    return default_url

# 当前 8 个 provider 配置 — Jev (native System One) + 7 个国内模型 (verbalized)
PROVIDER_TABLE = [
    # Jev — TypeSafe System One (native)。output tokens free,output 价格 = 0。
    {"name": "jev",                 "adapter": "jev",      "model": "jev-latest",
     "key_env": "TYPESAFE_API_KEY",
     "default_base_url": "https://api.typesafe.ai",
     "price_in_per_m": 0.042, "price_out_per_m": 0.0,
     "mode": "native",
     "notes": "Jev (TypeSafe native System One)"},
    # 7 个国内 verbalized 模型 — 通用 LLM 用 JSON schema 写出概率分布
    {"name": "deepseek-v4.1",       "adapter": "deepseek", "model": "deepseek-flash",
     "key_env": "DEEPSEEK_API_KEY",
     "default_base_url": "https://api.deepseek.com/v1",
     "price_in_per_m": 0.27,  "price_out_per_m": 1.10,
     "mode": "verbalized",
     "notes": "DeepSeek V4.1 (cache-miss input)"},
    {"name": "qwen3.8-max",         "adapter": "qwen",     "model": "qwen3.8-max",
     "key_env": "DASHSCOPE_API_KEY",
     "default_base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
     "price_in_per_m": 0.004, "price_out_per_m": 0.012,
     "mode": "verbalized",
     "notes": "Qwen3.8-Max (阿里百炼)"},
    {"name": "glm-5.3-codeplan",    "adapter": "glm",      "model": "glm-5.3",
     "key_env": "ZHIPU_API_KEY",
     "default_base_url": "https://open.bigmodel.cn/api/paas/v4",
     "price_in_per_m": 0.50,  "price_out_per_m": 0.50,
     "mode": "verbalized",
     "notes": "GLM-5.3 codeplan (智谱)"},
    {"name": "kimi-k3",             "adapter": "moonshot", "model": "kimi-k3",
     "key_env": "MOONSHOT_API_KEY",
     "default_base_url": "https://api.moonshot.cn/v1",
     "price_in_per_m": 0.10,  "price_out_per_m": 0.30,
     "request_options": {"temperature": 1},  # kimi-k3 only accepts temperature=1
     "mode": "verbalized",
     "notes": "Kimi K3 (Moonshot)"},
    {"name": "doubao-2.1-pro",      "adapter": "doubao",   "model": "doubao-2.1-pro",
     "key_env": "ARK_API_KEY",
     "default_base_url": "https://ark.cn-beijing.volces.com/api/v3",
     "price_in_per_m": 0.80,  "price_out_per_m": 1.00,
     "resolve_ark_prefix": "doubao-seed-2-1-pro",  # auto-discover endpoint id
     "mode": "verbalized",
     "notes": "豆包 2.1 Pro (火山方舟),endpoint id 由 ARK /models 自动发现"},
    {"name": "step-5-codeplan",     "adapter": "stepfun",  "model": "step-5",
     "key_env": "STEPFUN_API_KEY",
     "default_base_url": "https://api.stepfun.com/v1",
     "price_in_per_m": 1.00,  "price_out_per_m": 2.00,
     "mode": "verbalized",
     "notes": "Step-5 codingplan (阶跃星辰)"},
    {"name": "xiaomi-mimo-2.6-pro", "adapter": "xiaomi",   "model": "mimo-2.6-pro",
     "key_env": "XIAOMI_API_KEY",
     "default_base_url": "https://api.xiaomi.com/v1",  # placeholder
     "price_in_per_m": None,  "price_out_per_m": None,
     "mode": "verbalized",
     "skip_reason": "Xiaomi MiMo endpoint 是文档化的 placeholder,等公网 endpoint 出再启用",
     "notes": "MiMo 2.6 Pro (小米) — placeholder URL"},
]

# 计算 effective base_url
for p in PROVIDER_TABLE:
    p["base_url"] = resolve_base_url(p["key_env"], p["default_base_url"])

print(f"{'provider':22s} {'key':6s} {'source':8s} {'base_url':50s}")
print('-' * 95)
for p in PROVIDER_TABLE:
    val = effective.get(p["key_env"], "")
    has = bool(val)
    mark = "✓" if has else "✗"
    src = "pasted" if direct_keys.get(p["key_env"]) else ("env" if os.environ.get(p["key_env"]) else "-")
    url_short = p["base_url"]
    if len(url_short) > 48:
        url_short = url_short[:45] + "..."
    note = p.get("notes", "")
    print(f"  {mark}  {p['name']:20s} {('set' if has else '-'):6s} {src:8s} {url_short:50s} {note}")

MAX = int(os.environ.get("LBEVAL_MAX_WORKERS", "5"))
CAP = os.environ.get("LBEVAL_CAP_USD", "1.50")
print(f"\nLBEVAL_MAX_WORKERS = {MAX} (cell 12 will batch if more keys are detected)")
print(f"LBEVAL_CAP_USD      = {CAP} (per-provider budget cap; default 1.50 for full benchmark)")

# 安全检查:如果用了 pasted 方式,提醒一次
any_pasted = any(direct_keys.values())
if any_pasted:
    print("\n⚠️  You have pasted plaintext keys above. Remember:")
    print("    - Do NOT commit this notebook if it has keys filled in")
    print("    - Do NOT share or upload this notebook")
    print("    - Clear outputs and remove keys before saving")
# Cell 12 — 跑真模型:并行所有可用 provider(≤5 一批,跑全量 benchmark)
import os, sys, time, json
from pathlib import Path
from concurrent.futures import ThreadPoolExecutor
import urllib.request, urllib.error

from IPython.display import display, Markdown

import llm_eval
from llm_eval.adapters import get_adapter
from llm_eval.multi_config import MultiRunConfig, RunnerConfig
from llm_eval.multi_runner import run_multi
from llm_eval.task import load_tasks

# PROVIDER_TABLE 复用 cell 11 的(含 base_url / price / request_options)。
# 如果你直接执行本 cell 而跳过了 cell 11,下面有一份简化副本(不会自动用粘贴 key)。
if "PROVIDER_TABLE" not in globals():
    PROVIDER_TABLE = [
        # Jev — native System One(TypeSafe),output free
        {"name": "jev",              "adapter": "jev",      "model": "jev-latest",
         "key_env": "TYPESAFE_API_KEY",
         "default_base_url": "https://api.typesafe.ai",
         "price_in_per_m": 0.042, "price_out_per_m": 0.0,
         "mode": "native"},
        # 7 个国内模型 — verbalized(让通用 LLM 用 JSON schema 写出概率分布)
        {"name": "deepseek-v4.1",    "adapter": "deepseek", "model": "deepseek-flash",
         "key_env": "DEEPSEEK_API_KEY",
         "default_base_url": "https://api.deepseek.com/v1",
         "price_in_per_m": 0.27, "price_out_per_m": 1.10,
         "mode": "verbalized"},
        {"name": "qwen3.8-max",      "adapter": "qwen",     "model": "qwen3.8-max",
         "key_env": "DASHSCOPE_API_KEY",
         "default_base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
         "price_in_per_m": 0.004, "price_out_per_m": 0.012,
         "mode": "verbalized"},
        {"name": "glm-5.3-codeplan", "adapter": "glm",      "model": "glm-5.3",
         "key_env": "ZHIPU_API_KEY",
         "default_base_url": "https://open.bigmodel.cn/api/paas/v4",
         "price_in_per_m": 0.50, "price_out_per_m": 0.50,
         "mode": "verbalized"},
        {"name": "kimi-k3",          "adapter": "moonshot", "model": "kimi-k3",
         "key_env": "MOONSHOT_API_KEY",
         "default_base_url": "https://api.moonshot.cn/v1",
         "price_in_per_m": 0.10, "price_out_per_m": 0.30,
         "request_options": {"temperature": 1},  # kimi-k3 only accepts temperature=1
         "mode": "verbalized"},
        {"name": "doubao-2.1-pro",   "adapter": "doubao",
         # ARK 模型名是 endpoint id。auto-discover 最新 doubao-seed-2-1-pro-* endpoint。
         "model": "doubao-seed-2-1-pro-auto",
         "key_env": "ARK_API_KEY",
         "default_base_url": "https://ark.cn-beijing.volces.com/api/v3",
         "price_in_per_m": 0.80, "price_out_per_m": 1.00,  # 20 元 plan token 价
         "resolve_ark_prefix": "doubao-seed-2-1-pro",
         "mode": "verbalized"},
        {"name": "step-5-codeplan",  "adapter": "stepfun",  "model": "step-5",
         "key_env": "STEPFUN_API_KEY",
         "default_base_url": "https://api.stepfun.com/v1",
         "price_in_per_m": 1.00, "price_out_per_m": 2.00,
         "mode": "verbalized"},
        {"name": "xiaomi-mimo-2.6-pro","adapter": "xiaomi","model": "mimo-2.6-pro",
         "key_env": "XIAOMI_API_KEY",
         "default_base_url": "https://api.xiaomi.com/v1",
         "price_in_per_m": None, "price_out_per_m": None,
         "mode": "verbalized",
         "skip_reason": "Xiaomi MiMo endpoint 是文档化的 placeholder,等公网 endpoint 出再启用"},
    ]
    # fallback base_url & default request_options
    for p in PROVIDER_TABLE:
        p.setdefault("base_url", p["default_base_url"])
        p.setdefault("request_options", None)
        p.setdefault("skip_reason", "")

MAX_PARALLEL = min(int(os.environ.get("LBEVAL_MAX_WORKERS", "5")), 5)
CAP_USD = float(os.environ.get("LBEVAL_CAP_USD", "1.50"))
TASKS_PATH = os.environ.get("LBEVAL_TASKS_PATH", "tasks/public_all.jsonl")  # 231 题全量

OUT_ROOT = Path("runs/notebook-demo/real")
OUT_ROOT.mkdir(parents=True, exist_ok=True)

# 加载 tasks 用于预估成本 + 真实 token 校验
all_tasks = load_tasks(TASKS_PATH)
n_tasks = len(all_tasks)
print(f"benchmark: {TASKS_PATH}  ({n_tasks} tasks)")
print(f"  estimated per-provider cost (input tokens × price):")
for p in PROVIDER_TABLE:
    if p.get("skip_reason"):
        continue
    if p["price_in_per_m"] is not None:
        est_in = n_tasks * 1500 * p["price_in_per_m"] / 1e6
        est_out = n_tasks * 50 * (p["price_out_per_m"] or 0) / 1e6
        print(f"    {p['name']:18s}  ~${est_in + est_out:.4f}  (in {est_in:.4f} + out {est_out:.4f})")

# Key 来源合并:cell 11 的 `direct_keys` 优先,回退到 env var
_effective_keys = {}
_base_url_overrides: dict[str, str] = {}
if "direct_keys" in globals():
    for k, v in direct_keys.items():
        _effective_keys[k] = v if v else os.environ.get(k, "")
    _base_url_overrides = dict(globals().get("base_url_overrides", {}))
else:
    for p in PROVIDER_TABLE:
        _effective_keys[p["key_env"]] = os.environ.get(p["key_env"], "")


def _resolve_base_url(p: dict) -> str:
    """优先级:cell 11 base_url_overrides > env _BASE_URL > default"""
    if _base_url_overrides.get(p["key_env"]):
        return _base_url_overrides[p["key_env"]]
    env_url = os.environ.get(p["key_env"] + "_BASE_URL", "")
    if env_url:
        return env_url
    return p.get("default_base_url", p.get("base_url", ""))


def _chat_probe(key: str, base_url: str, model: str, adapter_name: str = "") -> int:
    """1-shot probe 提前检测 401/402/404。返回 HTTP status。

    - 默认(OpenAI 兼容):POST /chat/completions with {model, messages, max_tokens:1}
    - Jev (native System One):GET /v1/models(只要 key 正确就 200;systemone 需要合法 state)
    """
    if adapter_name in ("jev", "typesafe"):
        # Jev has no /chat/completions — auth-check via /v1/models
        url = base_url.rstrip("/") + "/v1/models"
        try:
            req = urllib.request.Request(url, method="GET",
                headers={"Authorization": f"Bearer {key}"})
            with urllib.request.urlopen(req, timeout=15) as r:
                return r.status
        except urllib.error.HTTPError as e:
            return e.code
        except Exception:
            return 0
    req = urllib.request.Request(
        base_url.rstrip("/") + "/chat/completions",
        method="POST",
        headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"},
        data=json.dumps({
            "model": model,
            "messages": [{"role": "user", "content": "ping"}],
            "max_tokens": 1,
        }).encode(),
    )
    try:
        with urllib.request.urlopen(req, timeout=15) as r:
            return r.status
    except urllib.error.HTTPError as e:
        return e.code
    except Exception:
        return 0


def _resolve_ark_endpoint(key: str, base_url: str, hint_prefix: str) -> str | None:
    """Probe ARK /models,返回最新版的 endpoint id。"""
    try:
        req = urllib.request.Request(
            base_url.rstrip("/") + "/models",
            headers={"Authorization": f"Bearer {key}"},
        )
        with urllib.request.urlopen(req, timeout=15) as r:
            j = json.loads(r.read())
        ids = [e["id"] for e in j.get("data", []) if "id" in e]
    except Exception:
        return None
    matches = [i for i in ids if hint_prefix in i]
    matches.sort(reverse=True)  # "260915" > "260628" > "260215"
    return matches[0] if matches else None


# Step 1 — 扫可用 provider;probe 每家区分 401/402/404/200
available = []
skipped = []
for p in PROVIDER_TABLE:
    if p.get("skip_reason"):
        skipped.append((p, f"config: {p['skip_reason']}"))
        continue
    key = _effective_keys.get(p["key_env"], "").strip()
    if not key:
        skipped.append((p, f"no {p['key_env']}"))
        continue
    p["base_url"] = _resolve_base_url(p)

    # ARK endpoint auto-discovery(model name ≠ endpoint id,必须自动 discover)
    if p.get("resolve_ark_prefix") and p["base_url"].rstrip("/").endswith("/api/v3"):
        old_model = p["model"]
        discovered = _resolve_ark_endpoint(key, p["base_url"], p["resolve_ark_prefix"])
        if discovered:
            p["model"] = discovered
            print(f"  [ark-discover] {p['name']}: {old_model} → {discovered}")
        else:
            skipped.append((p, f"ARK /models 不返回 {p['resolve_ark_prefix']}* 的 endpoint"))
            continue

    code = _chat_probe(key, p["base_url"], p["model"], adapter_name=p["adapter"])
    if code == 200:
        available.append(p)
        print(f"  ✓  {p['name']:18s}  base_url={p['base_url']}  model={p['model']}")
    elif code in (401, 403):
        skipped.append((p, f"http {code}: auth rejected (check key validity / plan)"))
    elif code == 402:
        skipped.append((p, f"http {code}: plan 额度用尽(去 {p['key_env'].replace('_API_KEY','').lower()} 续费)"))
    elif code == 404:
        skipped.append((p, f"http {code}: model '{p['model']}' 此 endpoint 不可用"))
    elif code == 0:
        skipped.append((p, f"chat endpoint unreachable(DNS / TLS / 代理)"))
    else:
        skipped.append((p, f"http {code}: 未知状态"))

if skipped:
    print("\n" + f"skipped {len(skipped)} provider(s):")
    for p, reason in skipped:
        print(f"  ✗  {p['name']:18s}  {reason}")

print(f"\nrunning {len(available)} provider(s) × {n_tasks} tasks, "
      f"max parallel={MAX_PARALLEL}, cap=${CAP_USD} per provider")

if not available:
    display(Markdown(
        "⚠️ **No API keys available** (or all probes failed).\n\n"
        "Two ways to provide keys:\n\n"
        "**A. Paste directly in cell 11** (`direct_keys` dict) — convenient for learning, "
        "but the key is saved into this notebook file.\n\n"
        "**B. `export DEEPSEEK_API_KEY=...` in your shell** before launching Jupyter — "
        "safer for production.\n\n"
        "Without working keys, cell 14 has no real data to plot. Re-run this cell after fixing the listed skips."
    ))
else:
    # Step 2 — 分批
    def chunked(lst, n):
        for i in range(0, len(lst), n):
            yield lst[i:i+n]
    batches = list(chunked(available, MAX_PARALLEL))

    all_runs = []
    for batch_idx, batch in enumerate(batches):
        print("\n=== batch " + f"{batch_idx+1}/{len(batches)}: {[p['name'] for p in batch]}" + " ===")

        def run_one(spec):
            try:
                os.environ[spec["key_env"]] = _effective_keys[spec["key_env"]]
                from llm_eval.adapters import PROVIDERS as _PROVIDERS
                original_url = _PROVIDERS.get(spec["adapter"], {}).get("base_url")
                if original_url and spec["base_url"] != original_url:
                    _PROVIDERS.setdefault(spec["adapter"], {})["base_url"] = spec["base_url"]
                try:
                    cfg = MultiRunConfig(
                        run_id=f"cell12-{spec['name']}",
                        tasks_path=TASKS_PATH,
                        runners=[RunnerConfig(
                            name=spec["name"], adapter=spec["adapter"], model=spec["model"],
                            key_env=spec["key_env"],   # ← 关键修复:之前是 "",runner 取不到 key
                            price_in_per_m=spec["price_in_per_m"],
                            price_out_per_m=spec["price_out_per_m"],
                            skip_if_done=True,
                        )],
                        cap_usd=CAP_USD,
                        delay_s=0.3,
                        out_root=str(OUT_ROOT / "_tmp"),
                        request_options=spec.get("request_options"),
                    )
                    manifest = run_multi(cfg)
                finally:
                    direct_used = ("direct_keys" in globals() and direct_keys.get(spec["key_env"]))
                    if not direct_used:
                        os.environ.pop(spec["key_env"], None)
                    if original_url and spec["base_url"] != original_url:
                        _PROVIDERS[spec["adapter"]]["base_url"] = original_url
                return (spec["name"], "ok", manifest)
            except Exception as e:
                return (spec["name"], "error", repr(e))

        with ThreadPoolExecutor(max_workers=len(batch)) as ex:
            futures = [ex.submit(run_one, p) for p in batch]
            for fut in futures:
                name, status, payload = fut.result()
                if status == "ok":
                    # multi_runner 会把 name slug 化成下划线(dot→underscore),
                    # 用 manifest dir 取最稳,不要硬编码 name。
                    runner_dir = Path(payload["runners"][0]["dir"])
                    summary_path = runner_dir / "summary.json"
                    if summary_path.exists():
                        import shutil
                        target = OUT_ROOT / name
                        target.mkdir(parents=True, exist_ok=True)
                        for fname in ("results.jsonl", "summary.json", "ledger.jsonl"):
                            src = runner_dir / fname
                            if src.exists():
                                shutil.copy(src, target / fname)
                        src_raw = runner_dir / "raw"
                        if src_raw.is_dir():
                            shutil.copytree(src_raw, target / "raw", dirs_exist_ok=True)
                        print(f"  [done] {name}: copied to {target}")
                    all_runs.append(name)
                else:
                    print(f"  [error] {name}: {str(payload)[:200]}")

        if batch_idx + 1 < len(batches):
            remaining = [p["name"] for p in available[batch_idx+1:]]
            display(Markdown(
                f"⚠️ **还有 {len(remaining)} 个 provider 没跑**:{remaining}。\n\n"
                f"再次执行本 cell 跑下一批(当前上限 {MAX_PARALLEL})。"
                f"`LBEVAL_MAX_WORKERS` 可调。"
            ))

    print("\nbatch summary: {len(all_runs)} provider(s) ran successfully")
    print(f"output root: {OUT_ROOT}")

# === Troubleshooting 子节 ===
# 常见情况:
# 1. summary.schema_validity 偏低(< 0.7):
#    - 模型返回了概率但某些题没覆盖所有 label → 长 label 集上 schema 漏报
#    - 模型有时返回空字符串 → rate-limit 或服务端偶发;flash 变体比主入口更明显
# 2. summary.accuracy 高但 brier/ece 也高:
#    - 答对了但置信度没校准——自信错的也自信对。
# 3. 大量 error 记录 → adapter 没适配该 provider 的 response_format;改 llm_eval/adapters/openai_compat.py
# 4. cost_usd_total = None → 该 provider 没设 price(米/placeholder),框架拒绝"编"价格
# 5. 直填 key 后报错"key empty":cell 11 里 `"DEEPSEEK_API_KEY": ""` 是空字符串就视作未填,
#    填一个真 key 即可(任意非空字符串都能识别为 "set")。
# 6. base_url 覆盖不生效:检查 cell 11 的 base_url_overrides 字典 key 是否跟 key_env 一致。
# 7. ARK 模型报 404:模型名要用 endpoint id(如 `doubao-seed-2-1-pro-260915`);
#    本 cell 已自动从 /models 取最新匹配,无需手填。
# 8. kimi-k3 报 `invalid temperature`:model 只接受 temperature=1,已通过 request_options 注入。
# 9. 小米/mimo 跳过:endpoint 还是 placeholder;等 MiMo 出公网后改 cell 11 的 base_url 即可启用。
# 10. StepFun 报 402:plan 额度用尽,去 https://platform.stepfun.com/ 续费 / 换套餐。
# Cell 13 — Mock 锚点对照(cell 14 会消费这里产出的 compare.json)
import json, os
from pathlib import Path

import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
from IPython.display import display, Markdown

import llm_eval
from llm_eval.multi_config import MultiRunConfig, RunnerConfig
from llm_eval.multi_runner import run_multi
from llm_eval.compare import compare_run, write_markdown_table

# 两个 mock 锚点:mock-perfect(理论满分) + mock-uniform(随机分布)
# 都是 cell 14 横评里的上下界参照系;真实 provider 由 cell 12 注入。
TASKS = "tasks/smoke.jsonl"  # cell 13 用 smoke(秒级);cell 14 真实 provider 跑的是 public_all(231 题)

cfg = MultiRunConfig(
    run_id="cell13-multi-mock",
    tasks_path=TASKS,
    runners=[
        RunnerConfig(name="mock-perfect", adapter="mock", model="perfect",   key_env=""),
        RunnerConfig(name="mock-uniform", adapter="mock", model="uniform-1", key_env=""),
    ],
    cap_usd=1.0, delay_s=0.0,
    out_root="runs/notebook-demo/multi/_tmp",
)
manifest = run_multi(cfg)
print(f"runners: {len(manifest['runners'])}")
for r in manifest["runners"]:
    print(f"  {r['name']:20s} status={r['status']:8s} n_ok={r.get('n_ok','-')}")

# Compare
cmp = compare_run("runs/notebook-demo/multi/_tmp/cell13-multi-mock")
charts_dir = Path("runs/notebook-demo/multi/charts")
charts_dir.mkdir(parents=True, exist_ok=True)
write_markdown_table(cmp, str(charts_dir.parent / "compare.md"))
(cmp_path := charts_dir.parent / "compare.json").write_text(
    json.dumps(cmp, ensure_ascii=False, indent=2)
)

# === 内联展示全指标对比表(不只"已导出") ===
rows = cmp["rows"]
cols = [
    ("adapter/model", lambda r: f"{r.get('adapter','')}/{r.get('model','')}"),
    ("accuracy",     lambda r: r.get("accuracy")),
    ("majority_base",lambda r: r.get("majority_class_accuracy")),
    ("schema_valid", lambda r: r.get("schema_validity")),
    ("schema_strict",lambda r: r.get("schema_validity_strict")),
    ("brier",        lambda r: r.get("brier")),
    ("ece",          lambda r: r.get("ece")),
    ("ordinal_mae",  lambda r: r.get("ordinal_mae")),
    ("p50_s",        lambda r: r.get("p50_s")),
    ("p95_s",        lambda r: r.get("p95_s")),
    ("cost_usd",     lambda r: r.get("cost_usd_total")),
]
def fmt(v):
    if v is None: return "—"
    if isinstance(v, float):
        return f"{v:.4f}" if abs(v) < 100 else f"{v:.2f}"
    return str(v)

header = "| runner | " + " | ".join(c[0] for c in cols) + " |"
sep = "|" + "|".join(["---"] * (len(cols) + 1)) + "|"
lines = [header, sep]
for name, row in rows.items():
    cells = [name] + [fmt(c[1](row)) for c in cols]
    lines.append("| " + " | ".join(cells) + " |")
display(Markdown("### Mock multi-model comparison (cell 13)\n\n" + "\n".join(lines)))

# === 3x3 图:全 9 指标对比 ===
metrics_9 = [
    ("accuracy",          "Accuracy (↑)",          "linear", 1.05),
    ("schema_validity",   "Schema valid (↑)",      "linear", 1.05),
    ("schema_validity_strict","Schema strict (↑)", "linear", 1.05),
    ("brier",             "Brier (↓)",             "log",    None),
    ("ece",               "ECE (↓)",               "log",    None),
    ("ordinal_mae",       "Ordinal MAE (↓)",       "linear",  None),
    ("p50_s",             "p50 latency (↓)",       "log",    None),
    ("p95_s",             "p95 latency (↓)",       "log",    None),
    ("cost_usd_total",    "Cost / 1k decisions (↓)","log",   None),
]
fig, axes = plt.subplots(3, 3, figsize=(15, 12))
names = list(rows.keys())
colors = ["tab:blue", "tab:orange", "tab:green", "tab:red", "tab:purple"]
for idx, (key, title, scale, ymax) in enumerate(metrics_9):
    r, c = divmod(idx, 3)
    ax = axes[r, c]
    vals = [(rows[n].get(key) or 0) for n in names]
    ax.bar(names, vals, color=[colors[i % len(colors)] for i in range(len(names))])
    ax.set_title(title, fontsize=11)
    ax.tick_params(axis='x', rotation=20)
    if scale == "log":
        positive = [v for v in vals if v > 0]
        if len(positive) >= 1:
            ax.set_yscale("log")
    if ymax is not None:
        ax.set_ylim(0, ymax)
fig.suptitle("JevBench full-metric comparison (cell 13 mock)", fontsize=14)
fig.tight_layout()
chart_path = charts_dir / "13_full_metrics.png"
fig.savefig(chart_path, dpi=120, bbox_inches='tight')
display(Markdown(f"**chart saved**: `{chart_path}`"))
plt.close(fig)

# 也保存老 4 联图
fig, axes = plt.subplots(1, 4, figsize=(14, 4))
metrics_4 = [
    ("accuracy",      [rows[n].get("accuracy", 0) for n in names],      "Accuracy (↑)"),
    ("brier",         [rows[n].get("brier", 0) or 0 for n in names],     "Brier (↓)"),
    ("p50_s",         [rows[n].get("p50_s", 0) or 0 for n in names],     "p50 (s)"),
    ("cost_usd_total",[rows[n].get("cost_usd_total") or 0 for n in names], "Cost USD (↓)"),
]
for ax, (k, v, t) in zip(axes, metrics_4):
    ax.bar(names, v, color=[colors[i % len(colors)] for i in range(len(names))])
    ax.set_title(t)
    ax.tick_params(axis='x', rotation=15)
    if k in ("brier", "p50_s", "cost_usd_total") and any(x > 0 for x in v):
        ax.set_yscale("log")
fig.suptitle("JevBench 4-key comparison (cell 13 mock)", fontsize=13)
fig.tight_layout()
fig.savefig(charts_dir / "13_mock_compare.png", dpi=120, bbox_inches='tight')
plt.close(fig)

display(Markdown(
    f"\n所有产物:\n"
    f"- `runs/notebook-demo/multi/compare.json`\n"
    f"- `runs/notebook-demo/multi/compare.md`\n"
    f"- `runs/notebook-demo/multi/charts/13_full_metrics.png` (3×3 全指标)\n"
    f"- `runs/notebook-demo/multi/charts/13_mock_compare.png` (4 联简版)"
))
# Cell 14 — Main score + 4 维横评(token / time / cost / accuracy)
import json
from pathlib import Path

import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
from IPython.display import display, Markdown

# 严格只显示真实数据(cell 12 runner 写到磁盘的 summary.json / results.jsonl + cell 13 mock 锚点)。
# 缺失数据 → 显示 '—',绝不编造任何数字。

def _fmt(v):
    if v is None: return "—"
    if isinstance(v, bool): return str(v)
    if isinstance(v, float):
        return f"{v:.2f}" if abs(v) >= 100 else f"{v:.4f}"
    if isinstance(v, int):
        return f"{v:,}"
    return str(v)

def _fmt_usd(v):
    if v is None: return "—"
    if isinstance(v, (int, float)): return f"${v:.4f}"
    return str(v)

def _load_real_tokens(results_path: Path):
    """Sum prompt_tokens / completion_tokens from results.jsonl."""
    if not results_path.exists():
        return None, None
    in_t = out_t = 0
    any_usage_seen = False
    with results_path.open("r", encoding="utf-8") as f:
        for line in f:
            try:
                rec = json.loads(line)
            except Exception:
                continue
            usage = rec.get("usage") or {}
            it = usage.get("prompt_tokens", 0) or 0
            ot = usage.get("completion_tokens", 0) or 0
            if it or ot:
                any_usage_seen = True
            in_t += it
            out_t += ot
    if not any_usage_seen:
        return None, None
    return in_t, out_t

# === Step 1: 扫描真实 runs (cell 12 写到磁盘的) ===
real_dir = Path("runs/notebook-demo/real")
real_rows = {}
if real_dir.is_dir():
    for d in sorted(real_dir.iterdir()):
        if not d.is_dir() or d.name == "_tmp":
            continue
        sp = d / "summary.json"
        if not sp.exists():
            continue
        try:
            s = json.loads(sp.read_text())
        except Exception:
            continue
        in_t, out_t = _load_real_tokens(d / "results.jsonl")
        real_rows[d.name] = {
            "data_source": "real",
            "accuracy": s.get("accuracy"),
            "p50_s": s.get("p50_s"),
            "p95_s": s.get("p95_s"),
            "token_in": in_t,
            "token_out": out_t,
            "cost_usd_total": s.get("cost_usd_total"),
            "schema_validity": s.get("schema_validity"),
            "brier": s.get("brier"),
            "ece": s.get("ece"),
            "ordinal_mae": s.get("ordinal_mae"),
            "jevbench_score": s.get("jevbench_score"),
        }

n_real = len(real_rows)
src_label = f"{n_real} real runner(s)"

display(Markdown(f"## 4-dim cross-model comparison — {src_label}"))
if n_real == 0:
    display(Markdown(
        "🔴 **No real provider data available.** "
        "Set at least one `*_API_KEY` env var (or paste into cell 11) and re-run cell 12. "
        "The charts / tables below need real API responses to plot — no synthesis, no estimation."
    ))

# === Step 2: 4 维表格 (accuracy / time / token / price) ===
table_cols = [
    ("data_src",   lambda r: r.get("data_source", "—")),
    ("accuracy",   lambda r: _fmt(r.get("accuracy"))),
    ("p50_s (time)", lambda r: _fmt(r.get("p50_s"))),
    ("token_in",   lambda r: _fmt(r.get("token_in"))),
    ("token_out",  lambda r: _fmt(r.get("token_out"))),
    ("cost_usd (price)", lambda r: _fmt_usd(r.get("cost_usd_total"))),
]
header = "| runner | " + " | ".join(c[0] for c in table_cols) + " |"
sep = "|" + "|".join(["---"] * (len(table_cols) + 1)) + "|"
table_lines = [header, sep]
for name, row in sorted(real_rows.items()):
    cells = [name] + [c[1](row) for c in table_cols]
    table_lines.append("| " + " | ".join(cells) + " |")
display(Markdown("\n".join(table_lines)))

# === Step 3: 4 维横评图 (2x2) — accuracy / time / token / price ===
charts_dir = Path("runs/notebook-demo/multi/charts")
charts_dir.mkdir(parents=True, exist_ok=True)

def _bar(ax, names, vals, title, ymax=None):
    real_pos = [v for v in vals if isinstance(v, (int, float)) and v > 0]
    if not real_pos:
        ax.text(0.5, 0.5, "no real data\n(set API key + re-run cell 12)",
                ha="center", va="center", transform=ax.transAxes,
                color="#aa0000", fontsize=11)
        ax.set_title(title, fontsize=11, color="#aa0000")
        ax.set_xticks([])
        ax.set_yticks([])
        return
    disp = [0.0 if v is None else float(v) for v in vals]
    colors = ["tab:blue", "tab:orange", "tab:green", "tab:red", "tab:purple",
              "tab:brown", "tab:pink", "tab:olive", "tab:cyan"]
    bar_colors = [colors[i % len(colors)] if (isinstance(vals[i], (int, float)) and vals[i] > 0)
                  else "lightgray" for i in range(len(names))]
    bars = ax.bar(names, disp, color=bar_colors)
    for i, (bar, v) in enumerate(zip(bars, vals)):
        if v is None or not isinstance(v, (int, float)) or v <= 0:
            bar.set_hatch("//")
            bar.set_edgecolor("gray")
    ax.set_title(title, fontsize=11)
    ax.tick_params(axis='x', rotation=30, labelsize=9)
    if title.endswith("(↓)") and len(real_pos) >= 2:
        ax.set_yscale("log")
    if ymax is not None and real_pos:
        ax.set_ylim(0, max(ymax, max(real_pos) * 1.1))

names = sorted(real_rows.keys())
PANELS_4DIM = [
    ("accuracy",        "Accuracy ↑",            1.05),
    ("p50_s",           "p50 latency (time) ↓",  None),
    ("token_in",        "Token in ↓",            None),
    ("cost_usd_total",  "Cost USD (price) ↓",    None),
]

fig, axes = plt.subplots(2, 2, figsize=(13, 9))
for idx, (key, title, ymax) in enumerate(PANELS_4DIM):
    r, c = divmod(idx, 2)
    ax = axes[r, c]
    vals = [real_rows[n].get(key) for n in names]
    _bar(ax, names, vals, title, ymax=ymax)
fig.suptitle(f"JevBench 4-dim comparison (real-only data) — {src_label}", fontsize=13)
fig.tight_layout()
chart_path = charts_dir / "14_4dim_compare.png"
fig.savefig(chart_path, dpi=120, bbox_inches='tight')
plt.close(fig)
display(Markdown(f"**4-dim chart saved**: `{chart_path}`\n\n"
                 f"_灰色斜纹 = 该行**无真实数据**(没 key / API 失败 / API 没返回 usage);实色 = 真实数据_"))

# === Step 4: JevBench composite score ranking (1 张总排名图) ===
# JevBench Score = 几何平均 (Intelligence, Calibration, Speed, Cost) in 0-100,
# with low-Intelligence penalty (I<50 → score * (I/50)²). 见 llm_eval.metrics.jevbench_score.
score_rows = []
for name, row in real_rows.items():
    js = row.get("jevbench_score") or {}
    composite = js.get("score")
    axes_dict = js.get("axes") or {}
    score_rows.append({
        "runner": name,
        "composite": composite,
        "intelligence": axes_dict.get("intelligence"),
        "calibration": axes_dict.get("calibration"),
        "speed": axes_dict.get("speed"),
        "cost": axes_dict.get("cost"),
        "n_scorable": js.get("n_scorable"),
        "penalty": js.get("intelligence_penalty_applied", False),
    })

# Sort by composite descending; None last
score_rows_sorted = sorted(
    score_rows,
    key=lambda r: (r["composite"] is None, -(r["composite"] or 0))
)

display(Markdown("# JevBench Score ranking (composite + 4 axes)"))
if not score_rows_sorted:
    display(Markdown("_No jevbench_score yet — re-run cell 12 after setting keys._"))
else:
    rank_lines = ["| rank | runner | composite | intelligence | calibration | speed | cost | notes |",
                   "|---|---|---|---|---|---|---|---|"]
    for i, r in enumerate(score_rows_sorted):
        composite = r["composite"]
        composite_str = f"{composite:.1f}" if composite is not None else "—"
        note = "⚠️ I<50, penalty applied" if r["penalty"] else ""
        rank_lines.append(
            f"| {i+1} | {r['runner']} | {composite_str} | "
            f"{_fmt(r['intelligence'])} | {_fmt(r['calibration'])} | "
            f"{_fmt(r['speed'])} | {_fmt(r['cost'])} | {note} |"
        )
    display(Markdown("\n".join(rank_lines)))

# Bar chart: composite score (sorted)
fig, ax = plt.subplots(figsize=(11, max(4, 0.5 * len(score_rows_sorted) + 1)))
rnames = [r["runner"] for r in score_rows_sorted]
comps = [r["composite"] for r in score_rows_sorted]
real_pos = [v for v in comps if v is not None]
if real_pos:
    colors = ["#2a9d8f" if not r["penalty"] else "#e76f51" for r in score_rows_sorted]
    bars = ax.barh(rnames, [v if v is not None else 0 for v in comps], color=colors)
    for i, (bar, v) in enumerate(zip(bars, comps)):
        width = v if v is not None else 0
        ax.text(width + 1, bar.get_y() + bar.get_height()/2,
                f"{width:.1f}" if v is not None else "—",
                va='center', fontsize=10)
    ax.set_xlim(0, 110)
    ax.set_xlabel("JevBench composite score (0-100)")
    ax.set_title(f"JevBench score ranking (real-only) — {src_label}", fontsize=13)
    ax.invert_yaxis()
    fig.tight_layout()
else:
    ax.text(0.5, 0.5, "no real jevbench_score data\n(set API key + re-run cell 12)",
            ha="center", va="center", transform=ax.transAxes, color="#aa0000", fontsize=12)
    ax.set_xticks([])
    ax.set_yticks([])
rank_chart = charts_dir / "14_main_score.png"
fig.savefig(rank_chart, dpi=120, bbox_inches='tight')
plt.close(fig)
display(Markdown(f"**Main-score chart saved**: `{rank_chart}`\n\n"
                 f"_绿色 = 4-axis 满分;红色 = Intelligence<50 触发了 (I/50)² 折扣_"))

# === Step 5: 持久化合并结果 ===
merged_path = Path("runs/notebook-demo/multi/compare_merged.json")
merged_payload = {
    "rows": real_rows,
    "n_real": n_real,
    "label": src_label,
    "ranking": score_rows_sorted,
}
merged_path.write_text(
    json.dumps(merged_payload, ensure_ascii=False, indent=2),
    encoding="utf-8",
)
display(Markdown(
    f"\n所有产物:\n"
    f"- `{merged_path}`(合并视图,仅真实数据,{n_real} 行)\n"
    f"- `runs/notebook-demo/multi/charts/14_4dim_compare.png`(4 维横评:accuracy / p50 latency / token_in / cost)\n"
    f"- `runs/notebook-demo/multi/charts/14_main_score.png`(JevBench 总分排名图)"
))

# === Step 6: Task coverage — 列出每个 provider 实际跑了哪些题 / 没跑哪些 ===
# 加载真实 task 集(默认是 tasks/public_all.jsonl = 231 题)
from llm_eval.task import load_tasks
_coverage_tasks = load_tasks(TASKS_PATH)
_all_task_ids = [t.id for t in _coverage_tasks]
display(Markdown(f"## Task coverage — 跑了哪些 / 没跑哪些\n\n"
                 f"全集: **{len(_all_task_ids)} tasks** (`{TASKS_PATH}`)"))

if real_rows:
    coverage_lines = ["| runner | total | ok ✓ | invalid ✗ | error ✗ | unattempted | covered task_ids |",
                      "|---|---|---|---|---|---|---|"]
    for name in sorted(real_rows.keys()):
        # Read this runner's results.jsonl
        rs_path = Path("runs/notebook-demo/real") / name / "results.jsonl"
        # if symlink, follow
        statuses = {"ok": [], "invalid": [], "error": [], "unattempted": []}
        if rs_path.exists():
            with rs_path.open() as f:
                for line in f:
                    try: r = json.loads(line)
                    except: continue
                    s = r.get("status", "?")
                    if s in statuses:
                        statuses[s].append(r.get("task_id", "?"))
        covered = sorted(set(sum(statuses.values(), [])))
        uncovered = sorted(set(_all_task_ids) - set(covered))
        covered_str = ", ".join(covered[:8])
        if len(covered) > 8:
            covered_str += f" … (+{len(covered)-8} more)"
        coverage_lines.append(
            f"| {name} | {sum(len(v) for v in statuses.values())} | "
            f"{len(statuses['ok'])} | {len(statuses['invalid'])} | "
            f"{len(statuses['error'])} | {len(statuses['unattempted'])} | "
            f"{covered_str or '—'} |"
        )
    display(Markdown("\n".join(coverage_lines)))

    # 对每个 runner 单独显示 ok / invalid / error / unattempted 的 task_id
    display(Markdown("### 哪些跑了 / 没跑 (per runner)"))
    for name in sorted(real_rows.keys()):
        rs_path = Path("runs/notebook-demo/real") / name / "results.jsonl"
        statuses = {"ok": [], "invalid": [], "error": [], "unattempted": []}
        if rs_path.exists():
            with rs_path.open() as f:
                for line in f:
                    try: r = json.loads(line)
                    except: continue
                    s = r.get("status", "?")
                    if s in statuses:
                        statuses[s].append(r.get("task_id", "?"))
        covered = sorted(set(sum(statuses.values(), [])))
        uncovered = sorted(set(_all_task_ids) - set(covered))
        ok_n = len(statuses['ok'])
        err_n = len(statuses['error'])
        unat_n = len(statuses['unattempted'])
        inv_n = len(statuses['invalid'])

        md_lines = [f"#### `{name}` — covered **{len(covered)}/{len(_all_task_ids)}**"]
        md_lines.append(f"- ✅ ok ({ok_n}): {', '.join(statuses['ok'][:5])}{' …' if ok_n > 5 else ''}")
        if inv_n:
            md_lines.append(f"- ⚠️ invalid ({inv_n}): {', '.join(statuses['invalid'][:5])}{' …' if inv_n > 5 else ''}")
        if err_n:
            md_lines.append(f"- ❌ error ({err_n}): {', '.join(statuses['error'][:5])}{' …' if err_n > 5 else ''}")
        if unat_n:
            md_lines.append(f"- ⏸️ unattempted ({unat_n}): {', '.join(statuses['unattempted'][:5])}{' …' if unat_n > 5 else ''}")
        if uncovered:
            uncovered_str = ', '.join(uncovered[:5])
            if len(uncovered) > 5:
                uncovered_str += f' … (+{len(uncovered)-5} more)'
            md_lines.append(f"- 🕳️ **没跑** ({len(uncovered)}): {uncovered_str}")
        if not uncovered:
            md_lines.append("- ✅ 全部题都跑了")
        display(Markdown("\n".join(md_lines)))
else:
    display(Markdown("_No real runner data — skip coverage report._"))

Cell 15 — 故障模式与设计纪律

故障模式与设计纪律

llm_eval runner 的不变量:

规则 做什么 不做什么
不重试 401/403/429 立即停跑 不 sleep 重试、不换 endpoint 重试
不重映射 模型答非所问时直接当 invalid 不"补"成合法分布、不替模型猜
不修补 malformed 分布记 invalid 并计 0 分 不重 parse、不删多余字段
reserve-before / settle-after 真扣费先 reserve,settle 用实测 余额超 cap 时请求不发
20 连续错就停 API 不可用时早停 不让一次跑完烧光预算
key 仅 env 不落盘不 echo --key-env '' 时连 Authorization 都不发

三个最常见的"看着不对"现象

  1. schema_validity 偏低 模型有时返了概率但 keys 不完整(漏 label)→ 该题记 invalid 并计 0。 多数情况下:模型 rounding 把 9 项题舍入到 0.999,落在 2% 容忍带里被 renorm 救回; 真正低的:模型 prompt 让它"用自然语言回答"了。

  2. accuracy 高但 brier / ece 也高 答对了但置信度没校准——自信错的也自信对。 native 分布(TypeSafe 的 Jev、djev 等)通常校准好;verbalized(让通用 LLM 写概率)容易翻车。

  3. 大量 error 记录 API 返了非 200 / 空 content。DeepSeek-flash 这种 flash 变体观察到 ~28% 空响应率; 主入口(deepseek-chat)通常稳定。

预算 ledger 怎么看

runs/notebook-demo/real/<provider>/ledger.jsonl 是 reserve/settle 流水。 任何 unsettled 的 reservation 都计为已花——中断不退款。

Cell 16 — 下一步 + CLI 速查

下一步

添加新 provider

只需在 llm_eval/adapters/__init__.py 的 PROVIDER_TABLE 里加一条(preset),并在 llm_eval/adapters/ 加个 8 行 thin shim 继承 OpenAICompatAdapter。无需改 runner。

添加新 metric

在 llm_eval/metrics.aggregate() 加聚合逻辑;在 llm_eval/summarize._PUBLIC_FIELDS 加白名单键——两者缺一不可,否则公共导出里看不到。

添加新题族

在 tasks/ 下放 JSONL,每行一个 task record(用 llm_eval.task.Task 的字段)。在 scripts/convert_jevbench.py 的 _TOPIC_HINTS 里给新 family 配 topic。

CLI 速查

llm-eval run        跑单个 (adapter, model) 对一组题
llm-eval summarize   把 results.jsonl 汇总成 summary.json
llm-eval multi-run   一次配置多个 runner 并行(按 ≤5 一批分批)
llm-eval compare     跨 runner 对比表(markdown + json)

每条命令加 --help 看完整参数。

跑更多题

  • tasks/smoke.jsonl — 6 题(秒级)
  • tasks/public/easy.jsonl — 48 题(默认)
  • tasks/public_all.jsonl — 231 题(≈$0.04-0.05/provider)

参考

📑 Jev Cookbook:System One 判断模型实战教程

1 第 1 章:认识 Jev:模型、上手与场景 2 第 2 章:核心概念总览 3 第 3 章:System One:判断的核心心智模型 4 第 4 章:状态:让判断连续可追溯 5 第 5 章:原语:Choice、Score 与 Noul 6 第 6 章:置信度:让概率可信 7 第 7 章:应用构建:从原语到完整系统 8 第 8 章:架构模式 9 第 9 章:实战指南总览 10 第 10 章:自一致性 · Noul 11 第 11 章:自一致性 · Choice 12 第 12 章:并行提问 13 第 13 章:重排序 14 第 14 章:逐行语义搜索 15 第 15 章:结构恢复 16 第 16 章:函数调用 17 第 17 章:技能推荐 18 第 18 章:实体对齐 19 第 19 章:RAG 段落分类 20 第 20 章:引用核查 21 第 21 章:LLM 防护栏 22 第 22 章:SDE 级联 23 第 23 章:日期抽取 24 第 24 章:预解析值抽取 25 第 25 章:层级分类 26 第 26 章:自动研究特征发现 27 第 27 章:基于置信度的分类 28 第 28 章:智能家居实验 29 第 29 章:模型评测总览 30 第 30 章:模型评测实验 31 第 31 章:Laya vs Jev 对比基准 32 第 32 章:JevBench:LLM 评测体系 33 第 33 章:智能家居应用实战 34 第 34 章:Jev-Mem 研究总览 35 第 35 章:Jev-Mem 缩放实验 36 第 36 章:Jev-Mem 完整走查 37 第 37 章:Agent 集成总览 38 第 38 章:Pi 集成实验 39 第 39 章:DSH 决策协作 40 第 40 章:本地模型总览 41 第 41 章:本地模型介绍与对比 42 第 42 章:中文数据集构建方案 43 第 43 章:微调指南 44 第 44 章:RLCD 原理与实验优化 45 第 45 章:中文数据集构建实验 46 第 46 章:中文 Head 微调实验 47 第 47 章:全量 v2 微调实验 48 第 48 章:知识库
← 返回本书大纲