Reduce run-llm to a pure batch executor

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-17 22:38:26 +08:00
co-authored by Claude Fable 5
parent 7a58f37d9f
commit 93a4f808bf
6 changed files with 263 additions and 439 deletions
+9 -6
View File
@@ -17,12 +17,15 @@
unarbitrated. If arbitration output is unexpected, revise unarbitrated. If arbitration output is unexpected, revise
the definition file and repeat that cycle; never patch the definition file and repeat that cycle; never patch
results by hand. results by hand.
- The current execution of each step is archived - Each run of a step is archived self-contained under the
self-contained under destination directory given explicitly on the `run-llm`
`runs/<definition-file>/`: prompt snapshot, command line (by convention `runs/<definition-file>/run<N>/`):
raw outputs of both runs, arbitration output, and `meta.json` prompt snapshot, raw output, and `meta.json` (model ID,
(model ID, parameters, timestamps, batch IDs). A rerun parameters, timestamps, batch ID). The two runs of a step
replaces the directory; superseded runs live in git history. are two separate invocations of `run-llm`. An arbitration
pass is a step of its own with its own archive. Replacing
an existing run archive requires an explicit flag;
superseded runs live in git history.
- Scripts read the API key from the `ANTHROPIC_API_KEY` - Scripts read the API key from the `ANTHROPIC_API_KEY`
environment variable (`.env`, gitignored). environment variable (`.env`, gitignored).
+3 -2
View File
@@ -26,8 +26,9 @@ Hot 10020162025)為例的內容分析。
--lyrics-dir ../data/captures/lyrics --lyrics-dir ../data/captures/lyrics
--wikidata-csv ../data/captures/artists-wikidata.csv`)。 --wikidata-csv ../data/captures/artists-wikidata.csv`)。
LLM 步驟使用 `claude-sonnet-4-6`、temperature=0、 LLM 步驟使用 `claude-sonnet-4-6`、temperature=0、
thinking 關閉;每步驟獨立執行兩次後由仲裁步驟合併 thinking 關閉;輸出可逐項比對的步驟獨立執行兩次,由
(「2+1」協定) 比對程式算出分歧、仲裁步驟裁決(「2+1」協定),自由
生成步驟兩次執行進池不仲裁。
4. 每次執行的完整紀錄(定義檔快照、原始輸出、參數)存於 4. 每次執行的完整紀錄(定義檔快照、原始輸出、參數)存於
`runs/`,可逐筆稽核。論文引用的最終資料表在 `results/` `runs/`,可逐筆稽核。論文引用的最終資料表在 `results/`
+19
View File
@@ -304,3 +304,22 @@
`<定義檔名>/`——同理不編日期:只存一份,重跑即取代, `<定義檔名>/`——同理不編日期:只存一份,重跑即取代,
被取代的執行在 git 歷史,時間戳在 meta.json。四次收斂執行(merge ×2、cap ×2 被取代的執行在 git 歷史,時間戳在 meta.json。四次收斂執行(merge ×2、cap ×2
各存合併記錄 JSON 隨該次執行入 runs/。 各存合併記錄 JSON 隨該次執行入 runs/。
- **`run-llm` 降階為純執行器**:原內建的 2+1 編排(文字級
一致性判定、固定模板仲裁)拆除;run-llm 只負責「一份
定義檔+一份輸入 JSONL,跑 N 次(預設 2、仲裁場合 1),
歸檔 `runs/<定義檔名>/`(重跑即取代)」。理由:一致性
判定須逐步驟、結構性(分組比對、標籤集合比對),屬
確定性腳本的工作;仲裁輸入由比對腳本建構,本身成為
runs/ 可稽核工件;執行器變小易測。比對/進池/裁決
套用子命令另行實作。
- **`run-llm` 一次呼叫即一次執行**`--runs``--run` 皆不存
在,完整命令形狀為 `run-llm <定義檔> <輸入檔> <歸檔目錄>`——三個
必要運算元皆為位置引數(沿 2026-08-02「必要運算元為位置引數」慣
例),歸檔目錄如 `runs/01-01-tag/run1`,工具內部零 run 概念,
meta 不記 run 編號;「獨立執行兩次」=重現命令清單上的兩行命令,
run 身分只活在命令清單與目錄佈局約定,不在工具內。理由:兩次執行
互相獨立是方法學宣稱,其證據應由執行結構與重現命令清單自明,不應
要求讀者讀工具原始碼;歸檔目的地為顯式引數,亦是「資料路徑全面顯
式化、消滅隱性推導」原則的貫徹。比對子命令驗證兩 run 的定義檔與
輸入 SHA 一致、缺 run 即失敗。目標目錄已存在即拒絕執行,重跑須明
`--replace`
+9 -9
View File
@@ -45,23 +45,23 @@ pop-fem-audit/
│ │ │ │ # Wikidata into the snapshot CSV │ │ │ │ # Wikidata into the snapshot CSV
│ │ │ ├── fetch_lyrics.py # fetch missing lyrics from the │ │ │ ├── fetch_lyrics.py # fetch missing lyrics from the
│ │ │ │ # public APIs into the lyrics dir │ │ │ │ # public APIs into the lyrics dir
│ │ │ └── run_llm.py # API runner2+1 協定、Batch API、 │ │ │ └── run_llm.py # API 執行器:一份定義檔+一份輸入
│ │ │ # 寫入引數指定的 runs 目錄;執行 │ │ │ # →歸檔至指定目錄(Batch API);
│ │ │ # 方式 pop-fem-audit-tools run-llm │ │ │ # 比對與仲裁編排由獨立子命令承擔
│ │ ├── config.py # pydantic-settings 設定(.env │ │ ├── config.py # pydantic-settings 設定(.env
│ │ ├── database.py # SQLAlchemy engine / session / Base │ │ ├── database.py # SQLAlchemy engine / session / Base
│ │ ├── models.py # SQLAlchemy ORM 資料模型 │ │ ├── models.py # SQLAlchemy ORM 資料模型
│ │ └── utils.py # 共用工具(format_duration │ │ └── utils.py # 共用工具(format_duration
│ └── tests/ # 單元測試(unittest │ └── tests/ # 單元測試(unittest
├── runs/ # 現行執行的完整稽核紀錄(進 git; ├── runs/ # 現行執行的完整稽核紀錄(進 git;
│ │ # 重跑即取代,舊執行在 git 歷史 │ │ # 重跑同一 run 須明示 --replace
│ └── <定義檔名>/ │ └── <定義檔名>/ # 仲裁步驟居自己的 <task>-arb/
│ └── run<N>/ # 每個 run 一份自我完備歸檔
│ ├── prompt.md # 當次定義檔快照(自我完備) │ ├── prompt.md # 當次定義檔快照(自我完備)
├── run1.jsonl # 第一次執行原始輸出 ├── output.jsonl # 該次執行原始輸出
│ ├── run2.jsonl # 第二次執行原始輸出
│ ├── arbitration.jsonl # 仲裁輸出
│ └── meta.json # model ID、temperature、時間戳、 │ └── meta.json # model ID、temperature、時間戳、
│ # batch ID、一致率 │ # batch ID、token 用量
│ # (一致率由比對子命令記錄)
├── results/ # 論文引用的報表 CSV(export 產出; ├── results/ # 論文引用的報表 CSV(export 產出;
│ # 「可再生仍 commit」的唯一例外) │ # 「可再生仍 commit」的唯一例外)
├── docs/ ├── docs/
+89 -233
View File
@@ -4,19 +4,25 @@
# Authors: # Authors:
# imacat@mail.imacat.idv.tw (imacat), 2026/7/30 # imacat@mail.imacat.idv.tw (imacat), 2026/7/30
# AI assistance: Claude Code (Anthropic) # AI assistance: Claude Code (Anthropic)
"""The generic batch runner for one LLM analysis step. """The generic batch executor for one LLM analysis step.
Sends every input item to the Anthropic Messages Batch API twice with One invocation is one definition file plus one input, sent to the
the same system prompt, reconciles the disagreeing items with a third Anthropic Messages Batch API exactly once, archived self-contained
arbitration batch, and archives every artifact self-contained under under the destination directory given by the three positional
``<runs_dir>/<phase>/<YYYYMMDD-HHMM>-<prompt-stem>/``, where the command-line arguments: prompt, input, archive_dir. The tool
base directory of the run archives is given as the positional knows nothing about run counts or protocols: run identity --
command-line argument. run1/run2, arbitration -- lives entirely in the caller's command
list, per the research plan. A rerun of an already existing
destination requires ``--replace``; any other directory is never
touched.
Comparing runs and reconciling disagreements are the responsibility
of separate subcommands, not this one.
""" """
import argparse import argparse
import enum
import hashlib import hashlib
import json import json
import shutil
import sys import sys
import time import time
from dataclasses import asdict, dataclass from dataclasses import asdict, dataclass
@@ -31,21 +37,8 @@ from ..config import get_settings
MODEL: str = "claude-sonnet-4-6" MODEL: str = "claude-sonnet-4-6"
TEMPERATURE: float = 0.0 TEMPERATURE: float = 0.0
THINKING: dict[str, str] = {"type": "disabled"} THINKING: dict[str, str] = {"type": "disabled"}
SCRIPT_VERSION: str = "run_llm.py 1.0.0" SCRIPT_VERSION: str = "run_llm.py 3.0.0"
POLL_INTERVAL_SECONDS: float = 60.0 POLL_INTERVAL_SECONDS: float = 60.0
ARBITRATION_TEMPLATE: str = (
"<item>\n{content}\n</item>\n"
"<run1>\n{run1}\n</run1>\n"
"<run2>\n{run2}\n</run2>")
class Source(enum.StrEnum):
"""The source of a final record."""
AGREED = "agreed"
"""The record takes the run text the two runs agreed on."""
ARBITRATION = "arbitration"
"""The record takes the arbitration output."""
class InputFormatError(Exception): class InputFormatError(Exception):
@@ -174,28 +167,26 @@ def parse_args(argv: list[str] | None) -> argparse.Namespace:
:return: The parsed arguments. :return: The parsed arguments.
""" """
parser: argparse.ArgumentParser = argparse.ArgumentParser( parser: argparse.ArgumentParser = argparse.ArgumentParser(
description="Run one LLM step: 2 runs + 1 arbitration.") description="Run one LLM definition file against one input"
" and archive the result.")
parser.add_argument( parser.add_argument(
"runs_dir", type=Path, "prompt", type=Path,
help="the base directory of the run archives")
parser.add_argument(
"--prompt", required=True, type=Path,
help="the prompt definition file, used as the system prompt") help="the prompt definition file, used as the system prompt")
parser.add_argument( parser.add_argument(
"--arbitration-prompt", required=True, type=Path, "input", type=Path,
help="the arbitration prompt definition file")
parser.add_argument(
"--input", required=True, type=Path,
help="the JSONL input file with \"id\" and \"content\"") help="the JSONL input file with \"id\" and \"content\"")
parser.add_argument( parser.add_argument(
"--phase", required=True, "archive_dir", type=Path,
help="the phase name for the archive directory") help="the destination archive directory")
parser.add_argument( parser.add_argument(
"--max-tokens", type=int, default=2048, "--max-tokens", type=int, default=2048,
help="the maximum output tokens per request (default 2048)") help="the maximum output tokens per request (default 2048)")
parser.add_argument( parser.add_argument(
"--dry-run", action="store_true", "--dry-run", action="store_true",
help="validate and archive without calling the API") help="validate and archive without calling the API")
parser.add_argument(
"--replace", action="store_true",
help="replace an already existing archive directory")
return parser.parse_args(argv) return parser.parse_args(argv)
@@ -256,19 +247,6 @@ def build_request(item: InputItem, system_prompt: str,
} }
def build_arbitration_content(content: str, run1_text: str,
run2_text: str) -> str:
"""Build the arbitration user message for one item.
:param content: The original item content.
:param run1_text: The run-1 output text.
:param run2_text: The run-2 output text.
:return: The user message text.
"""
return ARBITRATION_TEMPLATE.format(
content=content, run1=run1_text, run2=run2_text)
def submit_batch(client: anthropic.Anthropic, def submit_batch(client: anthropic.Anthropic,
requests: list[dict[str, Any]]) -> str: requests: list[dict[str, Any]]) -> str:
"""Submit one message batch. """Submit one message batch.
@@ -314,6 +292,22 @@ def usage_to_dict(usage: Any) -> dict[str, Any]:
if v is not None} if v is not None}
def sum_usage(results: Results) -> dict[str, int]:
"""Sum the token usage of every succeeded result.
:param results: The result records, keyed by item ID.
:return: The summed integer usage fields.
"""
totals: dict[str, int] = {}
for result in results.values():
if result.usage is None:
continue
for key, value in result.usage.items():
if isinstance(value, int):
totals[key] = totals.get(key, 0) + value
return totals
def collect_results(client: anthropic.Anthropic, def collect_results(client: anthropic.Anthropic,
batch_id: str) -> Results: batch_id: str) -> Results:
"""Collect the results of an ended batch. """Collect the results of an ended batch.
@@ -343,78 +337,25 @@ def find_failures(item_ids: list[str],
if x not in results or results[x].is_failure] if x not in results or results[x].is_failure]
def split_by_agreement( def create_archive_dir(directory: Path, replace: bool) -> Path:
items: list[InputItem], run1: Results, run2: Results, """Create the archive directory.
) -> tuple[list[str], list[str]]:
"""Split the item IDs into agreed and disagreeing ones.
Two outputs agree when their texts are identical after strip(). Only this directory is ever created or removed; no other
directory is ever touched.
:param items: The input items. :param directory: The destination archive directory.
:param run1: The run-1 results, keyed by item ID. :param replace: Whether to remove an already existing archive
:param run2: The run-2 results, keyed by item ID. directory before creating it.
:return: A tuple of the agreed item IDs and the disagreeing item
IDs, both in input order.
"""
agreed: list[str] = []
disagreed: list[str] = []
for item in items:
text1: str | None = run1[item.id].text
text2: str | None = run2[item.id].text
assert text1 is not None and text2 is not None
if text1.strip() == text2.strip():
agreed.append(item.id)
else:
disagreed.append(item.id)
return agreed, disagreed
def build_final_records(
items: list[InputItem], run1: Results, arbitration: Results,
) -> list[dict[str, str]]:
"""Assemble the final records, one per item, in input order.
An arbitrated item takes the arbitration output; an agreed item
takes the agreed (stripped) run text.
:param items: The input items.
:param run1: The run-1 results, keyed by item ID.
:param arbitration: The arbitration results, keyed by item ID.
:return: The final records with "id", "text", and "source".
"""
records: list[dict[str, str]] = []
for item in items:
text: str | None
if item.id in arbitration:
text = arbitration[item.id].text
assert text is not None
records.append({"id": item.id, "text": text,
"source": Source.ARBITRATION})
else:
text = run1[item.id].text
assert text is not None
records.append({"id": item.id, "text": text.strip(),
"source": Source.AGREED})
return records
def create_archive_dir(runs_root: Path, phase: str, prompt_path: Path,
now: datetime) -> Path:
"""Create the archive directory for this execution.
:param runs_root: The root directory of the run archives.
:param phase: The phase name.
:param prompt_path: The path of the prompt definition file.
:param now: The local timestamp of this execution.
:return: The created archive directory. :return: The created archive directory.
:raises FileExistsError: When the directory already exists. :raises FileExistsError: When the archive directory already
exists and ``replace`` is False.
""" """
stem: str = prompt_path.stem
name: str = f"{now.strftime('%Y%m%d-%H%M')}-{stem}"
directory: Path = runs_root / phase / name
if directory.exists(): if directory.exists():
if not replace:
raise FileExistsError( raise FileExistsError(
f"archive directory {directory} already exists") f"{directory} already exists; pass --replace to"
" replace it")
shutil.rmtree(directory)
directory.mkdir(parents=True) directory.mkdir(parents=True)
return directory return directory
@@ -446,17 +387,14 @@ def write_json(path: Path, data: dict[str, Any]) -> None:
def write_meta(path: Path, meta: dict[str, Any]) -> None: def write_meta(path: Path, meta: dict[str, Any]) -> None:
"""Write the metadata to the ``meta.json`` file. """Write the metadata to the ``meta.json`` file.
The ``BatchInfo`` values under ``batches`` are written as The ``BatchInfo`` value under ``batch`` is written as a plain
plain JSON objects. JSON object.
:param path: The path of the ``meta.json`` file. :param path: The path of the ``meta.json`` file.
:param meta: The metadata to write. :param meta: The metadata to write.
:return: None. :return: None.
""" """
write_json(path, { write_json(path, {**meta, "batch": asdict(meta["batch"])})
**meta,
"batches": {k: asdict(v)
for k, v in meta["batches"].items()}})
def sha256_of(path: Path) -> str: def sha256_of(path: Path) -> str:
@@ -477,13 +415,14 @@ def now_iso() -> str:
return datetime.now().astimezone().isoformat(timespec="seconds") return datetime.now().astimezone().isoformat(timespec="seconds")
def execute_runs( def execute_run(
client: anthropic.Anthropic, items: list[InputItem], client: anthropic.Anthropic, items: list[InputItem],
system_prompt: str, max_tokens: int, meta: dict[str, Any], system_prompt: str, max_tokens: int,
) -> tuple[Results, Results]: meta: dict[str, Any],
"""Submit the two identical runs and await their results. ) -> Results:
"""Submit the batch of this run and await its results.
The batch IDs and timestamps are recorded into the metadata as an The batch ID and timestamps are recorded into the metadata as an
observable side effect. observable side effect.
:param client: The Anthropic client. :param client: The Anthropic client.
@@ -491,72 +430,22 @@ def execute_runs(
:param system_prompt: The system prompt text. :param system_prompt: The system prompt text.
:param max_tokens: The maximum output tokens per request. :param max_tokens: The maximum output tokens per request.
:param meta: The metadata to record the batch bookkeeping into. :param meta: The metadata to record the batch bookkeeping into.
:return: The results of run 1 and run 2, keyed by item ID. :return: The results of this run, keyed by item ID.
""" """
requests: list[dict[str, Any]] = [ requests: list[dict[str, Any]] = [
build_request(x, system_prompt, max_tokens) for x in items] build_request(x, system_prompt, max_tokens) for x in items]
infos: dict[str, BatchInfo] = {}
for run_name in ("run1", "run2"):
info: BatchInfo = BatchInfo( info: BatchInfo = BatchInfo(
batch_id=submit_batch(client, requests), batch_id=submit_batch(client, requests),
submitted_at=now_iso()) submitted_at=now_iso())
infos[run_name] = info meta["batch"] = info
meta["batches"][run_name] = info print(f"submitted batch {info.batch_id}", file=sys.stderr)
print(f"{run_name}: submitted batch {info.batch_id}",
file=sys.stderr)
batches: dict[str, Any] = poll_batches(
client, [x.batch_id for x in infos.values()])
for info in infos.values():
info.ended_at = batches[info.batch_id].ended_at.isoformat()
return (collect_results(client, infos["run1"].batch_id),
collect_results(client, infos["run2"].batch_id))
def execute_arbitration(
client: anthropic.Anthropic, items: list[InputItem],
disagreed: list[str], run1: Results, run2: Results,
system_prompt: str, max_tokens: int, meta: dict[str, Any],
) -> Results:
"""Submit the arbitration batch and await its results.
The batch ID and timestamps are recorded into the metadata as an
observable side effect.
:param client: The Anthropic client.
:param items: The input items.
:param disagreed: The disagreeing item IDs.
:param run1: The run-1 results, keyed by item ID.
:param run2: The run-2 results, keyed by item ID.
:param system_prompt: The arbitration system prompt text.
:param max_tokens: The maximum output tokens per request.
:param meta: The metadata to record the batch bookkeeping into.
:return: The arbitration results, keyed by item ID.
"""
content_by_id: dict[str, str] = {
x.id: x.content for x in items}
requests: list[dict[str, Any]] = []
for item_id in disagreed:
text1: str | None = run1[item_id].text
text2: str | None = run2[item_id].text
assert text1 is not None and text2 is not None
requests.append(build_request(
InputItem(id=item_id,
content=build_arbitration_content(
content_by_id[item_id], text1, text2)),
system_prompt, max_tokens))
info: BatchInfo = BatchInfo(
batch_id=submit_batch(client, requests),
submitted_at=now_iso())
meta["batches"]["arbitration"] = info
print(f"arbitration: submitted batch {info.batch_id}",
file=sys.stderr)
batches: dict[str, Any] = poll_batches(client, [info.batch_id]) batches: dict[str, Any] = poll_batches(client, [info.batch_id])
info.ended_at = batches[info.batch_id].ended_at.isoformat() info.ended_at = batches[info.batch_id].ended_at.isoformat()
return collect_results(client, info.batch_id) return collect_results(client, info.batch_id)
def main(argv: list[str] | None = None) -> int: def main(argv: list[str] | None = None) -> int:
"""Run one LLM step end-to-end. """Run one LLM definition file against one input and archive it.
:param argv: The command-line arguments, or None for ``sys.argv``. :param argv: The command-line arguments, or None for ``sys.argv``.
:return: The exit status: 0 on success, non-zero on failure. :return: The exit status: 0 on success, non-zero on failure.
@@ -565,89 +454,56 @@ def main(argv: list[str] | None = None) -> int:
try: try:
items: list[InputItem] = load_items(args.input) items: list[InputItem] = load_items(args.input)
prompt_text: str = args.prompt.read_text(encoding="utf-8") prompt_text: str = args.prompt.read_text(encoding="utf-8")
arbitration_text: str = args.arbitration_prompt.read_text(
encoding="utf-8")
except (OSError, InputFormatError) as error: except (OSError, InputFormatError) as error:
print(f"error: {error}", file=sys.stderr) print(f"error: {error}", file=sys.stderr)
return 1 return 1
try: try:
run_dir: Path = create_archive_dir( archive_dir: Path = create_archive_dir(
args.runs_dir, args.phase, args.prompt, args.archive_dir, args.replace)
datetime.now())
except FileExistsError as error: except FileExistsError as error:
print(f"error: {error}", file=sys.stderr) print(f"error: {error}", file=sys.stderr)
return 1 return 1
meta_path: Path = run_dir / "meta.json" meta_path: Path = archive_dir / "meta.json"
(run_dir / "prompt.md").write_bytes(args.prompt.read_bytes()) (archive_dir / "prompt.md").write_bytes(args.prompt.read_bytes())
(run_dir / "arbitration_prompt.md").write_bytes(
args.arbitration_prompt.read_bytes())
meta: dict[str, Any] = { meta: dict[str, Any] = {
"script_version": SCRIPT_VERSION,
"model": MODEL, "model": MODEL,
"temperature": TEMPERATURE, "temperature": TEMPERATURE,
"max_tokens": args.max_tokens,
"thinking": THINKING, "thinking": THINKING,
"max_tokens": args.max_tokens,
"prompt_path": str(args.prompt), "prompt_path": str(args.prompt),
"prompt_sha256": sha256_of(args.prompt), "prompt_sha256": sha256_of(args.prompt),
"arbitration_prompt_path": str(args.arbitration_prompt), "input_path": str(args.input),
"arbitration_prompt_sha256": sha256_of( "input_sha256": sha256_of(args.input),
args.arbitration_prompt),
"batches": {},
"item_count": len(items), "item_count": len(items),
"agreed_count": None,
"agreement_rate": None,
"dry_run": args.dry_run, "dry_run": args.dry_run,
"script_version": SCRIPT_VERSION, "started_at": now_iso(),
"batch": None,
"usage": {},
} }
if args.dry_run: if args.dry_run:
write_meta(meta_path, meta) write_json(meta_path, meta)
print(json.dumps( print(json.dumps(
build_request(items[0], prompt_text, args.max_tokens), build_request(items[0], prompt_text, args.max_tokens),
ensure_ascii=False, indent=2)) ensure_ascii=False, indent=2))
print(f"dry run: archive created at {run_dir}", print(f"dry run: archive created at {archive_dir}",
file=sys.stderr) file=sys.stderr)
return 0 return 0
client: anthropic.Anthropic = anthropic.Anthropic( client: anthropic.Anthropic = anthropic.Anthropic(
api_key=get_settings().ANTHROPIC_API_KEY) api_key=get_settings().ANTHROPIC_API_KEY)
run1: Results results: Results = execute_run(
run2: Results
run1, run2 = execute_runs(
client, items, prompt_text, args.max_tokens, meta) client, items, prompt_text, args.max_tokens, meta)
item_ids: list[str] = [x.id for x in items] item_ids: list[str] = [x.id for x in items]
write_jsonl(run_dir / "run1.jsonl", write_jsonl(
[run1[x].to_record() for x in item_ids if x in run1]) archive_dir / "output.jsonl",
write_jsonl(run_dir / "run2.jsonl", [results[x].to_record() for x in item_ids if x in results])
[run2[x].to_record() for x in item_ids if x in run2]) meta["usage"] = sum_usage(results)
failed: set[str] = (set(find_failures(item_ids, run1)) write_meta(meta_path, meta)
| set(find_failures(item_ids, run2))) failed: list[str] = find_failures(item_ids, results)
if len(failed) > 0: if len(failed) > 0:
write_meta(meta_path, meta) print(f"error: failed items: {', '.join(failed)}",
names: str = ", ".join(x for x in item_ids if x in failed)
print(f"error: failed items: {names}", file=sys.stderr)
return 1
agreed: list[str]
disagreed: list[str]
agreed, disagreed = split_by_agreement(items, run1, run2)
meta["agreed_count"] = len(agreed)
meta["agreement_rate"] = len(agreed) / len(items)
arbitration: Results = {}
if len(disagreed) > 0:
arbitration = execute_arbitration(
client, items, disagreed, run1, run2, arbitration_text,
args.max_tokens, meta)
write_jsonl(run_dir / "arbitration.jsonl",
[arbitration[x].to_record() for x in disagreed
if x in arbitration])
arb_failed: list[str] = find_failures(disagreed, arbitration)
if len(arb_failed) > 0:
write_meta(meta_path, meta)
names = ", ".join(arb_failed)
print(f"error: failed arbitration items: {names}",
file=sys.stderr) file=sys.stderr)
return 1 return 1
write_jsonl(run_dir / "final.jsonl", print(f"done: {len(items)} items;"
build_final_records(items, run1, arbitration)) f" archived to {archive_dir}", file=sys.stderr)
write_meta(meta_path, meta)
print(f"done: {len(items)} items, {len(agreed)} agreed,"
f" {len(disagreed)} arbitrated; archived to {run_dir}",
file=sys.stderr)
return 0 return 0
+129 -184
View File
@@ -3,7 +3,7 @@
# Authors: # Authors:
# imacat@mail.imacat.idv.tw (imacat), 2026/7/30 # imacat@mail.imacat.idv.tw (imacat), 2026/7/30
# AI assistance: Claude Code (Anthropic) # AI assistance: Claude Code (Anthropic)
"""Unit tests for the run_llm batch runner module.""" """Unit tests for the run_llm batch executor module."""
import io import io
import json import json
import tempfile import tempfile
@@ -180,62 +180,6 @@ class TestRequestBuilding(RunLLMTestCase):
self.assertEqual(params["messages"], self.assertEqual(params["messages"],
[{"role": "user", "content": "the lyrics"}]) [{"role": "user", "content": "the lyrics"}])
def test_arbitration_content(self) -> None:
"""Test the arbitration user message format."""
content: str = run_llm.build_arbitration_content(
"the item", "output one", "output two")
self.assertEqual(content,
"<item>\nthe item\n</item>\n"
"<run1>\noutput one\n</run1>\n"
"<run2>\noutput two\n</run2>")
class TestAgreement(RunLLMTestCase):
"""Test cases for the agreement computation."""
def test_split_by_agreement(self) -> None:
"""Test splitting items into agreed and disagreeing ones."""
items: list[run_llm.InputItem] = [
run_llm.InputItem(id="a", content="one"),
run_llm.InputItem(id="b", content="two"),
run_llm.InputItem(id="c", content="three")]
run1: run_llm.Results = {
"a": run_llm.BatchResult(id="a", text="same\n"),
"b": run_llm.BatchResult(id="b", text="left"),
"c": run_llm.BatchResult(id="c", text=" padded ")}
run2: run_llm.Results = {
"a": run_llm.BatchResult(id="a", text="same"),
"b": run_llm.BatchResult(id="b", text="right"),
"c": run_llm.BatchResult(id="c", text="padded")}
agreed: list[str]
disagreed: list[str]
agreed, disagreed = run_llm.split_by_agreement(
items, run1, run2)
self.assertEqual(agreed, ["a", "c"])
self.assertEqual(disagreed, ["b"])
class TestFinalRecords(RunLLMTestCase):
"""Test cases for the final record assembly."""
def test_build_final_records(self) -> None:
"""Test assembling final records from runs and arbitration."""
items: list[run_llm.InputItem] = [
run_llm.InputItem(id="a", content="one"),
run_llm.InputItem(id="b", content="two")]
run1: run_llm.Results = {
"a": run_llm.BatchResult(id="a", text="agreed text\n"),
"b": run_llm.BatchResult(id="b", text="left")}
arbitration: run_llm.Results = {
"b": run_llm.BatchResult(id="b",
text="arbitrated text")}
records: list[dict[str, str]] = run_llm.build_final_records(
items, run1, arbitration)
self.assertEqual(records, [
{"id": "a", "text": "agreed text", "source": "agreed"},
{"id": "b", "text": "arbitrated text",
"source": "arbitration"}])
class TestCollectResults(RunLLMTestCase): class TestCollectResults(RunLLMTestCase):
"""Test cases for the batch result collection.""" """Test cases for the batch result collection."""
@@ -266,6 +210,20 @@ class TestCollectResults(RunLLMTestCase):
run_llm.find_failures(["a", "b", "c"], results), run_llm.find_failures(["a", "b", "c"], results),
["b", "c"]) ["b", "c"])
def test_sum_usage(self) -> None:
"""Test summing the token usage across results."""
results: run_llm.Results = {
"a": run_llm.BatchResult(
id="a", text="fine",
usage={"input_tokens": 10, "output_tokens": 5}),
"b": run_llm.BatchResult(
id="b", text="fine",
usage={"input_tokens": 3, "output_tokens": 2}),
"c": run_llm.BatchResult(id="c", error="errored")}
self.assertEqual(
run_llm.sum_usage(results),
{"input_tokens": 13, "output_tokens": 7})
class TestArchive(RunLLMTestCase): class TestArchive(RunLLMTestCase):
"""Test cases for the archive directory handling.""" """Test cases for the archive directory handling."""
@@ -275,23 +233,43 @@ class TestArchive(RunLLMTestCase):
self.__dir: Path = self._make_temp_dir() self.__dir: Path = self._make_temp_dir()
def test_create_archive_dir(self) -> None: def test_create_archive_dir(self) -> None:
"""Test the archive directory naming and creation.""" """Test the archive directory creation."""
now: datetime = datetime(2026, 7, 30, 20, 5) target: Path = self.__dir / "01-01-tag" / "run1"
directory: Path = run_llm.create_archive_dir( directory: Path = run_llm.create_archive_dir(target, False)
self.__dir, "coding", Path("prompts/gender_v3.md"), now)
self.assertTrue(directory.is_dir()) self.assertTrue(directory.is_dir())
self.assertEqual( self.assertEqual(directory, target)
directory,
self.__dir / "coding" / "20260730-2005-gender_v3")
def test_existing_archive_dir_rejected(self) -> None: def test_existing_archive_dir_rejected_without_replace(
"""Test that an existing archive directory is rejected.""" self) -> None:
now: datetime = datetime(2026, 7, 30, 20, 5) """Test that an existing archive is rejected by default."""
run_llm.create_archive_dir( target: Path = self.__dir / "01-01-tag" / "run1"
self.__dir, "coding", Path("gender_v3.md"), now) run_llm.create_archive_dir(target, False)
with self.assertRaises(FileExistsError): with self.assertRaises(FileExistsError):
run_llm.create_archive_dir(target, False)
def test_existing_archive_dir_replaced(self) -> None:
"""Test that --replace replaces an existing archive."""
target: Path = self.__dir / "01-01-tag" / "run1"
first: Path = run_llm.create_archive_dir(target, False)
(first / "stale.txt").write_text("stale", encoding="utf-8")
second: Path = run_llm.create_archive_dir(target, True)
self.assertEqual(first, second)
self.assertFalse((second / "stale.txt").exists())
def test_replace_leaves_sibling_dir_untouched(self) -> None:
"""Test that replacing run2 does not touch run1."""
run1: Path = run_llm.create_archive_dir(
self.__dir / "01-01-tag" / "run1", False)
(run1 / "output.jsonl").write_text(
"run1 data", encoding="utf-8")
run2: Path = run_llm.create_archive_dir(
self.__dir / "01-01-tag" / "run2", False)
(run2 / "stale.jsonl").write_text("stale", encoding="utf-8")
run_llm.create_archive_dir( run_llm.create_archive_dir(
self.__dir, "coding", Path("gender_v3.md"), now) self.__dir / "01-01-tag" / "run2", True)
self.assertEqual(
(run1 / "output.jsonl").read_text(encoding="utf-8"),
"run1 data")
def test_write_jsonl(self) -> None: def test_write_jsonl(self) -> None:
"""Test writing records as JSON Lines.""" """Test writing records as JSON Lines."""
@@ -313,54 +291,38 @@ class TestMainFlow(RunLLMTestCase):
"""Create a temporary directory with the input files.""" """Create a temporary directory with the input files."""
directory: Path = self._make_temp_dir() directory: Path = self._make_temp_dir()
self.__runs: Path = directory / "runs" self.__runs: Path = directory / "runs"
prompt: Path = directory / "task_v1.md" self.__archive_dir: Path = self.__runs / "task_v1" / "run1"
prompt.write_text("The task prompt.\n", encoding="utf-8") self.__prompt: Path = directory / "task_v1.md"
arbitration: Path = directory / "task_arbitration_v1.md" self.__prompt.write_text(
arbitration.write_text( "The task prompt.\n", encoding="utf-8")
"The arbitration prompt.\n", encoding="utf-8")
self.__input: Path = directory / "items.jsonl" self.__input: Path = directory / "items.jsonl"
self.__input.write_text( self.__input.write_text(
'{"id": "a", "content": "first item"}\n' '{"id": "a", "content": "first item"}\n'
'{"id": "b", "content": "second item"}\n', '{"id": "b", "content": "second item"}\n',
encoding="utf-8") encoding="utf-8")
self.__argv: list[str] = [ self.__argv: list[str] = [
str(self.__runs), str(self.__prompt), str(self.__input),
"--prompt", str(prompt), str(self.__archive_dir)]
"--arbitration-prompt", str(arbitration),
"--input", str(self.__input),
"--phase", "coding"]
self.__settings: config.Settings = config.Settings( self.__settings: config.Settings = config.Settings(
SQLALCHEMY_DATABASE_URL="sqlite://", SQLALCHEMY_DATABASE_URL="sqlite://",
ANTHROPIC_API_KEY="test-key") ANTHROPIC_API_KEY="test-key")
config.set_settings(self.__settings) config.set_settings(self.__settings)
@staticmethod @staticmethod
def __make_client(run1: list[Any], run2: list[Any], def __make_client(entries: list[Any]) -> mock.Mock:
arbitration: list[Any] | None = None) \ """Create a mock Anthropic client serving canned results.
-> mock.Mock:
"""Create a mock Anthropic client serving canned batch results.
:param run1: The result entries of the run-1 batch. :param entries: The result entries of the single run batch.
:param run2: The result entries of the run-2 batch.
:param arbitration: The result entries of the arbitration
batch.
:return: The mock client. :return: The mock client.
""" """
client: mock.Mock = mock.Mock() client: mock.Mock = mock.Mock()
batch_ids: list[str] = ["batch_run1", "batch_run2", client.messages.batches.create.return_value = mock.Mock(
"batch_arb"] id="batch_run1")
client.messages.batches.create.side_effect = [
mock.Mock(id=x) for x in batch_ids]
ended: mock.Mock = mock.Mock() ended: mock.Mock = mock.Mock()
ended.processing_status = "ended" ended.processing_status = "ended"
ended.ended_at = datetime(2026, 7, 30, 20, 0) ended.ended_at = datetime(2026, 7, 30, 20, 0)
client.messages.batches.retrieve.return_value = ended client.messages.batches.retrieve.return_value = ended
results: dict[str, list[Any]] = { client.messages.batches.results.return_value = iter(entries)
"batch_run1": run1, "batch_run2": run2,
"batch_arb": arbitration if arbitration is not None
else []}
client.messages.batches.results.side_effect = (
lambda batch_id: iter(results[batch_id]))
return client return client
@staticmethod @staticmethod
@@ -380,16 +342,6 @@ class TestMainFlow(RunLLMTestCase):
status: int = run_llm.main(argv) status: int = run_llm.main(argv)
return status, stdout.getvalue() return status, stdout.getvalue()
def __archive_dir(self) -> Path:
"""Locate the single archive directory of the run.
:return: The archive directory.
"""
directories: list[Path] = list(
(self.__runs / "coding").iterdir())
self.assertEqual(len(directories), 1)
return directories[0]
def test_dry_run(self) -> None: def test_dry_run(self) -> None:
"""Test the dry-run behavior.""" """Test the dry-run behavior."""
status: int status: int
@@ -397,114 +349,107 @@ class TestMainFlow(RunLLMTestCase):
status, stdout = self.__run_main( status, stdout = self.__run_main(
self.__argv + ["--dry-run"]) self.__argv + ["--dry-run"])
self.assertEqual(status, 0) self.assertEqual(status, 0)
run_dir: Path = self.__archive_dir() run_dir: Path = self.__archive_dir
self.assertEqual((run_dir / "prompt.md").read_text( self.assertEqual((run_dir / "prompt.md").read_text(
encoding="utf-8"), "The task prompt.\n") encoding="utf-8"), "The task prompt.\n")
self.assertEqual(
(run_dir / "arbitration_prompt.md").read_text(
encoding="utf-8"), "The arbitration prompt.\n")
meta: dict[str, Any] = json.loads( meta: dict[str, Any] = json.loads(
(run_dir / "meta.json").read_text(encoding="utf-8")) (run_dir / "meta.json").read_text(encoding="utf-8"))
self.assertTrue(meta["dry_run"]) self.assertTrue(meta["dry_run"])
self.assertEqual(meta["item_count"], 2) self.assertEqual(meta["item_count"], 2)
self.assertEqual(meta["model"], "claude-sonnet-4-6") self.assertEqual(meta["model"], "claude-sonnet-4-6")
self.assertFalse((run_dir / "run1.jsonl").exists()) self.assertNotIn("run", meta)
self.assertFalse((run_dir / "output.jsonl").exists())
request: dict[str, Any] = json.loads(stdout) request: dict[str, Any] = json.loads(stdout)
self.assertEqual(request["custom_id"], "a") self.assertEqual(request["custom_id"], "a")
self.assertEqual(request["params"]["system"], self.assertEqual(request["params"]["system"],
"The task prompt.\n") "The task prompt.\n")
def test_all_agreed_skips_arbitration(self) -> None: def test_run_produces_output_file(self) -> None:
"""Test that full agreement skips the arbitration batch.""" """Test that a run submits one batch and writes output."""
client: mock.Mock = self.__make_client( client: mock.Mock = self.__make_client(
run1=[self._make_success_entry("a", "answer a"), [self._make_success_entry("a", "answer a"),
self._make_success_entry("b", "answer b")],
run2=[self._make_success_entry("a", "answer a\n"),
self._make_success_entry("b", "answer b")]) self._make_success_entry("b", "answer b")])
status: int = self.__run_main(self.__argv, client)[0] status: int = self.__run_main(self.__argv, client)[0]
self.assertEqual(status, 0) self.assertEqual(status, 0)
self.assertEqual( self.assertEqual(
client.messages.batches.create.call_count, 2) client.messages.batches.create.call_count, 1)
run_dir: Path = self.__archive_dir() run_dir: Path = self.__archive_dir
self.assertEqual( self.assertTrue((run_dir / "output.jsonl").exists())
(run_dir / "arbitration.jsonl").read_text( output: list[dict[str, Any]] = [
encoding="utf-8"), "") json.loads(x) for x in (run_dir / "output.jsonl")
final: list[dict[str, Any]] = [
json.loads(x) for x in (run_dir / "final.jsonl")
.read_text(encoding="utf-8").splitlines()] .read_text(encoding="utf-8").splitlines()]
self.assertEqual(final, [ self.assertEqual(output[0]["text"], "answer a")
{"id": "a", "text": "answer a", "source": "agreed"},
{"id": "b", "text": "answer b", "source": "agreed"}])
meta: dict[str, Any] = json.loads( meta: dict[str, Any] = json.loads(
(run_dir / "meta.json").read_text(encoding="utf-8")) (run_dir / "meta.json").read_text(encoding="utf-8"))
self.assertEqual(meta["agreed_count"], 2) self.assertNotIn("run", meta)
self.assertEqual(meta["agreement_rate"], 1.0) self.assertEqual(meta["batch"]["batch_id"], "batch_run1")
self.assertNotIn("arbitration", meta["batches"]) self.assertEqual(meta["usage"],
{"input_tokens": 20, "output_tokens": 10})
def test_disagreement_triggers_arbitration(self) -> None: def test_existing_archive_rejected_without_replace(self) -> None:
"""Test that disagreeing items go through arbitration.""" """Test that an existing archive without --replace fails."""
run_dir: Path = self.__archive_dir
run_dir.mkdir(parents=True)
(run_dir / "stale.jsonl").write_text(
"stale", encoding="utf-8")
status: int = self.__run_main(
self.__argv + ["--dry-run"])[0]
self.assertEqual(status, 1)
self.assertTrue((run_dir / "stale.jsonl").exists())
def test_rerun_replaces_archive_with_flag(self) -> None:
"""Test that --replace replaces a pre-existing archive."""
run_dir: Path = self.__archive_dir
run_dir.mkdir(parents=True)
(run_dir / "stale.jsonl").write_text(
"stale", encoding="utf-8")
client: mock.Mock = self.__make_client( client: mock.Mock = self.__make_client(
run1=[self._make_success_entry("a", "answer a"), [self._make_success_entry("a", "answer a"),
self._make_success_entry("b", "answer b1")], self._make_success_entry("b", "answer b")])
run2=[self._make_success_entry("a", "answer a"), status: int = self.__run_main(
self._make_success_entry("b", "answer b2")], self.__argv + ["--replace"], client)[0]
arbitration=[ self.assertEqual(status, 0)
self._make_success_entry("b", "answer b final")]) self.assertFalse((run_dir / "stale.jsonl").exists())
status: int = self.__run_main(self.__argv, client)[0] self.assertTrue((run_dir / "output.jsonl").exists())
def test_replace_leaves_sibling_dir_untouched(self) -> None:
"""Test that replacing run2 does not affect run1's archive."""
run1_dir: Path = self.__archive_dir
run1_dir.mkdir(parents=True)
(run1_dir / "output.jsonl").write_text(
"run1 data", encoding="utf-8")
run2_dir: Path = self.__runs / "task_v1" / "run2"
run2_dir.mkdir(parents=True)
(run2_dir / "output.jsonl").write_text(
"stale run2 data", encoding="utf-8")
client: mock.Mock = self.__make_client(
[self._make_success_entry("a", "answer a"),
self._make_success_entry("b", "answer b")])
argv: list[str] = [
str(self.__prompt), str(self.__input),
str(run2_dir), "--replace"]
status: int = self.__run_main(argv, client)[0]
self.assertEqual(status, 0) self.assertEqual(status, 0)
self.assertEqual( self.assertEqual(
client.messages.batches.create.call_count, 3) (run1_dir / "output.jsonl").read_text(encoding="utf-8"),
arb_call: mock.call = \ "run1 data")
client.messages.batches.create.call_args_list[2] self.assertNotEqual(
arb_requests: list[dict[str, Any]] = \ (run2_dir / "output.jsonl").read_text(encoding="utf-8"),
arb_call.kwargs["requests"] "stale run2 data")
self.assertEqual(len(arb_requests), 1)
self.assertEqual(arb_requests[0]["custom_id"], "b")
self.assertEqual(arb_requests[0]["params"]["system"],
"The arbitration prompt.\n")
self.assertEqual(
arb_requests[0]["params"]["messages"][0]["content"],
"<item>\nsecond item\n</item>\n"
"<run1>\nanswer b1\n</run1>\n"
"<run2>\nanswer b2\n</run2>")
run_dir: Path = self.__archive_dir()
arb_lines: list[str] = (run_dir / "arbitration.jsonl") \
.read_text(encoding="utf-8").splitlines()
self.assertEqual(len(arb_lines), 1)
self.assertEqual(json.loads(arb_lines[0])["text"],
"answer b final")
final: list[dict[str, Any]] = [
json.loads(x) for x in (run_dir / "final.jsonl")
.read_text(encoding="utf-8").splitlines()]
self.assertEqual(final, [
{"id": "a", "text": "answer a", "source": "agreed"},
{"id": "b", "text": "answer b final",
"source": "arbitration"}])
meta: dict[str, Any] = json.loads(
(run_dir / "meta.json").read_text(encoding="utf-8"))
self.assertEqual(meta["agreed_count"], 1)
self.assertEqual(meta["agreement_rate"], 0.5)
self.assertEqual(meta["batches"]["arbitration"]["batch_id"],
"batch_arb")
def test_run_failure_exits_non_zero(self) -> None: def test_run_failure_exits_non_zero(self) -> None:
"""Test that a failed item aborts with a non-zero status.""" """Test that a failed item aborts with a non-zero status."""
client: mock.Mock = self.__make_client( client: mock.Mock = self.__make_client(
run1=[self._make_success_entry("a", "answer a"), [self._make_success_entry("a", "answer a"),
self._make_error_entry( self._make_error_entry("b", "invalid_request_error")])
"b", "invalid_request_error")],
run2=[self._make_success_entry("a", "answer a"),
self._make_success_entry("b", "answer b")])
status: int = self.__run_main(self.__argv, client)[0] status: int = self.__run_main(self.__argv, client)[0]
self.assertEqual(status, 1) self.assertEqual(status, 1)
run_dir: Path = self.__archive_dir() run_dir: Path = self.__archive_dir
self.assertTrue((run_dir / "run1.jsonl").exists()) self.assertTrue((run_dir / "output.jsonl").exists())
self.assertTrue((run_dir / "run2.jsonl").exists())
self.assertTrue((run_dir / "meta.json").exists()) self.assertTrue((run_dir / "meta.json").exists())
self.assertFalse((run_dir / "final.jsonl").exists()) output_lines: list[str] = (run_dir / "output.jsonl") \
run1_lines: list[str] = (run_dir / "run1.jsonl") \
.read_text(encoding="utf-8").splitlines() .read_text(encoding="utf-8").splitlines()
self.assertEqual(json.loads(run1_lines[1]), self.assertEqual(json.loads(output_lines[1]),
{"id": "b", {"id": "b",
"error": "invalid_request_error"}) "error": "invalid_request_error"})