Metadata-Version: 2.4
Name: cowork-judge
Version: 1.2.2
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"
Provides-Extra: workflow
Requires-Dist: pymupdf; extra == "workflow"
Requires-Dist: playwright; extra == "workflow"
Requires-Dist: pillow; extra == "workflow"
Requires-Dist: openai; extra == "workflow"
Requires-Dist: python-pptx; extra == "workflow"
Requires-Dist: lxml; extra == "workflow"

# 统一 rubric 判分器

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

1.2.0 起 rubric 分三类（`judge_method` 字段），由 `tests/router.py` 分派：

| `judge_method` | 谁来判 | 例子 |
|---|---|---|
| `code` | 直接执行 `utils.py` 里的函数，**零 LLM 调用** | `locate_deck`、`check_deck_openable` |
| `workflow` | `workflow/<name>/` 下代码写死流程的多步判分器 | `ppt_judge`（视觉 + 内容两轴） |
| `agentic` | 分块交给 coding agent（codex/claude）逐条判 | 1.9.0 及以前的全部 rubric |

**`judge_method` 缺失 = `agentic`。** 存量 4312 个包的 `rubric.json` 一个字不改、
行为逐字节等价，1.2.0 对它们是能力上线而不是行为变更。

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

这些文件各干什么：

| 文件 | 干什么 | 要不要拷 |
|---|---|---|
| `test.sh` | harbor 调的入口。收集产物、归档、探测环境能力、然后调 `judge.py` | ✓ |
| `judge.py` | rubric 规范化、工作区、算分、三件套输出。**唯一决定分数的地方** | ✓ |
| `router.py` | 按 `judge_method` 分派 + 串行编排 + 收分 + per-method 留痕 | ✓ |
| `utils.py` | `code` 型 rubric 的函数库 | ✓ |
| `workflow/{__init__,_contract}.py` | workflow 的加载器与契约（`router.py` 模块级 import） | ✓ |
| `workflow/<name>/` | 某一类任务的 workflow 判分器 | **按需**（用到才拷） |
| `lint_rubric.py` | 打包前的引用完整性检查 | ✗（工具） |
| `reward_exit_message.py` | 判分器自己崩了的时候，从日志里反推一个错误码 | ✓ |

## 装法 A：pip 装包（薄壳 instance，1.2.0+ 推荐新 export 用）

1.2.0 起判分代码发布到 PyPI（包名 `cowork-judge`）。新 export 的 instance 不再物理
拷贝判分代码：`tests/test.sh` 换成薄壳 `templates/test.sh`，运行时装包打分。
存量装机源（物理拷贝）的装法见下文「怎么装」，两条路共用同一份判分代码
（`src/cowork_judge/` 与 `tests/` 同步，CI 有防漂移门禁）。

### 装包

```bash
pip install cowork-judge                 # 核心包（纯 agentic 判分，零第三方依赖）
pip install "cowork-judge==1.2.0"        # 指定版本
pip install "cowork-judge[pptx]"         # + python-pptx/lxml（离线 ppt 取证 pptx_evidence）
pip install "cowork-judge[workflow]"     # + pymupdf/playwright/pillow/openai（ppt_judge）
```

`[workflow]` 只对 rubric 里有 `judge_method: "workflow"` 的 instance 有意义；装完后还要
`playwright install chromium`，判分镜像要装 LibreOffice（`soffice`）——这些是环境能力，
判分器靠 `env_capabilities.json` 探测，缺了只把相关 rubric 记「未判到」，不会误罚 deck。

### 薄壳 instance 集成

1. 拷入口：`cp templates/test.sh <instance>/tests/test.sh`（不拷 judge.py / router.py /
   utils.py / workflow/）。
2. 在 `<instance>/task.toml` 的 `[verifier.env]` 里声明版本 placeholder：

   ```toml
   COWORK_JUDGE_VERSION = "${COWORK_JUDGE_VERSION:-1.2.0}"
   # 可选：
   COWORK_WHEEL_URL     = "${COWORK_WHEEL_URL:-}"      # 内网 OSS PEP 503 index；空 = 公网 PyPI
   COWORK_JUDGE_EXTRAS  = "${COWORK_JUDGE_EXTRAS:-}"   # pptx / workflow / pptx,workflow
   CUSTOM_JUDGE_ENTRY   = "${CUSTOM_JUDGE_ENTRY:-}"    # 自定义判分脚本（相对 tests/ 或绝对路径）
   ```

3. 提交任务时按需注入 `COWORK_JUDGE_VERSION`（rollout 侧覆盖），薄壳据此 pip install，
   并把版本来源写进 result.json 的 `requested_version` / `version_source`。

**版本选择优先级：注入 > placeholder > 内置兜底。** 提交侧注入
`COWORK_JUDGE_VERSION` → `version_source=injection`；只靠 task.toml 的 placeholder →
`version_source=placeholder`；都没给 → 装 `1.2.0`（包 `__version__` 兜底）。

### 薄壳环境变量

| 变量 | 默认 | 说明 |
|---|---|---|
| `COWORK_JUDGE_VERSION` | `1.2.0` | 要装的包版本。**keep in sync with `src/cowork_judge/__init__.py`** |
| `COWORK_JUDGE_VERSION_SOURCE` | `package` | 写进 result.json：`injection`（提交侧注入）/ `placeholder`（task.toml 默认）/ `package`（内置兜底） |
| `COWORK_WHEEL_URL` | 空 | 内网 OSS PEP 503 simple index；空 = 公网 PyPI |
| `COWORK_JUDGE_EXTRAS` | 空 | 追加的 pip extra：`pptx` / `workflow` / `pptx,workflow` |
| `CUSTOM_JUDGE_ENTRY` | 空 | 自定义判分脚本路径（相对 `tests/` 或绝对路径）；失败自动写 0 分三件套 |

