Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ ProbHub 会在这条流程中提供:
- 面向 Agent 的 Skill,让 Agent 了解规范文件、验证顺序和交付标准;
- standard、custom checker、浮点比较和 interactive 四类常见评测场景;
- AC、WA、TLE、MLE、OLE、RE、FAIL 等结果和完整进程树清理;
- Checker/Interactor 的题目级主动 Judge QA:fixture、鲁棒性探针、隔离执行和有界 evidence;
- 可复现的数据生成、差分测试、反例重放和错解击杀矩阵;
- 题面与 Validator 的范围对账、多组数据累计总量的静态复核提示,以及按复杂度、测试需求和资源余量推导 `T` 与累计上限的 Agent 指引;
- Typst 全卷排版、单题 PDF、DOMjudge ZIP 和交付前验包;
Expand Down Expand Up @@ -172,6 +173,7 @@ Agent 和 WebUI 都会调用同一套 Core。只有需要手动排查或编排
| `probhub lint L01` | 检查目录、配置、题面结构和约束对账 |
| `probhub report L01` | 查看数据画像、错解击杀和累计约束状态 |
| `probhub judge L01` | 编译并运行 Validator、标程、暴力和错解 |
| `probhub judge-qa L01 --no-cache` | 主动测试 Checker/Interactor 的 fixture 和鲁棒性 |
| `probhub stress L01 --rounds 1000 --seed 12345` | 用随机小数据对拍 |
| `probhub seal L01 --no-cache` | 验证并冻结当前题目版本 |
| `probhub build L01 --no-cache` | 正式生成 PDF、ZIP 和 Manifest |
Expand Down Expand Up @@ -223,6 +225,7 @@ Agent 完成题目后,应明确报告下列结果:

- 命令退出码为 0;
- Judge 最终结果为 `all_expectations_met`;
- 对 custom/interactive 题,Judge QA 已配置且最终状态为 `passed`,evidence 为 `current`;
- `status` 为 `current`;
- ZIP 深度验证没有错误;
- 人工检查过单题 PDF 和整场 PDF。
Expand All @@ -231,6 +234,8 @@ Agent 完成题目后,应明确报告下列结果:

本机通过不等于目标 DOMjudge 机器一定具有相同速度。时间限制和内存限制仍应在目标 Linux/DOMjudge 环境校准。

对 `judge.type: custom` 或 `judge.type: interactive` 的新题,或修改 Checker/Interactor 后,先在 `judge.qa` 中登记题目级 fixture,再运行 `probhub judge-qa <ID> --no-cache`。fixture 每次都会执行,只有编译结果可以缓存;`judge-qa-evidence-v1.json` 是本地有界证据,不会进入 ZIP、PDF 或 Manifest。lint/status 中的 evidence 缺失或过期是体检 warning,但 `seal` 和正式 `build` 不允许已配置题目绕过通过的 Judge QA。

## 并行出题时怎么做

多名出题人或多个 Agent 可以各自只修改自己的题目目录:
Expand Down Expand Up @@ -310,6 +315,20 @@ probhub seal L01 --no-cache --seed 12345

所有题目完成后再运行多题 `build`。

### `seal` 提示 `seal_judge_qa_failed`

题目已配置 Checker/Interactor 主动测试,但 fixture 没有全部得到期望状态,或 Judge/清理基础设施失败。先运行:

```bash
probhub judge-qa L01 --no-cache
```

按结构化结果修复 Checker、Interactor、模拟选手、fixture 或期望状态,再重新执行 `seal`。`FAIL` 表示题目基础设施错误,不是成功击杀错解。

### `lint` 或 `status` 显示 Judge QA evidence warning

`judge_qa_evidence_missing`、`judge_qa_evidence_stale` 和 `judge_qa_evidence_invalid` 不会单独让 lint 失败,但正式交付前必须恢复为 `current`。旧的 standard 题或未配置 QA 的旧工作区仍可继续使用;新的 custom/interactive 题不能以 `not-configured` 交付。

### `status` 显示 `stale`

