Metadata-Version: 2.4
Name: cowork-judge
Version: 1.10.0
Summary: Cowork-Judger: unified rubric LLM judge for GDP/WLE/DR/ppt datasets
Author: Cowork Platform
License: Proprietary
Classifier: Programming Language :: Python :: 3
Classifier: Operating System :: OS Independent
Requires-Python: >=3.10
Description-Content-Type: text/markdown
Provides-Extra: pptx
Requires-Dist: python-pptx; extra == "pptx"
Requires-Dist: lxml; extra == "pptx"

# 统一 rubric 判分器

凡是靠 `tests/rubric.json` 让大模型逐条打分的 instance，判分代码都用这一份
`tests/judge.py`，在这个目录改，改完拷到各个 instance 里去。

这个目录本身就摆成一个 harbor instance 的样子：把 `tests/` 下那三个文件拷进目标 instance 的
`tests/`，再把 `task.toml` 里 `[verifier.env]` 那段照抄过去，就算装好了。

这三个文件各干什么：

| 文件 | 干什么 |
|---|---|
| `test.sh` | harbor 调的入口。收集产物、归档、然后调 `judge.py` |
| `judge.py` | 真正打分的。**只有这个文件需要维护** |
| `reward_exit_message.py` | 判分器自己崩了的时候，从日志里反推一个错误码 |

## 目录里有什么

```
judger/
├── task.toml                  instance 示例，只有 [verifier.env] 那段要照抄
├── README.md                  本文件
├── CHANGELOG.md               每一版改了什么、为什么改（带实测数据）
├── REVIEW.md                  2026-08-16 对 1.2.0 做的一次代码评审，还有 1 个 P0 没修，见文末
├── tests/                     ← 要拷进 instance 的就是这里的东西
│   ├── judge.py               判分器，唯一需要维护的文件
│   ├── reward_exit_message.py 错误码兜底（照抄 FIN-0071 的原件，一个字没改）
│   ├── test.sh                harbor 入口（FIN-0071 原件 + 2 处改动，见 CHANGELOG 1.0.0）
│   ├── pptx_evidence.py       ppt 取证库，只装进 GDP ppt 那 126 个
│   ├── rubric.json            示例文件，各 instance 用自己的
│   ├── judge_instruction.md   示例文件，各 instance 用自己的
│   └── task_prompt.md         示例文件，各 instance 用自己的
└── selftests/                 自测，跑法见文末
```

## 怎么装

单个 instance：

```bash
cp judger/tests/{judge.py,reward_exit_message.py,test.sh} <instance>/tests/
chmod 755 <instance>/tests/{judge.py,test.sh}
# 再照 judger/task.toml 的 [verifier.env] 把目标 instance 的对齐，然后重新打包传 OSS
```

几件容易踩的事：

- **这三个文件是打进每个 instance 的 `content.tgz` 的**。改完不重新打包上传 OSS，
  线上跑的还是旧版本——本地改了等于没改。
- **`rubric.json` / `judge_instruction.md` / `task_prompt.md` / `expected_output/` 不要动**。
  `judger/tests/` 下那几个只是示例，别拷过去把 instance 自己的覆盖了。
- **判分说明一个字都不用改**。（1.2.0 曾往里加过严格交付的说法，1.3.0 已经逐字节退回原文。
  装机脚本遇到被 1.2.0 改过的实例，会从包内 `.pre-unify.<md5>.bak` 还原。）
- **原来的判分脚本备份成 `<名字>.pre-unify.<md5>.bak` 留在包里**。OSS 那个桶只能写，
  下载会 403，所以回滚点只能放在包内。
- 装完 `test.sh` 调的是同目录的 `judge.py`。原来 `test.sh` 里写死 `python3 tests/agent_judge.py`
  的实例会一起被换掉——`test.sh` 也是要替换的文件之一，不是保留文件。

## 改一版要重新装哪些包

**判断标准很简单：`judger/tests/judge.py` 变了没有。**

| 改了什么 | 要重新装 |
|---|---|
| `judge.py`（`VERSION` 会跟着变） | **4312 个**（GDP 1229 + WLE 1157 + DR 1800 + ppt 126）+ long_horizon 204 个 |
| 只改 `pptx_evidence.py` / `instruction_ppt.md` / ppt 的 env | 只有 ppt 那 126 个 |
| 只改 `[verifier.env]` 里已声明的变量的值 | 不用重装，rollout 侧直接覆盖就行 |