判分本身的 `JUDGE_*` 环境变量不变，见下文「环境变量」。

### 发布流程

tag `v*`（如 `v1.2.0`）→ Aone CI（`.aoneci/release.yaml`）：

1. tag 版本必须等于 `src/cowork_judge/__init__.py` 的 `__version__`；
2. **防漂移门禁**：`tests/judge.py` 的 `VERSION` == `__version__`，且
   `tests/judge_instruction.md` 与 `src/cowork_judge/judge_instruction.md` 逐字节一致；
3. 构建 sdist + wheel，泄漏红线扫描（禁止内网地址/凭据进包）；
4. twine 上传公网 PyPI；`release-oss` job 同步推到内网 OSS PEP 503 index。

## 目录里有什么

```
judger/
├── task.toml                  instance 示例，只有 [verifier.env] 那段要照抄
├── README.md                  本文件
├── CHANGELOG.md               每一版改了什么、为什么改（带实测数据）
├── src/cowork_judge/          PyPI 打包源（与 tests/ 同步，CI 防漂移门禁）
├── templates/test.sh          薄壳 verifier 入口（pip 装法用，见「装法 A」）
├── tests/                     ← 要拷进 instance 的就是这里的东西（物理拷贝装法）
│   ├── judge.py               ★ 唯一决定分数的地方
│   ├── router.py              ★ 三类 rubric 的分派与编排
│   ├── utils.py               ★ code 型函数库（白名单 CODE_METHODS）
│   ├── workflow/              ★ workflow 型判分器
│   │   ├── README.md            契约、三条规则、故障归属、ppt_judge 说明
│   │   ├── _contract.py         verdict row 构造器 / 幂平均 / 退避 / na_policy
│   │   ├── stub/                最小示例
│   │   └── ppt_judge/           视觉 + 内容两轴
│   ├── lint_rubric.py         打包前校验（不通过别打包）
│   ├── reward_exit_message.py 错误码兜底（照抄 FIN-0071 的原件，一个字没改）
│   ├── test.sh                harbor 入口
│   ├── pptx_evidence.py       旧的 ppt 取证库，**已废弃**（ppt_judge 自带渲染链路）
│   ├── rubric.json            示例文件，各 instance 用自己的
│   ├── judge_instruction.md   示例文件，各 instance 用自己的
│   ├── task_prompt.md         示例文件，各 instance 用自己的
│   └── workflow_assets/       ← per-instance 的 workflow 资产（如 ppt 的 task_check_dir）
└── selftests/                 自测，跑法见文末
```

**注意三类文件的维护方式完全不同**（判据是「改了会不会让**别的包**的分数变」，
不是「是不是代码」）：

- **算分引擎，改了要全量重装**：`judge.py` / `router.py` / `workflow/_contract.py`。
  它们每个包都有、且决定分数口径，受 `VERSION` 约束。
- **能力库，改了只重装用它的包**：`utils.py` / `workflow/<name>/`。
  **不受 `VERSION` 约束**，纯新增（加一个函数、加一个 workflow 目录）时**零重装**。
- **per-instance 资产，改了只重装那一批**：`rubric.json` / `judge_instruction.md` /
  `task_prompt.md` / `workflow_assets/`。**不用动 `VERSION`**。

各 `workflow/<name>/` 目录是**按需拷**的 —— 一个 word 任务不该带 1500 行
`ppt_judge` 代码和 playwright / LibreOffice 依赖。只有 `workflow/__init__.py` 和
`workflow/_contract.py` 是每个包必须带的（`router.py` 在模块级 import 它们）。

## 怎么装

单个 instance：

```bash
# 算分引擎（每个包都要）
cp judger/tests/{judge.py,router.py,reward_exit_message.py,test.sh} <instance>/tests/
mkdir -p <instance>/tests/workflow
cp judger/tests/workflow/{__init__.py,_contract.py} <instance>/tests/workflow/
# 能力库（按这个 instance 的 rubric.json 实际用到的拷）
cp judger/tests/utils.py <instance>/tests/                      # code 型用得到
cp -r judger/tests/workflow/ppt_judge <instance>/tests/workflow/ # 只有 ppt 任务要
chmod 755 <instance>/tests/{judge.py,test.sh}
python3 <instance>/tests/lint_rubric.py <instance>/tests   # 不通过别打包
# 再照 judger/task.toml 的 [verifier.env] 把目标 instance 的对齐，然后重新打包传 OSS
```

几件容易踩的事：

- **这些文件是打进每个 instance 的 `content.tgz` 的**。改完不重新打包上传 OSS，
  线上跑的还是旧版本——本地改了等于没改。
- **`workflow/<name>/` 按需拷，`workflow/{__init__.py,_contract.py}` 必拷。** 后两个
  是 `router.py` 在模块级 import 的；前者惰性加载（`load()` 只 import rubric.json
  点名的那一个），所以一个 word 任务带着 `ppt_judge/` 只是白占体积和依赖。
  `lint_rubric.py` 会同时报「引用了但没拷」（ERROR）和「拷了但没用到」（WARN）。
- **`router.py` / `utils.py` / `workflow/` 是 1.2.0 新增的，装机脚本要一起拷。**
  漏了 `router.py` 的话：全 agentic 的包会退回 1.9.0 的直判路径（行为等价，只在
  stderr 告警）；但只要有一条 `code`/`workflow` 型 rubric，就会以
  `judge:scorer_error` 硬失败 —— 不能让 agent 去判一条本该由代码执行的 rubric，
  那会悄悄产出一个看起来正常的错分。