这表示题目、数据、题序、模板、构建工具链或正式产物在上次构建后发生了变化。读取 `stale_fields` 定位原因;重新 `seal` 并 `build`,不要手工修改 Manifest。若显示 `builder_fingerprint.unavailable`,先按诊断修复 Typst 或 Python 依赖。
Expand Down
14 changes: 13 additions & 1 deletion SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ probhub build
| `report [ID...]` | 只读汇总难度、数据画像、recipe、TL 余量和错解击杀矩阵;`--format markdown` 输出 Markdown |
| `sample-check [ID...]` | 只运行样例与首个 accepted,严格核对 `.ans`;不发布 Judge 校准 evidence |
| `judge [ID...]` | 编译并运行 Validator、accepted、brute、wrong |
| `judge-qa [ID...]` | 对已配置的 Checker/Interactor fixture 和鲁棒性探针做主动 Judge QA;只缓存编译结果 |
| `stress ID...` | 反复生成小数据,对拍 accepted 与 brute,保存首个可重放反例;`--against <解法>` 反向找刀,`--fixate <case>` 把命中一步固化为 secret 数据 + 配方 + 定向数据组 |
| `checkpoint ID` | 发布当前题目的不可变 draft checkpoint,供并行组卷使用 |
| `seal ID` | lint、judge、stress 后冻结 revision,并自动生成一版完整试卷 |
Expand Down Expand Up @@ -139,6 +140,14 @@ probhub build L01 --no-cache
probhub build L01 --skip-judge
```

对 `judge.type: custom` 或 `judge.type: interactive` 的新题、以及修改过 Checker/Interactor 的题,必须在 `judge.qa` 中登记题目级 fixture 后执行:

```powershell
probhub judge-qa L01 --no-cache
```

Judge QA 每次都会重新执行 fixture 和内建探针;`--no-cache` 只额外强制重编官方 Judge 与模拟选手。成功会原子发布题目本地的 `judge-qa-evidence-v1.json`,该文件不进入 ZIP、PDF、Manifest 或正式数据。没有配置 QA 的旧题仍保持 Core 兼容,但 Agent 不得把 `not-configured` 当作新 custom/interactive 题的交付完成。

完整语法、产物、退出码和故障处理见 `references/cli.md`。配置或执行差分测试前读取 `references/stress.md`;修改资源限制、解释 OLE 或排查残留进程时读取 `references/process-control.md`。

# 4. Agent 验证模式
Expand Down Expand Up @@ -184,6 +193,8 @@ probhub build L01 --skip-judge

发现反例后先用输出的 `replay_command` 固定复现,再修复并重跑;完整协议见 `references/stress.md`。

对已配置 `judge.qa` 的 custom/interactive 题,再执行 `probhub judge-qa <ID> --no-cache`,确认最终状态为 `passed`;自动探针若返回 `AC`,还必须人工确认这不是 Checker/Interactor 误放行。

6. 完成后执行:

```powershell
Expand All @@ -197,7 +208,7 @@ probhub build L01 --skip-judge
probhub build <ID> --no-cache
```

8. 只有命令退出码为 `0`、沙箱最终事件为 `all_expectations_met`、ZIP 深度验证成功且 `status` 为 `current` 时才可交付。独立复核正式包时使用 `probhub --workspace <工作区> verify-package <ID>.zip --require-pdf --problem <ID>`;不带 `--problem` 的 `verification_scope: structural` 不能替代题名、限制、数据和输入 Validator 对账。Manifest 的 `collection_hash` 会跟踪整场排版输入;其他题题面、题面媒体、样例、题序或模板变化后,受影响题目也必须重新构建。
8. 只有命令退出码为 `0`、沙箱最终事件为 `all_expectations_met`、已配置 Judge QA 的最终状态为 `passed` 且 evidence 为 `current`、ZIP 深度验证成功且 `status` 为 `current` 时才可交付。独立复核正式包时使用 `probhub --workspace <工作区> verify-package <ID>.zip --require-pdf --problem <ID>`;不带 `--problem` 的 `verification_scope: structural` 不能替代题名、限制、数据和输入 Validator 对账。Manifest 的 `collection_hash` 会跟踪整场排版输入;其他题题面、题面媒体、样例、题序或模板变化后,受影响题目也必须重新构建。
9. 查看 judge summary 与 lint/status 的 `calibration`、`diagnostics`:默认 accepted 应满足 `max_time × 3 <= TL`,期望 TLE 的目标用例应有至少 `1.5 × TL` 的延长探针证据。缺失或低余量 warning 必须在交付前人工处理或在题目 `calibration` 中有意识地调整阈值。

本地 `max_time`、内存和输出余量不是正式评测承诺。Windows 与 Linux/DOMjudge 的启动、链接、调度、计时和内存口径不同;正式 TL/ML/OL 必须在目标 Linux 评测环境重新校准,结构化结果中的 `target_guarantee` 固定为 `false`。
Expand All @@ -220,6 +231,7 @@ probhub build L01 --skip-judge
- 普通唯一答案题使用 `judge.type: standard`:忽略整个输出首尾空白和每行末尾空格/Tab,但行内空格与内部换行仍需一致。需要 Token 级宽松比较时改用 Checker。
- 非唯一答案和浮点题使用 `judge.type: custom` 与 `code/checker.cpp`;交互题使用 `judge.type: interactive` 与 `code/interactor.cpp`。实现前读取 `references/checker-interactor.md`。
- Checker/Interactor 必须使用附带的 DOMjudge/testlib 协议;交互题按需设置 `judge.interactive.idle_limit` 和 `transcript_limit`。Core 负责本地编译以及生成 `output_validators/validate/`,不得手工维护该生成目录。
- Checker/Interactor 题应在 `judge.qa` 中登记真实 fixture;Checker 可声明 `AC/WA`,Interactor 可声明 `AC/WA/RE/TLE/MLE/OLE`,并可使用 `early-eof`、`idle`、`output-flood` 内建模拟行为。fixture 文件放在 `judge-fixtures/`,交互模拟选手源码放在 `code/judge-qa/`,都会按原始字节进入 `fixture_hash`,但永远不会进入正式题目包。
- 数据严格放在 `data/sample` 和 `data/secret`,每个 `.in` 必须有同名 `.ans`。
- 样例 `.ans` 必须由配置顺序中的首个 accepted 精确复现;只归一 CRLF/CR 为 LF,尾空格、缺少尾换行和其他字节差异仍失败。Custom Checker 的非唯一输出语义不能替代这条样例不变量;交互题明确不适用。
- 题面只能有一个 H1,必需 H2 依次为题目描述、输入格式、输出格式且内容非空;提示位于输出之后,样例输入/输出只来自 `data/sample`。lint 的约束对账会保守识别直接 LaTeX/中文累计上限与 Validator 直接累加器,并在多测但未发现累计上限时提示复核;结果始终是 `analysis_state: partial`,启发式 mismatch 只能 warning,不能替代复杂度分析或正确性证明。
Expand Down
39 changes: 39 additions & 0 deletions probhub/building.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@
from .generations import checkpoint_revision, latest_checkpoint
from .hashing import hash_file
from .io import write_json
from .judge_qa import inspect_judge_qa
from .judge_qa_evidence import (
JUDGE_QA_EVIDENCE_FILENAME,
JUDGE_QA_EVIDENCE_LOCK_FILENAME,
validate_judge_qa_evidence_document,
)
from .judging import judge_problem
from .linting import (
BUILD_MANIFEST_SCHEMA_VERSION,
Expand Down Expand Up @@ -211,6 +217,9 @@ def require_collection_sealed(plan):
f"{problem_id}: sealed revision does not match live {', '.join(mismatches)}"
)
continue
if (qa_error := _sealed_judge_qa_error(item, checkpoint)) is not None:
rejected.append(f"{problem_id}: {qa_error}")
continue
checkpoints[problem_id] = checkpoint

if rejected:
Expand All @@ -222,6 +231,27 @@ def require_collection_sealed(plan):
return checkpoints


def _sealed_judge_qa_error(item, checkpoint):
inspection = inspect_judge_qa(item.problem_dir, item.config)
if not inspection.get("configured"):
return None
evidence = checkpoint.get("evidence")
judge_qa = evidence.get("judge_qa") if isinstance(evidence, dict) else None
if not isinstance(judge_qa, dict) or judge_qa.get("status") != "passed":
return "sealed revision has no passed Judge QA evidence"
evaluated = validate_judge_qa_evidence_document(
judge_qa,
item.config,
inspection,
item.source_hash,
item.data_hash,
)
if evaluated.get("state") != "current":
reason = evaluated.get("reason") or evaluated.get("state") or "invalid"
return f"sealed Judge QA evidence is {reason}"
return None


def assert_collection_seals_unchanged(plan, checkpoints):
"""Revalidate the exact sealed revisions immediately before publication."""
rejected = []
Expand Down Expand Up @@ -252,6 +282,8 @@ def assert_collection_seals_unchanged(plan, checkpoints):
or checkpoint.get("data_hash") != item.data_hash
):
rejected.append(f"{problem_id}: sealed revision changed during build")
elif (qa_error := _sealed_judge_qa_error(item, checkpoint)) is not None:
rejected.append(f"{problem_id}: {qa_error}")
if rejected:
raise ProbHubError(
"sealed build evidence changed during the run: " + "; ".join(rejected),
Expand Down Expand Up @@ -298,6 +330,9 @@ def _snapshot_ignore(plan):
"generation.lock",
"generations",
EVIDENCE_LOCK_FILENAME,
JUDGE_QA_EVIDENCE_FILENAME,
JUDGE_QA_EVIDENCE_LOCK_FILENAME,
"compile",
"sandbox-cache-v1.json.tmp",
"stress",
"submissions",
Expand All @@ -321,6 +356,10 @@ def ignore(directory, names):
result.append(name)
if directory.name == ".probhub" and name.startswith(EVIDENCE_FILENAME + "."):
result.append(name)
if directory.name == ".probhub" and name.startswith(
JUDGE_QA_EVIDENCE_FILENAME + "."
):
result.append(name)
return result

return ignore
Expand Down
77 changes: 72 additions & 5 deletions probhub/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
generation_status,
)
from .io import atomic_write_bytes, atomic_write_text, write_yaml
from .judge_qa import judge_qa_problem
from .judging import check_sample_answers, judge_problem
from .linting import (
compute_collection_hash,
Expand Down Expand Up @@ -492,6 +493,24 @@ def command_judge(args):
return {"ok": all(item["ok"] for item in results.values()), "problems": results}


def command_judge_qa(args):
root, workspace = workspace_context(args)
ensure_no_pending_transactions(root, workspace)
_ensure_local_gitignore(root)
results = {}
for entry in select_entries(workspace, args.problem):
problem_dir, _ = load_problem(root, entry)
results[entry["id"]] = judge_qa_problem(
root,
problem_dir,
use_cache=not args.no_cache,
)
return {
"ok": all(item["ok"] for item in results.values()),
"problems": results,
}


def command_sample_check(args):
root, workspace = workspace_context(args)
ensure_no_pending_transactions(root, workspace)
Expand Down Expand Up @@ -576,6 +595,9 @@ def command_seal(args):
f"cannot seal {entry['id']}: " + "; ".join(messages),
code="seal_lint_failed",
)
qa_configured = bool(
(lint["problems"][0].get("judge_qa") or {}).get("configured")
)

problem_dir, config = load_problem(root, entry)
source_hash = compute_source_hash(problem_dir, config)
Expand All @@ -586,10 +608,41 @@ def command_seal(args):
f"cannot seal {entry['id']}: sandbox failed: {judge.get('final')}",
code="seal_judge_failed",
)
# Judge publishes the successful local calibration evidence. Refresh the
# read-only lint view so the sealed checkpoint does not preserve the
# pre-judge "evidence missing" warning.
judge_qa = judge_qa_problem(
root,
problem_dir,
use_cache=not args.no_cache,
)
qa_status = judge_qa.get("status")
qa_passed = (
judge_qa.get("ok") is True
and (
qa_status == "passed"
if qa_configured
else qa_status == "not-configured"
)
)
if not qa_passed:
raise ProbHubError(
f"cannot seal {entry['id']}: Judge QA failed: "
f"{qa_status or 'unknown'} ({judge_qa.get('code') or 'no-code'})",
code="seal_judge_qa_failed",
)
# Judge and Judge QA publish local evidence. Refresh the read-only lint
# view so the checkpoint records the post-verification states.
lint = lint_workspace(root, workspace, [entry])
evidence_state = (
((lint["problems"][0].get("judge_qa") or {}).get("evidence") or {}).get(
"state"
)
)
expected_evidence_state = "current" if qa_configured else "not-configured"
if evidence_state != expected_evidence_state:
raise ProbHubError(
f"cannot seal {entry['id']}: Judge QA evidence is "
f"{evidence_state or 'missing'}",
code="seal_judge_qa_failed",
)

stress = None
if config.get("stress"):
Expand Down Expand Up @@ -629,6 +682,20 @@ def command_seal(args):
"summaries": judge.get("summaries", []),
"calibration": judge.get("calibration"),
},
"judge_qa": (
{
"status": "not-configured",
"applicable": False,
"judge_type": judge_qa.get("judge_type"),
}
if qa_status == "not-configured"
else {
"status": "passed",
"applicable": True,
"code": judge_qa.get("code"),
**dict(judge_qa.get("evidence") or {}),
}
),
"stress": stress,
}
checkpoint = create_problem_checkpoint(
Expand Down Expand Up @@ -756,12 +823,12 @@ def build_parser():
report.add_argument("--format", choices=("text", "markdown"), default="text")
report.set_defaults(handler=command_report, renderer=render_report_result)

for name, handler in (("lint", command_lint), ("status", command_status), ("judge", command_judge), ("sample-check", command_sample_check), ("typeset", command_typeset), ("package", command_package), ("build", command_build)):
for name, handler in (("lint", command_lint), ("status", command_status), ("judge", command_judge), ("judge-qa", command_judge_qa), ("sample-check", command_sample_check), ("typeset", command_typeset), ("package", command_package), ("build", command_build)):
item = sub.add_parser(name)
item.add_argument("problem", nargs="*")
if name == "package":
item.add_argument("--allow-missing-pdf", action="store_true")
if name in {"judge", "sample-check", "build"}:
if name in {"judge", "judge-qa", "sample-check", "build"}:
item.add_argument("--no-cache", action="store_true", help="ignore existing sandbox caches and refresh them")
if name == "build":
item.add_argument("--skip-judge", action="store_true")
Expand Down
4 changes: 4 additions & 0 deletions probhub/judge_qa.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
import unicodedata
from pathlib import Path, PurePosixPath

from .judge_qa_evidence import (
evaluate_judge_qa_evidence,
validate_judge_qa_evidence_document,
)
from .problem_paths import ProblemPathError, resolve_problem_regular_file


Expand Down
Loading
Loading