`judge.py` 一变，`VERSION` 就和包里的对不上（校验脚本的期望版本号是直接读 `judge.py` 里的
`VERSION` 的），所以必须全部重装。

long_horizon 那 204 个（`long_horizon_0707_fix`）**不走批量装机那条链**：每个包自带一份
`judge.py`，没有单一来源，装机和校验脚本都不认它们。同源的改动**两边都要打**，
否则线上一半新一半旧。

## 打分怎么算

记第 i 条 rubric 的权重 `w_i`、判词命中 `hit_i`（没判到的按 `hit=false`），
`gate_i` = 它 `depends_on` 的行是否全部命中（没写 `depends_on` 时恒为真），则

```
分母 W = Σ w_i                                  w_i > 0 的行（含 hurdle 行）
分子 E = Σ w_i · lvl_i · [gate_i]               w_i > 0 且非 hurdle
       + Σ w_i · [hit_i]                        w_i < 0 且非 hurdle（w_i 是负数，这项在扣分）
fatal  = 存在 hurdle 行且 hit_i = true

lvl_i  = 判分模型给的档位（分级项，`"type": "gradient"`，取值只能是 levels 的键）
       = 1 if hit_i else 0                      二值项（也是分级项漏回 level 时的兜底）

score_normalized = 0                            若 fatal
                 = clamp(E / W, 0, 1)           否则

reward = score_normalized                       JUDGE_REWARD_MODE=continuous（默认）
       = 1.0 if 判分成功 and 过线 else 0.0       JUDGE_REWARD_MODE=binary（迁移期才用）
```

`task_score` 就是 `reward`，原样透给 harbor。分子分母都在 `judge.py:_compute()`，
对应自测在 `selftests/test_score.py`。

逐条说明：

- **`w > 0`** 加分项：命中且过 gate 才加 `w`；不管判没判到都**记进分母**。分级项加 `w × level`。
- **`w < 0`** 扣分项：命中就从分子里减 `|w|`，**不进分母**。所以扣分只压已得分、不改天花板，
  扣成负数会被 clamp 抬回 0。扣分项**不看 gate**，依赖没满足也照扣。
- **`hurdle: true`** 致命门槛项：无论权重多少，本行**永不产生分子**，命中一条整题直接 0 分。
  但权重 > 0 的门槛项仍然**留在分母里**（代码没排除），等于白占天花板 —— 门槛项应当写
  `weight: 0`，线上 3124 条门槛项都是这个形态。
- **`depends_on`** 是 DAG gate：依赖没全命中 → 该行不计分，但**分母保留**，否则模型可以靠
  不做前置步骤来抬分。依赖 id 悬空（指向不存在的行）按未满足处理（fail-closed）。
- 一条正权重都没有、且**没有** hurdle 行时，权重为 0 的行按等权 `1.0` 兜底（对齐 GDPval 原实现的
  `if not primary: primary = criteria`）；有 hurdle 就不兜底，否则命中门槛反而变成加分。
  兜底之后依然没有正权重 → `judge:scorer_error`，整题不出分。

阈值（`JUDGE_PASS_THRESHOLD`）默认**不参与算分**，只写进 `extra_fields.passed` 供统计过线率。
`JUDGE_REWARD_MODE=binary` 是迁移期才用的开关，见下文。

### 门槛项的 hit 该怎么读（1.7.0）

门槛项的描述几乎都是**负向**的（"最终交付缺失或为空"），所以 `hit=true` 的意思是
"确实发现了这个问题"，不是"这条要求达到了"。判分模型很容易读反。

原来指望判分说明来讲清楚，但说明里只写了"weight > 0 加分、< 0 扣分"，而**线上 3124 条门槛项
全都是 `weight == 0` 且 `hurdle == true`**，两条规则一条都不覆盖它，模型只能按正向猜——
交付明明合规也判 `hit=true`，整题 0 分。实测这种假 0 占了命中门槛项的 job 的 28%。

1.7.0 的做法：`judge.py` 发给模型的那份 block `rubric.json` 里，除了原有字段，再补两个
**派生**字段（不是标注字段，标注文件里没有）：