- **`rubric.json` / `judge_instruction.md` / `task_prompt.md` / `expected_output/` /
  `workflow_assets/` 不要动**。`judger/tests/` 下那几个只是示例，别拷过去把
  instance 自己的覆盖了。
- **判分说明一个字都不用改。** 1.2.0 也没改它：`_judge_block` 本来就给每个块写
  **子集** `rubric.json`，router 分派后 agentic 段拿到的也是子集，语义不变。
- **原来的判分脚本备份成 `<名字>.pre-unify.<md5>.bak` 留在包里**。OSS 那个桶只能写，
  下载会 403，所以回滚点只能放在包内。

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

**判据只有一条：改一个组件，要重装的是「带有这个组件、且行为真会变」的包。**

这不是一个共享库，是 4312 份**互不通信**的独立拷贝（各自独立进程、独立文件系统）。
包 A 的 `utils.py` 比包 B 新，两者不会互相影响 —— 真正的不变量是**每个包自己内部
自洽**（`rubric.json` 引用的函数/workflow 在它自己的 `tests/` 里存在且能跑），
而不是 fleet-wide 一致。

| 改了什么 | 哪些包有它 | 哪些包行为会变 | 要重装 |
|---|---|---|---|
| `judge.py`（算分 / 留痕 / 输出） | 全部 | 全部 | **4312 + 204** |
| `router.py`（分派 / gate / 留痕聚合） | 全部 | 全部 | **4312 + 204** |
| `workflow/_contract.py`（verdict row 形状、`power_mean`、`snap_to_grid`） | 带 `workflow/` 的包 | 有 workflow 型 rubric 的包 | 那一批 |
| `utils.py` **纯新增**一个函数 | 全部 | **一个都没有**（存量 rubric.json 没人引用它） | **0 个** |
| `utils.py` **改已有函数** | 全部 | 声明了那个函数的包 | 那一批 |
| `workflow/ppt_judge/` 的实现 | 拷了这个目录的包 | `workflow_name: "ppt_judge"` 的包 | 那一批 |
| **新增** `workflow/word_judge/` | 只有新任务 | 只有新任务 | **0 个存量** |
| 某个 instance 的 `rubric.json` / `workflow_assets/` | 那一个 | 那一个 | 那一批 |
| 只改 `[verifier.env]` 里已声明的变量的值 | — | — | 不用重装，rollout 侧覆盖 |

只有前三行会 bump `VERSION`。**`utils.py` 和 `workflow/<name>/` 不受 `VERSION` 约束。**

### 但一致性的作用域缩到了「批」，不是没有

放宽之后会出现新风险：包 A 的 `locate_deck` 是旧版（有 bug）、包 B 是新版。如果这两个
包**在同一次 RL 训练里**，那就是静默的口径不一致 —— 正是 1.0.0 的「各批比较符不一致」、
1.9.0 的「clawgym 被按二值判」那类问题。所以：

> **同一个任务族（同一份 jsonl / 同一次 RL 训练）里的包，能力库组件必须一致。**
> 不同任务族之间不要求一致 —— 它们的 reward 本来就不可比（不同 rubric、不同 weight）。

也就是把校验粒度从 `--all` 改成 `--batch <jsonl>`。这是放宽的**代价**，不是免费的：
装机流程要开始记录「哪批用了哪个组件版本」，而不是只记一个全局 `VERSION`。

### `ABI`：防「新 workflow + 旧 judge.py」

`judge.py` 里除了 `VERSION` 还有 `ABI` / `ABI_COMPAT`，两者是不同的东西：

| 常量 | 含义 | 什么时候动 |
|---|---|---|
| `VERSION` | 算分引擎版本 | 每次引擎改动 |
| `ABI` | `verdict row` / `ctx` / `WorkflowResult` 的**契约形状** | 只在契约真的换形状时 |

每个 `workflow/<name>/__init__.py` 声明 `REQUIRES_ABI = N`，router 加载时对着
`ABI_COMPAT` 校验，对不上就把该批 rubric 记**未判到**（`workflow_missing`）。

这道闸防的是：包里的 `workflow/` 和 `judge.py`/`router.py` 不是同一次拷来的。不校验
的话，按新契约写的 workflow 会返回一个字段名对不上的 `WorkflowResult`，router 只当
它漏返回、补哨兵 —— 事后看起来像判分抖动，而不是装机错误。那是最难查的一类失败。

`lint_rubric.py` 在打包时也查这一条（以及「声明了 `REQUIRES_ABI` 没有」）。

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

### 1.2.0 对 judge.py 改了什么

只有 5 处，`_compute` / `_judge_all` / `_judge_block` / `_build_schema` /
`_write_outputs` / `_die` / `_result_document` / `_extract_verdict_json` / `_polarity`
**一个字没动**：

| # | 位置 | 改动 |
|---|---|---|
| 1 | 头部常量 | `VERSION` `1.9.0`→`1.2.0`；新增 `ABI` / `ABI_COMPAT`；`sys.path` 加 `TASK_DIR` |
| 2 | `_normalize_rubrics` | 透传 + 校验 7 个新字段；`judge_method` 缺失→agentic；认不出→退回 agentic + WARN |
| 3 | `main()` | `_judge_all(...)` → `router.route(criteria, ctx)`；router 缺失时全 agentic 退回直判、有非 agentic 则硬失败 |
| 4 | `main()` | 纯 code 任务跳过 `_configure_backend`（零 LLM 调用不该因缺凭据判死） |
| 5 | `extra_deliv` | `update(method_stats)`，合并 router 的 per-method 留痕 |