- `polarity`：`negative` / `positive`，由 `weight` 符号 + `hurdle` 推出来。
- `hit_means`：一句话写清这条的 `hit=true` 到底是什么意思。negative 那档明确写
  "要求已满足、没发现这个违规 → 必须 `hit=false`"。

算分代码一个字没改，纯粹是给模型的输入里多说一句。改前后的实测对比见 CHANGELOG 1.7.0。

### 分级项 `"type": "gradient"`（1.9.0）

标注侧新增的第二种 rubric 形态：不是「做到/没做到」，而是「做到几成」。

```json
{"id": "R06", "description": "DCF 用的 WACC 应落在 [10%,12%]，越接近得分越高",
 "tag": "内容质量-数值与计算准确性", "criterion_type": "Objective",
 "criterion_necessity": "Explicit", "type": "gradient", "weight": 7.0,
 "levels": {"1": "落在 [10%,12%] 内", "0.75": "偏离 ≤±1%", "0.5": "偏离 ±1%~±3%",
            "0.25": "偏离 ±3%~±5%", "0": "未给出或偏离 >±5%"}}
```

- **`levels` 的键就是该档的得分比例**，必须是 0~1 的数、至少两档。得分 = `weight × level`，
  所以分级项和二值项可以在同一份 rubric 里混用，分母口径完全不变（还是 `Σ w`）。
- 档位表**原样发给判分模型**（block `rubric.json` 里带 `levels` + `level_choices` +
  分级版 `hit_means`），并且 `level` 字段是**结构化输出 schema 里 required 的**——
  这是关键的接线点：4186 个已铺包各自带着一份只讲二值的 `judge_instruction.md`，
  靠指令文件讲不通，靠 schema 才强制得住。
- 模型回了表外的数（0.8、0.9）会**吸附到最近的合法档位**，并列时取低的一档（宁严不宽）；
  漏回 `level` 则退回按 `hit` 算（满档/零分）并照常出分——分级项判不了也不能让整题没分。
- `hurdle` 行和 `w < 0` 行**不吃 `levels`**：门槛是「有没有踩」、扣分是「有没有犯」，
  没有「做到几成」这回事。写了也按二值判。
- 结果里分级项多一个 `results[].level`（和 `criteria[].level_raw`，模型回的原值），
  二值项的输出形状**一个字不变**。
- 顺带把 clawgym 那批的 `scores` 档位表（`{"0":…,"0.25":…,…}`，无 `weight`）也接上了：
  它没有权重 → 走「全零权重按等权 1.0 兜底」 → 分数恰好等于原 `clawgym_grader` 的
  「各条分级得分算术平均」（等价性用例 `t_clawgym_scores_alias_equals_original_mean`）。
  1.8.x 之前这批被按二值判，0.75 档当 0，分数系统性偏低。

## 环境变量

**"线上可调" 那一列很关键**：harbor 只会注入 `[verifier.env]` 里声明过的变量，
没声明的等于线上不存在，改代码默认值也只对新装的包生效。
标 ✗ 的那几个只在本地调试有用。

本仓 `task.toml` 只声明了凭据和常动的那几个旋钮，标「模板未声明」的默认值等价、
但**新装的包线上调不了**，要调先把那行加回 `[verifier.env]`。已铺的 4186 个包里
这些变量当初都声明着，对它们仍然线上可调。

| 变量 | 默认 | 线上可调 | 说明 |
|---|---|---|---|
| `JUDGE_API_KEY` / `JUDGE_BASE_URL` | 无 | ✓ | 判分模型的凭据，**必须显式给**。少任一个就直接以 `judge:no_credential` 退出，不会拿不到凭据还接着跑 |
| `OPENAI_API_KEY` / `OPENAI_BASE_URL` | 无 | ✓ | 老配置兜底，`JUDGE_*` 优先。解析结果会**回写进两套变量名**，codex 的 `config.toml` 用哪个 `env_key` 都取得到 |
| `JUDGE_MODEL` | `gpt-5.5` | ✓ | 判分模型名 |
| `JUDGE_BATCH` | `4` | ✓ | 一个会话判几条。块数 = 会话数 = 请求量，代价是**一个块超时就丢这么多条**。1.5.1 从 8 减到 4、1.8.1 回到 8、1.8.2 又回到 4（8 条一块把长 rubric 实例判崩，覆盖率 0.980 → 0.903），三条 CHANGELOG 都要看 |
| `JUDGE_CONCURRENCY` | `2` | ✓ | 同时跑几个块。**这是全 fleet 峰值 RPM 的直接倍数**，1.8.0 从 4 降到 2（ppt 那 126 个还是 `8`，没跟着改） |
| `JUDGE_PER_TIMEOUT` | `150` | ✓ | 单次调用超时（秒）。单次耗时 ≈ 固定开销（起会话 + 读完所有产物，实测 ~100s）+ 条数 × ~3s。ppt 那族一条 rubric 的边际成本是几十秒，所以单独装 `240` |
| `JUDGE_MAX_TRIES` | `6` | ✓ | 一个块最多试几次。**别调小**：ppt 上试过 `1`，覆盖率反而从 0.825 掉到 0.722，见 CHANGELOG `1.6.0-ppt.2` |
| `JUDGE_RESPLIT` | `1`（= 开） | ✓ | 块失败后拆成单条重判。1.8.0 关掉（怕放大限流）、1.8.3 **重新打开**：块失败的实因是单次调用越过 `PER_TIMEOUT`，不是限流。**必须和 `DEADLINE=1800` 一起开**，只开这个是把块预算漏换成撞总墙漏 |
| `JUDGE_DEADLINE` | `1800` | ✓ | 判分总时长上限（秒，从 `judge.py` 启动算）。超了就停，已判到的照常算分。1.8.0 从 780 提到 1200（配合 K=2 和 180s 抖动），1.8.3 提到 1800（给 resplit 留时间）|
| `JUDGE_START_JITTER` | `180` | 模板未声明 | 第一波每个块各自随机等 0~N 秒再发请求，摊平瞬时速率。1.8.0 从 30 提到 180 |
| `JUDGE_RETRY_BACKOFF_MAX` | `20` | 模板未声明 | 被限流时的最长等待秒数，`uniform(1, min(20, 2·3^(n-1)))` = 2→6→18→20 |
| `JUDGE_REWARD_MODE` | `continuous` | ✓ | `continuous` = reward 就是连续分（默认）；`binary` = 按阈值压成 0/1。认不出的值按 `continuous` 处理并告警 |
| `JUDGE_PASS_THRESHOLD` | `0.75` | ✓ | `continuous` 下只写统计字段；`binary` 下它决定分数 |
| `JUDGE_PASS_COMPARE` | `gte` | 模板未声明 | `gt` / `gte`。开 `binary` 迁移时要连比较符一起对齐，见下文 |
| `JUDGE_LENIENT_DELIVERY` | 空（= 严格） | 模板未声明 | 设真值 → 把写在 `/app` 根和 `/output` 的产物也搬进来判分。默认严格：只认 `/app/output` |
| `JUDGE_CHAT_FALLBACK` | `1`（= 开） | 模板未声明 | `output/` 空但对话里有答案 → 照常判分。`0` 恢复 1.2.0 的硬 0 |
| `JUDGE_EVIDENCE` | `180` | 模板未声明 | 交付物是 `.pptx` 时，先离线抽一份结构 + 页图给判分模型的预算秒数（`0` = 关）。抽一次所有块共用，见下文 |
| `JUDGE_METRIC_ID` | `rubric_llm_judge` | 模板未声明 | 写进 `result.json`，下游按它归类 |
| `JUDGE_AUX_FILES` | 无 | ppt 那 126 个声明了 | 判分说明要 `import` 的辅助模块（逗号或空格分隔）。ppt 的 `pptx_evidence.py` 就靠它。不声明就不拷，模型 import 必失败 |
| `JUDGE_BACKEND` | `codex` | ✗ | `codex` / `claude` |
| `JUDGE_INSTRUCTION_FILE` | 无 | ✗ | 判分说明的路径。不给就按 `judge_instruction.md` → `judge_instructions.md` → `rubric_instruction.md` → `judge_prompt.md` 找，都没有就用内置的默认说明并告警 |
| `JUDGE_SYSTEMIC_FRACTION` | `0.4` | ✗ | 未判比例超过它才算系统性失败 |
| `JUDGE_SYSTEMIC_MIN` | `2` | ✗ | 且未判条数至少这么多 |
| `JUDGE_SKIP` | 无 | ✗ | 设真值 → 只归档产物不判分，`reward.txt` 写 `SKIPPED` |

## 几个关键行为

### 产物写错地方 = 没交付；但"没写文件" ≠ "什么都没做"