存量等价性由 `selftests/test_score.py::new_routing_fields_do_not_touch_compute` 钉住：
7 个路由字段对 `_compute` 的输出**逐字节**没有影响。建议全量铺开前先在小批量上核对
`task_score` 与 1.9.0 逐位一致。

## 打分怎么算

记第 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，分数系统性偏低。

## rubric 的三类判分手段（1.2.0）

`rubric.json` 里每条用 `judge_method` 标类型，`tests/router.py` 按它分派。
**缺失 = `agentic`**，所以存量 4312 个包的 rubric.json 一个字不改。

```json
{
  "workflow_config": {
    "ppt_judge": {"mode": "grouped", "group_size": 1, "page_mean_p": 1.0,
                  "render_resolution": "1920x1080"}
  },
  "rubrics": [
    {"id": "C-LOCATE-DECK", "judge_method": "code",
     "code_method_name": "locate_deck",
     "code_method_params": {"prefer": ["pptx", "html", "pdf"], "allow_multiple": false},
     "description": "交付目录下存在且仅存在一个可识别的 deck 产物", "weight": 2.0},

    {"id": "V-SL-OVERLAP", "judge_method": "workflow", "workflow_name": "ppt_judge",
     "workflow_group": "containment", "na_policy": "unjudged",
     "description": "所有元素之间没有遮挡对方可读内容的有害重叠……", "weight": 6.0,
     "type": "gradient", "levels": {"1": "[grid]", "0.95": "[grid]", "…": "…"},
     "depends_on": ["C-LOCATE-DECK", "C-DECK-OPENABLE"]},

    {"id": "A-NARRATIVE-FLOW", "judge_method": "agentic",
     "description": "整套 deck 的叙事有清晰主线……", "weight": 5.0}
  ]
}
```

新增的字段：

| 字段 | 用于 | 说明 |
|---|---|---|
| `judge_method` | 全部 | `code` / `workflow` / `agentic`。**缺失 = `agentic`**；认不出的值退回 agentic 并 WARN |
| `code_method_name` | `code` | `utils.py` 的 `CODE_METHODS` 白名单里的函数名。缺了就退回 agentic |
| `code_method_params` | `code` | 传给那个函数的参数对象 |
| `workflow_name` | `workflow` | `workflow/<name>/` 的目录名。router 按它聚合后整批交过去 |
| `workflow_group` | `workflow` | `mode=grouped` 时的调用分组。缺失 → grouped 退化成 combined |
| `na_policy` | `workflow` | 全部页都判「不适用」时怎么记：`pass` / `fail` / `unjudged`（默认） |
| `page_mean_p` | `workflow` | 单条覆盖跨页聚合的幂平均指数（缺省用 `workflow_config` 的值） |

顶层的 `workflow_config` 放 workflow 的**质量/口径**参数（per-instance 属性，
不随 fleet 变，所以不占 harbor 变量）。放顶层是安全的：`_rubric_rows` 先按
`rubrics`/`criteria`/… 找 list 并提前返回，只有这些键都没有时才会走 id→行映射分支。

细节（契约、怎么写新 workflow、ppt_judge 的跨页聚合和 `na_policy`、极性陷阱）
见 **`tests/workflow/README.md`**。

### 示例 rubric.json 的构成

本仓 `tests/rubric.json` 是一个**完整**的三类混合示例（50 条，正权重合计 191），
配套资产是真实的 Abbott FY2022 10-K 那份 `task_check_dir`：

| 类型 | 条数 | 来源 | 形态 |
|---|---|---|---|
| `code` | 2 | 手写 | `locate_deck`（点名 slides.pptx）/ `check_pptx_openable` |
| `workflow` 视觉 | **14** | 源 `visual_judge/rubrics.json` 逐条搬 | 分级项 + 21 档量化网格，5 个 cluster |
| `workflow` 内容 | **33** | 源 `task_check_dir` 的 19 type1 + 14 type2 | 二值项，type2 `depends_on` 配对 type1 |
| `agentic` | 1 | 手写 | 正文语言与源文档一致 |

权重分配（视觉 53 : 内容 100 ≈ 1:2）：

| 侧 | 档位 | 条数 | 小计 | 依据 |
|---|---|---|---|---|
| 视觉 | critical 6.0 | 4 | 24 | 源 `CRITICAL_RUBRICS`：结构性缺陷（裁切/遮挡/乱码/空白页） |
| 视觉 | 普通 3.0 | 9 | 27 | |
| 视觉 | richness 2.0 | 1 | 2 | 唯一的正向项，「可以更好」而非「有缺陷」 |
| 内容 | type2 4.0 | 14 | 56 | **讲得对不对** —— 数字错比漏章节严重 |
| 内容 | type1 必需 3.0 | 6 | 18 | 源 `mandatory`（原 `VERIFY_MANDATORY_PENALTY` 默认关，实际只表示「更重要」） |
| 内容 | type1 普通 2.0 | 13 | 26 | **讲没讲** |
| code | 2.0 / 3.0 | 2 | 5 | 纯门禁，失败时下游全被 gate，自身权重不重要 |
| agentic | 5.0 | 1 | 5 | |

`gen_ppt_rubrics.py --balance 1.0` 可以把视觉侧等比缩放到 1:1，但那会让档位变成
11.3 / 5.7 这种非整数。当前保持整数档位，两轴的实际投入靠
`JUDGE_WORKFLOW_DEADLINE_SHARE` 和 `mode` 调。自测
`weights_are_balanced_between_axes` 卡住比例落在 0.4~1.6 区间、critical 重于普通项、
type2 重于 type1。

**`hurdle` 字段所有条目都显式写了**（当前全 `false`）。机制本身完好、有自测钉着
（命中一条 → 整题 0 分、不吃 `levels`、`_polarity` 推成 negative），随时可以启用 ——
但记得 hurdle + `weight: 0` 的 description 必须写成**违规**的描述，见下文极性那节。