题面对交付路径是硬要求，三批 yuanrang 数据一字不差（`/app/output/result.md`、
`把唯一的最终产物写入 /app/output/`），`gdpval0701` 是同一句的英文版。所以默认口径是
**只认 `/app/output` 下的非空文件**。写到 `/app` 根或 `/output` 的**不算分**，
但会归档到 `/logs/artifacts/output/_stray_not_scored/`，不然"模型到底写哪了"查不出来。

但这批数据里有一批题**题面本身就要求答在对话里**，和 harbor 的样板要求直接冲突。数过：

| 分类 | 实例数 | rubric 里**一条**文件性要求都没有 | 文件性权重占比中位数 |
|---|---|---|---|
| 纯对话题（题面明确不让建文件） | 61（WLE 48 + GDP 13） | 31 个（51%） | **0.0%** |
| 混合题（要交文件 + "洞察请在文本直接回复"） | 49（GDP 45 + WLE 4） | 11 个（22%） | 29.3% |

那 31 个纯对话题，一刀切给 0 分丢掉的是**全部**能判的权重：rubric 一条文件性要求都没提，
每条都能从对话回复里判出来，结果一条没判就给 0。所以 1.3.0 改成**让 rubric 条目自己决定
看哪儿的证据**：

- 判分说明**保持原样**。原文本来就写着"纯文本题（`output/` 为空）以 `model_response_text.txt`
  为判据"，1.2.0 把它改坏了，1.3.0 逐字节退回去。
- **不需要造假文件**。`judge.py` 无条件把 agent 最后那句回复写进判分工作区的
  `model_response_text.txt`，判分模型本来就看得到。反过来，把回复写成 `output/` 下一个文件
  会让"`output/` 为空"这个前提不成立，和刚退回去的原文打架。
- 所以 `JUDGE_CHAT_FALLBACK=1`（默认开）放宽的只是**这道判断本身**：没交文件、但对话里有答案
  → 照常判分，并记一笔。
- **文件性条目该扣的分不用另加机制**。判分说明第 1 条就是"有 `output/` 交付文件时优先以其为判据"，
  真·文件题只答对话，点名要文件的那些条目自然判不中。于是罚分是按权重成比例的
  （混合题中位数 29.3%），比一刀切 0 分准得多，也不用给每个实例配开关。
- `details.delivery.from_chat` / `extra_fields.delivery_from_chat` 会记下来，
  下游能把"真交付"和"只答对话"分开统计。

**两个渠道都空**（文件没写、对话里也没答案）才是真的什么都没交：

- **直接给 0，一次判分 API 都不发**。没有产物就没有证据可命中，逐条判一遍结果一样，
  白花一轮钱。`extra_fields.judge_called = false` 标记这件事。
- **`status` 仍是 `success`，不给 `exit_code`**。没交付是**任务没做完**，不是判分坏了。
  给了错误码，下游会按"基础设施故障"把这条过滤掉，等于没交付还免罚。这个 0 必须原样进训练集。
- 原因写进 `exit_reason`，`extra_fields.delivery_missing = true`，
  `details.delivery` 记下 `files` / `missing` / `from_chat` / `stray`。

想回到老口径：`JUDGE_CHAT_FALLBACK=0` 恢复 1.2.0 的"没写文件就 0 分"；
`JUDGE_LENIENT_DELIVERY=1` 恢复原来的宽松路径（并且会连带打开纯文本题那条）。
已部署的实例有 2957 个，所以这两处都做成可配置而不是写死。

### 少判了几条也照常出分，但要留下痕迹

口径是：**没判到的 rubric 按未命中算、照常出分，只有系统性失败才标 `exit_code`**。
好处是判分端抖一下不会丢掉整条 rollout，代价是**局部降级原来一点痕迹都没有**——实测
`group-0a04ac64`（GDP 全量）3582 个真判过的 job 里，有 **1862 个（52%）少判但没到门槛，
平均丢 20.3% 的 rubric**（reward 均值 0.400，满覆盖的是 0.448），分被静默压低，
下游按 `exit_code` 也筛不出来。所以 1.4.0 起 `extra_fields` 里恒定写这几个字段：

| 字段 | 含义 |
|---|---|
| `judge_coverage` | 判到的比例。想只用满覆盖的样本就按 `== 1.0` 筛 |
| `judge_unjudged` | 漏判几条 |
| `judge_degraded` | 有没有降级 |
| `judge_error_kind` | 漏判的真因（限流是 `rate_limit`、撞总时长墙是 `deadline`） |
| `judge_seconds` | 判分花了多少秒（1.6.0 加） |
| `judge_evidence_pages` | 离线取证渲出几页（`0` = 镜像里没有渲图链路，1.6.0 加） |

这几个字段在**所有**出分路径上都写，下游不用处理缺字段。局部降级也写 `exit_reason`，
但**仍然不给 `exit_code`**——分是真的，只是覆盖率不满；给了错误码下游会当故障整条丢掉。

### 超时和限流

**三层时间上限**：单次调用 `PER_TIMEOUT` ⊂ 单块 `2 × PER_TIMEOUT` ⊂ 全局 `DEADLINE`。
中间那层是**推出来的，不给单独的参数**——并发和重试的参数堆多了没人看得懂。

能这么推，是因为两种失败的代价本来就不一样：超时失败一次就烧掉一个 `PER_TIMEOUT`
（所以块预算天然只够试 2 次），限流是秒级失败、不吃预算（所以 `MAX_TRIES=6` 才用得上）。

- **只有限流才退避**，等待时间是 `uniform(1, min(JUDGE_RETRY_BACKOFF_MAX, 2·3^(n-1)))`
  = 2→6→18→20 秒。超时类失败不走这个，按 1s 定值重试。
- 限流关键词要包含 dashscope 的原文 `Request rate increased too quickly`（**它不带 429 字样**）。
- 判分端限的是**增速**，尖峰来自"几千个 job 同时进判分、每个又一瞬打出好几个请求"。
  所以真正对症的是 `JUDGE_START_JITTER`（第一波各自随机延迟再发），而不是等更久——
  窄区间的抖动会让全 fleet 近乎同步重试，自己造出第二个尖峰。
- 配额分两种：`quota exceeded`（配额窗口打满）退避重试；`insufficient_quota`（账号没额度）
  立刻放弃，不白烧重试。
- `exit_code` 的取值集合没有变（不给下游造新码），限流归 `judge:api_error`，
  细分看 `judge_error_kind`。
- 撞 `DEADLINE` 时：**墙前判到的照常计分**，剩下的按未命中算，`judge_error_kind=deadline`，
  和端点故障区分开。没这道墙的时候，实测有 job 烧掉 4320s（= 9 × 480s）还在重试。
- **单次调用的成本主要是固定开销**（起会话 + 把长文档和所有产物读一遍，实测 ~100s），
  每条 rubric 的边际成本只有 ~3s。所以块要尽量大（调小 `BATCH` 只会多付几倍固定开销），
  拆出来的单条也**不比整块便宜**，照样给满 `PER_TIMEOUT`。

#### 峰值 RPM 是怎么算出来的（1.8.0 改默认值的依据）

限流是**全 fleet 一起打同一个 workspace** 造成的，单看一个 job 永远看不出问题。
把 pod 里的 `logs/litellm-proxy.log` 一行行数出来，两个 group 的对比：

| | `group-55acd93e` GDPval（炸） | `group-2d2fe36f` AQ（正常） |
|---|---|---|
| 判分方式 | 本判分器，codex agent 判分 | 直接调 API，`/v1/responses` 用了 0 次 |
| 单 job 判分请求数（中位） | 正常 98，失败 **564~638** | 34 |
| job 时长中位 | 736s | 2653s |
| 一个 step 内完成时间跨度(p10~p90) | **13 分钟** | 72 分钟 |
| 峰值同时判分的 job | **576**（step 0 实测 1350） | 77 |
| **估算峰值 RPM** | 正常 ~19.6k，失败态 **~52k** | ~460 |

三条结论直接落到默认值上：

1. **`CONCURRENCY` 就是峰值倍数。** job 数和请求数都不由判分器决定，K 是唯一能直接除的因子，4→2 峰值减半。
2. **`RESPLIT` 是失败态的放大器。** 正常 job 起 10 个 codex 会话，失败 job 起 68~78 个（最高 137）——
   31 条拆 31 个会话、每条又最多 6 次。而块失败本身多半就是限流造成的，重判只会更堵。