这个示例是**面向 pptx 任务配的**：`locate_deck` 只扫 `.pptx`（源材料常是 pdf，不能
误当交付物）、点名 `slides.pptx` 且 `require_expect_name: true`（题面一般会指定产物
文件名）。html 相关的比例闸和「产物必须放在指定路径」那两条先去掉了 —— 前者是 html
任务才用，后者不是所有任务都硬性规定。

**视觉和内容两侧都是从源文件程序化生成的，不是手写。** 第一版示例只手写了 7 条视觉
rubric、漏了一半（还漏掉 `CRITICAL_RUBRICS` 里的 `visual_eq_blank_page`），而 lint
查不出来 —— 它只查引用完整性，不知道「应该有几条」。所以补了两条自测钉住：
`example_rubric_json_covers_both_axes_completely`（条数、cluster 覆盖、critical 权重、
分级/二值形态、`na_policy`）和 `example_rubric_json_visual_questions_are_verbatim`
（description 必须是源文件 question 的原文，改写就会改判分口径）。

几处映射规则（源文件的信息不能丢）：

| 源字段 | 映射成 | 为什么 |
|---|---|---|
| visual `cluster` | `workflow_group` | `mode=grouped` 的调用分组 |
| visual `question` | `description` | **逐字搬**，严重度分级和豁免条件都写在里面 |
| `CRITICAL_RUBRICS`（4 条） | `weight` 8.0（普通 5.0、richness 4.0） | 原来只用于 `n_critical_severe` 上报，那套聚合删掉后改用权重表达 |
| 「NA if there are no X」（4 条） | `na_policy: "pass"` | 全 NA 说明 deck 里根本没这类元素，不该扣分 |
| type1 `mandatory`（6 条） | `weight` 3.0（非必需 2.0） | 原 `VERIFY_MANDATORY_PENALTY` 默认 0、门本来就关着，它事实上只表示「更重要」 |
| type2 `paired_type1_id` | `depends_on` | 这就是 `leak_fix` 的等价表达 |

### 执行顺序：code → workflow → agentic，严格串行

三段相对独立，串行有三个理由：

1. **workflow 和 agentic 都要调 LLM，并行会叠加瞬时 RPM。** 1.8.0 那场 62% 判分失败
   的根因就是全 fleet 峰值 RPM 顶穿配额。
2. **code 段在最前面**，于是 `locate_deck` 定下的 deck 路径能给后两段复用（走
   `ctx["artifacts"]`）。这消掉了 PPT-Judger 的一个真 bug：那边 content 轴优先
   `index.html`、visual 轴优先 `.pptx`，同一个目录能判出两个不同的文件，而最终分
   是这两者的加权和。
3. **workflow 段在 agentic 之前**，于是它渲出的页图落在 `evidence/pages/` —— 而
   `_judge_block` 现成的硬链清单里已经有 `evidence`，所以每个 agentic 块的工作目录
   会自动拿到这批页图，不用再各自渲一遍（这正是 1.6.0 离线取证要解决的问题）。

`JUDGE_WORKFLOW_DEADLINE_SHARE`（默认 0.5）给 workflow 段划一段预算，剩下的留给
agentic。**不留余量就是把「块预算漏」原地换成「撞总墙漏」**，而且会让降级偏斜到
某一类 rubric 上（覆盖率掉了，但掉的是特定一类的分，reward 被系统性压低）。

### code 段的定位结果怎么传给后两段

`locate_deck` 把 deck 路径写进 `artifacts`，router 收进 `ctx["artifacts"]`。两条传递
路径不同：

- **workflow 段**：直接从 `ctx["artifacts"]` 读（`run_workflows` 里 `sub = dict(ctx)`
  是浅拷贝，`artifacts` 是同一个 dict 对象）。
- **agentic 段**：`_judge_all` 的签名是 `(criteria, instr_text, env, codex_bin,
  model_arg)`，塞不进 artifacts —— 改签名就要动 judge.py。所以 router 把它写进
  **`WORK/evidence/artifacts.json`**，而 `_judge_block` 的硬链清单里**已经有
  `evidence`**，于是每个 agentic 块的工作目录都会自动拿到，零 judge.py 改动。

```json
// evidence/artifacts.json（agentic 型 rubric 的 description 里可以直接引它）
{"deck_path": "/tmp/rubric_judge/output/slides.pptx", "deck_kind": "pptx",
 "deck_rel": "slides.pptx", "deck_in_workspace": "output/slides.pptx"}
```

`deck_in_workspace` 是相对路径 —— agent 的工作目录里是 `output/` 的硬链，不是宿主机
绝对路径。不这么传的后果：agentic 型 rubric 只能自己 glob 一遍 `output/`，可能判到一个
和 code/workflow 段**不同的文件**，那正是 PPT-Judger 里「content 轴优先 index.html、
visual 轴优先 .pptx，同一个目录判出两个文件」那个 bug 从 agentic 这侧漏回来。

### `depends_on` 的三层消费

同一份声明被三处消费，各管一件事：

| 层 | 干什么 | 性质 |
|---|---|---|
| **router**（跨段） | 外部 dep 未命中 → 整条 rubric 不执行 | 优化：省钱 |
| **workflow**（段内） | 内部 dep 未命中 → 跳过这条的 LLM 调用（= 原 `leak_fix`） | 优化：省钱 |
| **`_compute`** | gate 未满足 → 不计分、**分母保留** | **语义：唯一决定分数的一层** |

只有第三层决定分数，前两层有 bug 也只是白花钱。三条规则：

- **规则 1**：段内短路只看**内部** dep。外部 dep（code 段的 `C-LOCATE-DECK`）
  workflow 查不到判定，误当成「前置未命中」会把整批错杀。
- **规则 2**：确定性短路产出 `hit=false` + `[gate]` reason，**不是未判到哨兵**。
  记成未判到会让 `judge_coverage` 从「评估了多少」变成「deck 做得多好」，下游那条
  按 `judge_coverage < 1` mask 假 0 的口径就失效了。
- **规则 3**：dep **未判到** ≠ dep **未命中**。前者沿 DAG 传播成未判到，后者是真 0。
  分数上都是 0，但留痕上必须分得开 —— 否则「code 函数崩了」和「deck 真没交产物」
  在下游长得一模一样。

**`depends_on` 必须写传递闭包。** `_compute` 的 `hit_by_id` 用的是**原始 `hit`**，
gate 不沿依赖链传递。反例：`C-LOCATE-DECK` 因为有两个 pptx 而歧义未命中，但
`C-DECK-OPENABLE` 随便挑一个能打开、`hit=true`，于是只写了
`depends_on: ["C-DECK-OPENABLE"]` 的视觉项 gate 照样打开 —— 定位失败了却还在打分，
判的还是一个来源不明的文件。`lint_rubric.py` 会算闭包并在不闭合时报错。

### 新任务怎么生成 rubric.json：`gen_ppt_rubrics.py`

**别手写。** 两侧来源不同，但都是生成的：

```bash
python3 tests/gen_ppt_rubrics.py <instance>/tests              # 视觉 + 内容都刷
python3 tests/gen_ppt_rubrics.py <instance>/tests --content     # 只刷内容侧
python3 tests/gen_ppt_rubrics.py <instance>/tests --balance 1.0 # 视觉:内容 = 1:1
python3 tests/gen_ppt_rubrics.py <instance>/tests --dry-run     # 只看不写
```

| 侧 | 来源 | 性质 |
|---|---|---|
| 视觉 14 条 | `workflow/ppt_judge/visual_rubrics.json` | **所有 ppt 任务共享的模板**，属于 workflow 包 |
| 内容 N 条 | `workflow_assets/ppt_judge/task_check_dir/` | **每个任务都不一样**，per-instance 资产 |

它保留 `code` / `agentic` / 别的 workflow 的 rubric，只重刷 `ppt_judge` 那部分，所以
可以反复跑（幂等，有自测钉着）。

`visual_rubrics.json` 是模板而不是运行时配置 —— **workflow 运行时不读它**（criteria 由
router 传进来）。它不带 `depends_on`，门禁由生成器按 `--gate` 注入（因为门禁的 id 是
per-instance 的）。

#### task_check_dir 的信息去哪了

两边靠 **id** 对应，各存各的一半，`lint_rubric.py` 校验不漂。**没有信息丢失，
只是按「谁需要」分了两处存**：

| 字段 | 去向 | 用途 |
|---|---|---|
| `id` | 两边都有 | 连接键 |
| `question` | → `description` | **逐字搬**，判定标准就写在里面 |
| `mandatory` | → `weight`（3.0 vs 2.0） | 字段本身留在资产里作溯源 |
| `paired_type1_id` | → `depends_on` | **这就是 `leak_fix` 的等价表达** |
| `section` / `check_kind` | → `tag` | 下游分类统计 |
| `search_hints` | 留在资产 | type1 Pass A/B 的弱定位符 |
| `scope` | 留在资产 | `deck_wide` → type2 看代表页而不是配对页 |
| `anchor_refs` | 留在资产 | ground truth，整份内联进 prompt |
| `manifest.anchor_index` | 留在资产 | `anchor_refs` 缺失时的兜底（运行时唯一会读的 manifest 字段） |
| `manifest.pairs` / `type1_only` / `counts` | 留在资产 | 溯源；运行时不读，lint 校验与 `depends_on` 一致 |
| `verify_input` / `compare_against` | 留在资产 | **纯文档**，代码从不读（写错也不报错） |
| `generation-log.json` | 留在资产 | 生成过程审计，判分时完全不读 |

判分口径进 rubric.json（标注可改、可加权），取证手段留在资产里（跟着 checklist 生成
流程走）。这样改 anchor 文字不用动 rubric.json，调权重不用动资产。

### 打包前必须跑 lint

```bash
python3 tests/lint_rubric.py <instance>/tests
```

查：

- **rubric.json 内部**：`code_method_name` / `workflow_name` 有没有写、`depends_on`
  悬空/成环/不闭合、量化网格是否合法、`workflow` 型有没有既是 hurdle 又是分级项、
  hurdle + `weight: 0` 的 description 是不是负向的
- **per-package 自洽**（放宽「全量一致」之后最要紧的一条）：`code_method_name` 在
  **本包** `utils.py` 的 `CODE_METHODS` 里注册了没、`workflow_name` 在**本包**
  `workflow/` 下存在没、`workflow/{__init__.py,_contract.py}` 在不在、各 workflow
  的 `REQUIRES_ABI` 和本包 `judge.py` 的 `ABI_COMPAT` 对不对得上、有没有拷了用不到的
  workflow
- **ppt 资产**：anchor 是否都在包里、`task_check_dir` 与 rubric.json 的 id 集合是否
  一致、`manifest.pairs` 与 `depends_on` 是否一致

退出码非 0 就别打包。

**为什么是 lint 而不是 checksum**：资产是 per-instance 的，随时会被优化、重标注，
按 md5 卡死会挡住一切正当改动；真正要防的不是「变了」而是「**变得不自洽**」——
那些在运行时只会表现为静默的错分或降级。能力库（`utils.py` / `workflow/<name>/`）
不受 `VERSION` 约束之后，也不能再靠 fleet-wide 的 md5 一致来保证「包能跑」，只能
逐包查引用完整性。