3. **抖动窗口要盖住整个爆发。** 一个 step 的几百个 job 在 13 分钟内集体进判分，30s 的窗口
   摊不平，等于没抖；放到 180s 才有意义。

**限流在 HTTP 层看不见**：失败 job 的 proxy log 里 737 个请求全是 200，
限流是在 SSE 流里返回的（`stream disconnected before completion`，codex 侧 168 次 `Reconnecting`）。
所以网关 metrics 上一个错误都没有，只能下 artifact 查。

### ppt 交付物先离线抽一次证据

**块是独立进程**，几 MB 的 `.pptx` 每个块都从零解析一遍，块块撞满超时（ppt split 的
`judge:timeout` 一度 60.3%）。所以 `JUDGE_EVIDENCE` 打开时，`judge.py` 先对每个交付的
`.pptx` 确定性地抽一份 `evidence/<文件名>/{digest.json, pages/slide-NNN.json,
slides/slide-NNN.png}`，再硬链进每个块——**抽一次，所有块共用**。纯 python-pptx + lxml +
LibreOffice，没有 LLM 调用。

预算剩不到 45s 就只抽结构不渲图；缺 LibreOffice 或 PyMuPDF 只少抽一层、不报错，
日志里写清抽了几份、渲了几页。

**取证绝不整份放弃**：被判的 deck 是模型生成的，经常有 python-pptx 解析不了的结构，
所以做了字段级 → 页级 → 整份级三层隔离，连 `Presentation()` 都打不开时照样渲图
（渲图不经过 python-pptx）。失败原因写进 `digest.json` 的 `structure_ok` /
`extract_errors` / `structure_note`，判分说明里也明确写了**抽不到 ≠ 原件里没有**，
让模型自己去解 zip，别据此判未命中。


### codex 自定义端点必须走 config.toml

codex(Rust) **不认** `OPENAI_BASE_URL` / `JUDGE_BASE_URL` 这两个环境变量。所以只要
`JUDGE_BASE_URL` 不是 `api.openai.com`，判分器就往 `$CODEX_HOME/config.toml` 写一段
`[model_providers.judge_endpoint]`（`env_key = "JUDGE_API_KEY"`、`wire_api = "responses"`），
而不是走 `codex login --with-api-key`。routify 的 compatible-mode 端点实测
`/chat/completions` 和 `/responses` 都返 200，用 `responses` 没问题。

### binary 模式是迁移期才用的

`JUDGE_REWARD_MODE=binary` 只在"要和历史 reward 分布逐批对齐"时才开，开了会在 stderr 大声告警。
代价是同一批 rollout 之间的梯度全丢：`GDPval1107` 那 8 条 rollout 的原始分是
53/53、46/53、38/53(×3)、37/53、30/53(×2)（= 1.0 / 0.868 / 0.717 / 0.698 / 0.566），
在 `binary 0.75 gte` 下只有 2 条给 1，另外 6 条全被压成同一个 0。

要开的话**比较符也得一起对齐**：原来各批口径不一致（DR/GDP `>=0.75`、WLE `>0.70`、
GDP other_file 和 gdpval_v3 multi_files `>=1.0`）。恰好等于阈值的样本在 `gt` 和 `gte`
两种写法下 reward 是相反的。

## 自测

```bash
cd Cowork-RL-Data-Pipeline/judger
for t in test_parse test_score test_degrade test_harbor test_pptx_evidence; do
  python3 selftests/$t.py
done
```

当前 **111 例全过**（1.8.0）：

| 文件 | 例数 | 测什么 |
|---|---|---|
| `test_parse.py` | 15 | 模型输出的判词解析（五级还原：整体 JSON → 剥代码块 → 配平扫描 → 逐条抢救 → 兜底） |
| `test_score.py` | 44 | 算分规则、reward 语义、系统性失败门槛、限流退避、极性字段、gradient 分级项（含与 clawgym 原口径的等价性）|
| `test_degrade.py` | 46 | 判分坏成各种样子的时候还能不能出分（用假 codex 跑真 `main()`），外加交付判断、覆盖率字段、gradient 端到端 |
| `test_harbor.py` | 13 | 真的 `bash tests/test.sh` 跑一遍，只把 `codex` 换成假的，验三个文件之间调用对不对 |
| `test_pptx_evidence.py` | 5 | 取证的三层容错：单个字段坏 / 一页坏 / 整份打不开，都不能丢掉整份 |