## 环境变量

**"线上可调" 那一列很关键**：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` |

### 1.2.0 新增的三个（都是 fleet 级成本旋钮）

新变量只加了三个，而且都是**成本/并发/时限**类。判断标准：

- **质量/口径参数 → `rubric.json` 的 `workflow_config`**（per-instance，不随 fleet 变）：
  `mode` / `group_size` / `page_mean_p` / `render_resolution`
- **成本/并发/时限参数 → `[verifier.env]`**（fleet 级，限流事故时要能**不重打 4312 个包**
  就一键拧低）：下面这三个

| 变量 | 默认 | 线上可调 | 说明 |
|---|---|---|---|
| `JUDGE_WORKFLOW_CONCURRENCY` | `4` | ✓ | workflow 段**全局 VLM 在飞上限**（信号量）。不是线程池大小 —— workflow 有嵌套并行（页 × rubric 组、type1/type2 各起一批），两个池各 8 worker 就是 16 个在飞，只有信号量封得住 |
| `JUDGE_WORKFLOW_DEADLINE_SHARE` | 空（= 自适应） | ✓ | **留空 = 按需预留**（见下）。显式给 0~1 的数就退回固定比例，事故时的逃生阀 |
| `JUDGE_WORKFLOW_START_JITTER` | `30` | ✓ | workflow 段起判抖动（秒）。agentic 段有 `JUDGE_START_JITTER=180`，这是它的对应物 —— 单次调用短得多，所以默认小一档 |
| `JUDGE_CODE_TIMEOUT` | `60` | ✓ | 单个 code 型函数的超时（守护线程 + join，超时记未判到 + `code_timeout`） |

`JUDGE_PER_TIMEOUT` / `JUDGE_MAX_TRIES` / `JUDGE_RESPLIT` / `JUDGE_DEADLINE` /
`JUDGE_START_JITTER` / `JUDGE_RETRY_BACKOFF_MAX` / `JUDGE_MODEL` / `JUDGE_API_KEY` /
`JUDGE_BASE_URL` **全部复用现有的**，不新增同义变量。PPT-Judger 原来的
`VJ_PRIMARY_MODEL` / `VJ_FALLBACK_MODEL` / `VLM_MODEL` / `TIMEOUT` / `WORKERS` /
`API_MAX_ATTEMPTS` / `API_RETRY_INTERVAL` / `PARSE_MAX_ATTEMPTS` /
`VERIFY_RENDER_RESOLUTION` 以及 `JUDGE_*→OPENAI_*` 凭据桥都删掉了。

### 两段的预算怎么分：按需预留，不是固定比例

`code → workflow → agentic` 串行，预算得分开，否则前面的段会吃光后面的。但**固定比例
对谁都不对** —— 两段的 rubric 比例在任务之间波动极大（ppt 这批是 47:1，有的任务没有
workflow，有的以 agentic 为主）。实测过一次事故：

```
固定 0.5：workflow 47 条 → 898s（不够，28 条 type2 全部未判到）
          agentic  1 条 → 898s（只用 191s，浪费 707s）
                            ↑ 浪费的这半，正好是被饿死的那部分需要的
```

关键不对称：**agentic 的成本可预测，workflow 的不可预测**。

- agentic：CHANGELOG 1.5.0 实测的成本模型 —— 单次 ≈ 固定开销 ~100s（起 codex 会话 +
  读完所有产物）+ 条数 × ~3s，固定开销占九成。块数 = ⌈条数/`BATCH`⌉，波数 = ⌈块数/`K`⌉。
- workflow：取决于页数，而页数**要渲完图才知道**。

所以给可预测的那一侧按需预留，剩下全给不可预测的那一侧：

```
agentic 预留 = 波数 × (100s + BATCH×3s) × 2      安全系数 2 同 BLOCK_BUDGET 的推导
workflow 可用 = max(剩余 − 预留, 0.5 × 剩余)      下限防 agentic 很多时把 workflow 挤掉
```

同样那两个任务：预留 224s（实测 agentic 只用 191/211s，够），workflow 拿到
**1571s（原来的 1.75×）**。

`agentic` 一条都没有时预留是 0，workflow 拿全部。显式设 `JUDGE_WORKFLOW_DEADLINE_SHARE`
可以退回固定比例当逃生阀。

### workflow 段内的执行顺序：content 先，visual 后

这**不增加预算**，只改变预算不足时谁被饿死 —— 而两侧的降级质量差一个数量级：

| | 预算耗尽时 |
|---|---|
| visual | job 是**页优先**排的，所以是「所有 rubric 都少看几页」：用判到的页算跨页平均、reason 记「N 页未判到」，**rubric 不丢** |
| content type2 | 一条 rubric 一次调用，没了就是**整条丢**；而且它有依赖链（tagging → type1 → type2）天然排最后 |

所以让预算不足砸在能优雅降级的那一侧。

### workflow 的成本账（铺开前必须重算峰值 RPM）

`JUDGE_DEADLINE` 的三层结构（单次 ⊂ 单块 `2×PER_TIMEOUT` ⊂ 全局）**只适用于
agentic 段**。那个推导的前提是「超时失败一次就烧掉一个 `PER_TIMEOUT`，所以块预算
天然只够试 2 次」，而 workflow 的单次调用便宜得多、重试很划算。所以 workflow 段
不复用 `BLOCK_BUDGET`，用 `DEADLINE_SHARE` 划总预算、段内按 `PER_TIMEOUT` 控单次。

两段的成本形状完全不同：

| | agentic | workflow（20 页 deck，grouped） |
|---|---|---|
| 单次成本 | 固定开销 ~100s（起 codex 会话 + 读全部产物）+ 条数 × ~3s | 一次 VLM chat，几秒~几十秒 |
| 每 job 调用数 | 块数 = ⌈条数/BATCH⌉，典型 **~6** | O(页数 × rubric 数 / group_size)，典型 **~100** |
| 默认并发 | `JUDGE_CONCURRENCY=2` | `JUDGE_WORKFLOW_CONCURRENCY=4` |
| 每 job RPM 量级 | 2/100 × 60 ≈ **1.2** | 4/10 × 60 ≈ **24** |

**`mode` 的选择直接乘在峰值 RPM 上**：`combined` 1× / `grouped` 5× / `split` 7×。
1.8.0 实测过 `CONCURRENCY` 是全 fleet 峰值 RPM 的直接倍数，正常态就已吃掉 key 额度
的 65%，一抖动就正反馈；而且**限流在 HTTP 层看不见**（在 SSE 流里返回，proxy log
里 737 个请求全是 200，网关 metrics 一个错误都没有，只能下 artifact 查）。

所以：**在小批量 A/B 之前不要往 4312 个包上铺 workflow 型 rubric。** 先按目标
instance 的真实页数 × rubric 数 × 预计同时判分的 job 数算一遍峰值，再定
`JUDGE_WORKFLOW_CONCURRENCY` 和 `mode`。

## 几个关键行为

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

题面对交付路径是硬要求，三批 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 加） |
| `judge_methods` | 各 method 的 `{n, judged, coverage}`（1.2.0 加） |
| `judge_coverage_by_method` | `{code: 1.0, workflow: 0.82, agentic: 1.0}`（1.2.0 加） |
| `judge_unjudged_weight_frac` | 未判到的**正权重占比**（1.2.0 加） |

最后三个是 1.2.0 新增的。加 `judge_unjudged_weight_frac` 的动机：三类混合之后
systemic 门禁按「条数」算会失真 —— 漏掉 3 条 `weight=8` 的内容项，比漏掉 10 条
`weight=1` 的视觉项严重得多。**但门禁本身这一版不改，先只留痕。** 1.4.0 先加覆盖率
字段、1.8.x 才拿实测数据改并发默认值，这一条走同一个节奏：攒到线上数据再决定要不要
把 `systemic` 改成按权重。

`judge_error_kind` 在 1.2.0 多了几个更细的值（`vlm_error` / `render_env` /
`asset_broken` / `code_timeout` / `code_error` / `workflow_missing` / `dep_unjudged` /
`parse_error`），由 router 提供并覆盖 judge.py 算出的粗分类。**撞全局时间墙的情况
router 不覆盖**，仍由 judge.py 判成 `deadline`（它认 `DEADLINE_MARK`）。
`exit_code` 的取值集合没有变，不给下游造新码。

这几个字段在**所有**出分路径上都写，下游不用处理缺字段。局部降级也写 `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-Judger
for t in test_parse test_score test_router test_workflow_ppt test_degrade test_harbor; do
  python3 selftests/$t.py
done
python3 tests/lint_rubric.py          # 示例 rubric.json 的引用完整性
```

当前 **220 例全过**（1.2.0）：

| 文件 | 例数 | 层 | 测什么 |
|---|---|---|---|
| `test_parse.py` | 15 | 纯函数 | 判词解析的五级还原（整体 JSON → 剥代码块 → 配平扫描 → 逐条抢救 → 兜底） |
| `test_score.py` | 47 | 算分引擎 | 算分规则、reward 语义、systemic 门槛、限流退避、极性字段、gradient（含与 clawgym / gdpval 原口径的等价性）、**1.2.0 的存量等价性** |
| `test_router.py` | 41 | 分派层 | 三类分派、gate 三条规则、跨 method `depends_on`、分段预算、某类全崩不影响其他类、code 型异常/超时归未判到、ABI 不匹配归未判到 |
| `test_workflow_ppt.py` | 51 | workflow | 视觉：跨页幂平均、量化网格吸附、`na_policy` 三态、cell 级 resplit。内容：两趟 type1（caption → 真图复核、单向翻正）、type1→type2 页码路由与短路、真实 Abbott 资产端到端 8 例。故障：P0（VLM→未判到）、P1（环境缺→未判到 / deck 坏→真 0） |
| `test_degrade.py` | 46 | `main()` | 判分坏成各种样子还能不能出分（假 codex 跑真 `main()`），外加交付判断、覆盖率字段、gradient 端到端 |
| `test_harbor.py` | 20 | bash 接线 | 真 `bash tests/test.sh`，只把 `codex` 换成假的。含三类混合端到端、纯 code 零调用零凭据、router 缺失的两条退路、环境能力探测 |
| `test_pptx_evidence.py` | 5 | 纯函数 | 旧 `pptx_evidence.py` 的三层容错（该库已废弃，用例保留） |
| `test_real_rubrics.py` | — | 语料 | 线上真实 `rubric.json` 的规范化 + 算分普查（只读，不调模型） |

1.2.0 新增的三条存量等价性用例最要紧：

- `new_routing_fields_do_not_touch_compute` —— 7 个路由字段对 `_compute` 的输出**逐字节**
  没有影响。这是全量重装 4312 个包的前提。以后谁想在 `_compute` 里读路由字段，会先撞到它。
- `no_judge_method_defaults_to_agentic` —— 缺 `judge_method` 必须是 agentic。
- `workflow_grid_level_survives_the_round_trip` —— workflow 吸附过的 level 再经
  `_level_frac` 是恒等变换（这是「不动 `_level_frac`、用量化网格」这个决定的正确性依据）。

