重點摘要
- 除非有檔案另外指定,Claude Code 的 subagent 不會用 Haiku 5.5。內建的 Explore agent 和每個自訂 subagent 都繼承主模型,單獨設定 subagent 模型變數,Explore 不會有任何改變。
- 兩個檔案就能解決:一個專案內的
Explore.mdagent 檔(Haiku 5.5,effort low)只處理探索者,以及一個帶有CLAUDE_CODE_SUBAGENT_MODEL_FORCE的settings.jsonenv 區塊,把每個 subagent 都推到 Haiku。 - 我們在一個真實儲存庫上的矩陣共 182 次執行:探索、測試、lint、commit、refactor、審查和總結,在 Haiku 5.5 上全部被隱藏測試或固定規則接受,低與中等 effort 各 70 次中的 70 次。
- 七個任務都交給低 effort 的 Haiku 5.5。複雜的 agentic coding 則留給 Sonnet 5.5 或 Opus 5.5,這也是 Anthropic 自己的建議。
- 真正的方案用量表幾乎沒動:70 次低 effort Haiku 執行,五小時視窗從 21% 到 21%,182 次執行的每週視窗都是 67%。
/usage明細是用本地權重排序模型,不是計費。 - 請固定使用完整的模型 ID。
haiku別名在 Bedrock、Vertex 和 Foundry 上是 Haiku 4.5。
資料來源怎麼說
Haiku 5.5 在 100k token 以內的 prompt 定價為每百萬輸入 token $0.10,超過則是 $0.50,輸出是 $0.50 和 $2.50。Haiku 4.5 是 $1.00 和 $5.00,Sonnet 5.5 是 $2.00 和 $10.00 s1。定價頁面列出 Opus 5.5 為每百萬 token $4 和 $20,並註明 Haiku 5.5 與其他 1M context 模型不同,是依 prompt 長度計價 s20。標題上的 90% 指的是前一代 Haiku 的請求中 prompt 不到 100k token 的比例,不是降價 s1。
Anthropic 把 Haiku 5.5 定位為與 Opus 5.5 和 Sonnet 5.5 並列的 subagent,並表示複雜的 agentic coding 仍以 Sonnet 5.5 和 Opus 5.5 為較佳選擇,Haiku 5.5 用於範圍明確的工作 s1。在 Terminal-Bench 4.0 上,發布文章給出 Haiku 5.5 為 39.2%,Sonnet 5.5 為 70.6% s1。
預設沒有任何東西會送到 Haiku。從 v2.1.198 開始,內建的 Explore agent 不再預設使用 Haiku,而是繼承主模型 s24。文件記載的 subagent 模型解析順序是:每次呼叫的參數、定義的 frontmatter、CLAUDE_CODE_SUBAGENT_MODEL,然後是主對話的模型 s2。單獨使用 CLAUDE_CODE_SUBAGENT_MODEL 不會改變內建的 Explore 和 Plan subagent s2。使用者或專案中名為 Explore 的 subagent 會覆蓋內建版本,文件也建議定義一個使用 Haiku 模型的版本 s2。CLAUDE_CODE_SUBAGENT_MODEL_FORCE=1 會把同一個模型套用到每個 subagent,從 v2.1.257 開始 s2 s24。
成本文件建議簡單的 subagent 任務使用 model: haiku,並說明 subagent 的請求仍然使用同一份用量 s3。每週的計數器是所有模型共用一個,加上包含 Fable 的方案另有一個 Fable 專用的 s22,其他模型也從同樣的額度中扣除 s23。
在 Claude Code 中,Haiku 5.5、Opus 5.5、Sonnet 5.5 和 Fable 的思考都無法關閉:alwaysThinkingEnabled 設為 false 和 MAX_THINKING_TOKENS=0 都沒有作用。effort 從 low 到 max,Haiku 5.5 預設為 medium s21。haiku 別名在 Anthropic API 上解析為 Haiku 5.5,在 Platform on AWS、Bedrock、Vertex 和 Foundry 上解析為 Haiku 4.5 s21。
一位實踐者的 Pro 方案配置(主 session 用中等 effort 的 Opus 5.5、一個 Sonnet 5.5 implementer、一個唯讀的 Haiku 5.5 scoper、一個低 effort 的 Haiku 5.5 shipper)回報十個 session 共 13 個 PR,每個約佔五小時視窗的 6%,單一 PR session 約佔每週計數器的 1% s4。他自己的提醒:省下來的部分來自主 context 保持很小,而不是來自比較便宜的模型,因為把 subagent 的 token 按 Opus 價格計算,也只多出 5% s4。
兩個檔案
兩個檔案,用其中一個或兩個都用。專案裡的 agent 檔 .claude/agents/Explore.md 名為 Explore,所以它會取代內建的探索者。完整的模型 ID、effort low、只有讀取和搜尋工具。
Explore.md
---
name: Explore
description: Read-only codebase explorer. Use it to find where something is defined or called and to summarize what it found, with file paths and function names.
model: claude-haiku-5-5
effort: low
tools: Read, Glob, Grep, Bash
---
You explore a codebase and report back. Search with Glob and Grep, read only the files you need, and never edit anything. Answer with file paths and function names, then stop.
settings 檔 .claude/settings.json 裡放的是 env 區塊:subagent 模型,以及讓它套用到每個 subagent 的 force 旗標。
settings.json
{
"env": {
"CLAUDE_CODE_SUBAGENT_MODEL": "claude-haiku-5-5",
"CLAUDE_CODE_SUBAGENT_MODEL_FORCE": "1"
}
}
誰該用哪一個:
- 想讓每個 subagent 都便宜,就用 env 區塊。FORCE 會把每個 agent 都拉到 Haiku,包括你刻意指定 Opus 的那些。
- 只想讓探索者移動,就用 Explore 檔。其他 subagent 保留各自的模型。
- 設定
CLAUDE_CODE_SUBAGENT_MODEL而不加 FORCE,對 Explore 沒有任何作用。我們做了兩次 headless 執行,主 session 用 Fable,「use the Explore agent to find where the query cache is cleared」:沒有任何設定時,工具結果寫的是 Opus 5.5;只把變數設成 Haiku 5.5、其他什麼都不改時,仍然是 Opus 5.5。用了 Explore 檔,或用了這兩個環境變數,它讀到的就是 Haiku 5.5。
檢查方式:/tasks 會顯示每個 subagent 執行時用的模型和 effort(v2.1.243 以後 s24),headless 執行的 Agent 工具結果也帶有 resolvedModel。請相信那一行,而不是檔案。
別名陷阱:請寫 claude-haiku-5-5,不要寫 haiku。這個別名只在 Anthropic API 上是 Haiku 5.5;在 Bedrock、Vertex 和 Foundry 上它仍然代表 Haiku 4.5 s21。
一份較早的公開回報說,subagent 的 frontmatter 模型被無聲地忽略 s25,較晚的一份則統計在 2.1.277 到 2.1.290 版上,1,371 個 subagent 中有 1,149 個跑在父層的 Opus 模型上 s10。在 Claude Code 2.1.293 上,我們 182 次執行每一次都用了設定的模型(結果表中 model_ok 為 True)。還是要檢查。
路由表
在 tinydb 的 18d73a1 提交上做七個日常任務,226 個測試,headless claude -p,每個任務一個確定性的接受或拒絕檢查。每個儲存格是通過的次數、每次執行的平均牌價、平均牆上秒數。Haiku 儲存格每個任務 10 次執行,Sonnet 和 Opus 儲存格每個任務 3 次執行。
| 任務 | Haiku 5.5 中等 | Haiku 5.5 低 | Sonnet 5.5 | Opus 5.5 | 路由 |
|---|---|---|---|---|---|
| explore | 10/10, $0.0047, 13.4 s | 10/10, $0.0035, 10.7 s | 3/3, $0.0679, 30.0 s | 3/3, $0.1454, 18.0 s | Haiku 5.5,effort low |
| tests | 10/10, $0.0033, 9.8 s | 10/10, $0.0031, 9.0 s | 3/3, $0.0531, 20.0 s | 3/3, $0.0892, 14.3 s | Haiku 5.5,effort low |
| lint | 10/10, $0.0048, 16.7 s | 10/10, $0.0041, 14.0 s | 3/3, $0.0693, 23.3 s | 3/3, $0.1459, 27.7 s | Haiku 5.5,effort low |
| commit | 10/10, $0.0017, 5.1 s | 10/10, $0.0018, 4.7 s | 3/3, $0.0313, 7.0 s | 3/3, $0.0593, 5.7 s | Haiku 5.5,effort low |
| refactor | 10/10, $0.0078, 26.3 s | 10/10, $0.0052, 17.1 s | 3/3, $0.1050, 30.3 s | 3/3, $0.1675, 30.3 s | Haiku 5.5,effort low |
| review | 10/10, $0.0040, 16.0 s | 10/10, $0.0030, 10.6 s | 3/3, $0.0500, 20.3 s | 3/3, $0.0951, 17.3 s | Haiku 5.5,effort low |
| summary | 10/10, $0.0037, 12.4 s | 10/10, $0.0032, 8.4 s | 3/3, $0.0347, 8.7 s | 3/3, $0.0701, 7.7 s | Haiku 5.5,effort low |
| 全部七個 | 70/70, 總計 $0.3006 | 70/70, 總計 $0.2382 | 21/21, 總計 $1.2339 | 21/21, 總計 $2.3175 |
「refactor」任務就是那個小功能:在 tinydb.operations 加入 push 和 pull 更新操作,由一個隱藏的測試檔評分。它在低 effort 的 Haiku 上花 $0.0052,在 Opus 上花 $0.1675,耗時 17.1 s 對 30.3 s。
留在 Sonnet 或 Opus 的:複雜的 agentic coding,這是 Anthropic 自己的話 s1。這七個是範圍明確的任務,每個不到一分鐘,在一個小專案上。
測試工具
產生這張表的基準測試。請在一個普通的終端機裡執行,期間不要有其他東西使用這個帳號,因為方案用量表是共用的。tinydb 的 checkout、隱藏測試和參考 patch 沒有附上:請把它指向你自己的儲存庫,並寫你自己的檢查。
執行器與用量表:
README.md:這個工具是什麼,以及哪些東西沒有附上。README.md
# routing bench
The benchmark harness behind the routing table: run.sh runs one cell headless and reads the real meters from the rate_limit_event, matrix.sh runs cells sequentially, check.py decides accept or reject per task, analyze.py writes the results table. tasks/ holds the seven prompts, inject/ the planted bugs. The tinydb checkout (commit 18d73a1), the hidden tests and the reference patches are not included: bring your own repo and checks.
- `run.sh`:一個儲存格,用固定模型的 headless `claude -p`、全新的工作副本、執行後的用量探測,然後是檢查。run.sh
#!/usr/bin/env bash
# One run: fresh tinydb work copy + task injection, headless claude -p, extract tokens/meter, check.
# Usage: run.sh <config> <task> <rep>
# config: a key of configs.json (haiku-med | haiku-low | sonnet | opus)
# task: a key of tasks.json (explore | tests | lint | commit | refactor | review | summary)
set -uo pipefail
CFG="$1"; TASK="$2"; REP="${3:-1}"
HERE="$(cd "$(dirname "$0")" && pwd)"
. "$HERE/env.sh"
PY="$HERE/.venv/bin/python"
MAX_TURNS="${BENCH_MAX_TURNS:-40}"
NAME="${TASK}-${CFG}-r${REP}"
OUT="$HERE/runs/$NAME"; W="$HERE/work/$NAME"
rm -rf "$OUT"; mkdir -p "$OUT"
"$HERE/prep.sh" "$TASK" "$W" > "$OUT/prep.txt" 2>&1 || { echo "$NAME: prep failed"; cat "$OUT/prep.txt"; exit 2; }
( cd "$W" && git diff HEAD > "$OUT/pre.diff" )
# Config args (model pinned by full id, optional --effort)
CARGS=()
while IFS= read -r a; do CARGS+=("$a"); done < <("$PY" -c "
import json; [print(a) for a in json.load(open('$HERE/configs.json'))['$CFG']['args']]")
[ ${#CARGS[@]} -gt 0 ] || { echo "bad config $CFG" >&2; exit 2; }
# Fixed allow-list for every cell, including Bash(ruff:*) for the lint task.
TOOLS="Read,Edit,Write,MultiEdit,Glob,Grep,Skill,TodoWrite,Bash(uv:*),Bash(python:*),Bash(python3:*),Bash(pytest:*),Bash(ruff:*),Bash(git status:*),Bash(git diff:*),Bash(git log:*),Bash(ls:*),Bash(cat:*),Bash(mkdir:*),Bash(mktemp:*),Bash(head:*),Bash(tail:*),Bash(grep:*),Bash(wc:*)"
cd "$W"
START=$(date +%s)
claude -p "$(cat "$HERE/tasks/$TASK.md")" "${CARGS[@]}" \
--output-format stream-json --verbose --max-turns "$MAX_TURNS" \
--permission-mode acceptEdits --allowedTools "$TOOLS" \
--setting-sources project --strict-mcp-config \
> "$OUT/stream.jsonl" 2> "$OUT/stderr.txt"
echo "$?" > "$OUT/exit_code.txt"
END=$(date +%s)
echo "$((END-START))" > "$OUT/wall_s.txt"
# After-meter: one cheap Haiku probe (its own stream is kept for audit).
"$HERE/probe.sh" "$OUT/probe.jsonl" > "$OUT/after.json" 2>/dev/null
# Diff of the agent's work (tracked + untracked) vs the post-injection state
# (index left untouched: the commit task's staged changes stay staged for the check)
git diff HEAD -- . ':(exclude).coverage' > "$OUT/post.diff"
git diff HEAD --numstat -- . ':(exclude).coverage' > "$OUT/numstat.txt"
git ls-files --others --exclude-standard | while IFS= read -r f; do
git diff --no-index -- /dev/null "$f" >> "$OUT/post.diff"
echo "$(wc -l < "$f" | tr -d ' ') 0 $f" >> "$OUT/numstat.txt"
done
"$PY" "$HERE/extract.py" "$OUT" "$CFG" "$TASK" "$REP" > "$OUT/result.json"
"$PY" "$HERE/check.py" "$TASK" "$W" "$OUT/result.json" > "$OUT/check.txt"; echo "$?" >> "$OUT/check.txt"
"$PY" "$HERE/extract.py" "$OUT" "$CFG" "$TASK" "$REP" --score
- `matrix.sh`:依序執行排好順序的儲存格,並把結果附加到紀錄檔。matrix.sh
#!/usr/bin/env bash
# Run an ordered list of "<config> <task> <rep>" lines SEQUENTIALLY (the plan meter is shared:
# never run two cells at once). Blank lines and #comments are skipped. Appends to matrix.log.
# Usage: matrix.sh [matrix-file] (default: matrix.txt)
# SKIP_DONE=1 matrix.sh ... skip cells whose runs/<task>-<config>-r<rep>/score.json exists (resume)
set -uo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"; cd "$HERE"
LIST="${1:-matrix.txt}"
# one matrix at a time: the meter is shared
mkdir "$HERE/.matrix.lock" 2>/dev/null || { echo "another matrix.sh is running (remove .matrix.lock if stale)" >&2; exit 1; }
trap 'rmdir "$HERE/.matrix.lock"' EXIT
echo "=== $(date '+%F %T') start $LIST" >> matrix.log
grep -vE '^\s*(#|$)' "$LIST" | while read -r CFG TASK REP; do
if [ "${SKIP_DONE:-0}" = 1 ] && [ -f "runs/${TASK}-${CFG}-r${REP}/score.json" ]; then
echo "$(date '+%F %T') skip ${TASK}-${CFG}-r${REP}" >> matrix.log; continue
fi
line=$(./run.sh "$CFG" "$TASK" "$REP" < /dev/null 2>&1 | tail -1)
echo "$(date '+%F %T') $line" | tee -a matrix.log
done
echo "=== $(date '+%F %T') done $LIST" >> matrix.log
- `gen_matrix.py`:寫出排好順序的矩陣,每個設定是一個連續區塊,便宜的設定先跑,第二輪倒過來。gen_matrix.py
#!/usr/bin/env python3
"""Write the ordered matrix file.
gen_matrix.py [--reps 3] [--passes 2] [--configs haiku-low,haiku-med,sonnet,opus] [--out matrix.txt]
Order: configs run as contiguous BLOCKS (all tasks of one rep set), cheap configs first; tasks are
rotated inside each block so no task always runs first. With --passes 2 the second pass runs the
configs in reverse order (ABBA), which interleaves configs over time without breaking the
contiguous segments that analyze.py needs to beat the 1% meter resolution.
"""
import argparse, json, os
HERE = os.path.dirname(os.path.abspath(__file__))
ap = argparse.ArgumentParser()
ap.add_argument("--reps", type=int, default=3, help="reps per config x task per pass")
ap.add_argument("--passes", type=int, default=2)
ap.add_argument("--configs", default="haiku-low,haiku-med,sonnet,opus")
ap.add_argument("--start-rep", type=int, default=1, help="first rep number (use 2 to keep the r1 smoke runs)")
ap.add_argument("--out", default=os.path.join(HERE, "matrix.txt"))
a = ap.parse_args()
tasks = list(json.load(open(os.path.join(HERE, "tasks.json"))))
cfgs = a.configs.split(",")
lines, rep = [], a.start_rep
for p in range(a.passes):
order = cfgs if p % 2 == 0 else list(reversed(cfgs))
lines.append(f"# pass {p + 1}: {' -> '.join(order)}")
for c in order:
lines.append(f"# block {c}")
for r in range(a.reps):
k = rep + r
rot = (k - 1) % len(tasks)
for t in tasks[rot:] + tasks[:rot]:
lines.append(f"{c} {t} {k}")
rep += a.reps
open(a.out, "w").write("\n".join(lines) + "\n")
n = sum(1 for l in lines if not l.startswith("#"))
print(f"{a.out}: {n} runs ({len(cfgs)} configs x {len(tasks)} tasks x {a.reps} reps x {a.passes} passes)")
- `prep.sh`:建立全新的工作副本,並套用任務預先植入的變更。prep.sh
#!/usr/bin/env bash
# Build a fresh work copy for one task: upstream tinydb, baseline commit, task injection.
# Usage: prep.sh <task> <workdir>
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
TASK="$1"; W="$2"
read -r INJ MODE < <("$HERE/.venv/bin/python" -c "
import json,sys; t=json.load(open('$HERE/tasks.json'))['$TASK']; print(t['inject'] or '-', t['mode'] or '-')")
rm -rf "$W"; mkdir -p "$(dirname "$W")"
cp -R "$HERE/upstream" "$W"
cd "$W"
/usr/bin/find . -name __pycache__ -type d -prune -exec rm -rf {} +
rm -f .coverage
git update-index -q --refresh || true
G() { git -c user.email=dev@example.com -c user.name=dev "$@"; }
# Injections that belong to the baseline are folded into the top upstream commit
# (amend, same message/author/date), so `git log` shows no extra "bug" commit.
if [ "$MODE" = "commit" ]; then
git apply "$HERE/$INJ"
git add -A
G commit -q --amend --no-edit --no-verify
fi
case "$MODE" in
stage) git apply --index "$HERE/$INJ" ;;
worktree) git apply "$HERE/$INJ" ;;
esac
- `env.sh`:每次呼叫共用的認證與環境。env.sh
# Sourced by run.sh and probe.sh.
# Auth: an isolated CLAUDE_CONFIG_DIR is not logged in
# (`claude auth status` -> loggedIn false), so runs use the user's default config dir:
# CLAUDE_CONFIG_DIR is deliberately left unset.
unset CLAUDE_CONFIG_DIR
# Fresh work copies at the same path must not inherit memory written by an earlier rep.
export CLAUDE_CODE_DISABLE_AUTO_MEMORY=1
# The bench venv (pytest, ruff, mypy) comes first, so `python -m pytest` and `ruff` just work.
export PATH="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/.venv/bin:$PATH"
- `probe.sh`:一次 Haiku 對話輪,以 JSON 印出目前的方案用量表。probe.sh
#!/usr/bin/env bash
# Cheapest possible call: one Haiku turn answering "ok". Prints the current plan
# meter read from its rate_limit_event as one JSON line. The probe itself costs a
# few hundred tokens of Haiku (well under the 1% meter resolution).
# Usage: probe.sh [raw-stream-out-file]
set -uo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
[ -f "$HERE/env.sh" ] && . "$HERE/env.sh"
RAW="${1:-$(mktemp -t hrb-probe)}"; case "$RAW" in /*) ;; *) RAW="$PWD/$RAW";; esac
cd "$(mktemp -d -t hrb-probe-cwd)"
claude --model claude-haiku-5-5 -p "ok" --max-turns 1 --output-format stream-json --verbose \
--setting-sources project --strict-mcp-config > "$RAW" 2>/dev/null
"$HERE/.venv/bin/python" "$HERE/meter.py" "$RAW"
- `meter.py`:從串流的 `rate_limit_event` 行讀出五小時和七天視窗。meter.py
#!/usr/bin/env python3
"""Read a stream-json file and print the plan meter carried by its rate_limit_event lines.
Output (one JSON line): {"ts", "five_hour", "five_hour_resets_at", "seven_day",
"seven_day_resets_at", "n_events", "raw_first", "raw_last"}: utilizations are the
values of the FIRST event (the meter as the run began). `--last` uses the last event.
"""
import json, sys, time
def events(path):
out = []
for line in open(path, errors="replace"):
line = line.strip()
if not line.startswith("{"):
continue
try:
o = json.loads(line)
except Exception:
continue
if o.get("type") == "rate_limit_event":
out.append(o)
return out
def windows(ev):
info = ev.get("rate_limit_info") or {}
uw = info.get("unifiedWindows") or {}
def w(name):
x = uw.get(name) or {}
return x.get("utilization"), x.get("resetsAt")
fh, fhr = w("five_hour")
sd, sdr = w("seven_day")
return {"five_hour": fh, "five_hour_resets_at": fhr, "seven_day": sd, "seven_day_resets_at": sdr}
def meter(path, which="first"):
evs = events(path)
if not evs:
return {"ts": int(time.time()), "n_events": 0, "five_hour": None, "seven_day": None}
ev = evs[0] if which == "first" else evs[-1]
d = {"ts": int(time.time()), "n_events": len(evs)}
d.update(windows(ev))
d["raw_first"] = evs[0].get("rate_limit_info")
d["raw_last"] = evs[-1].get("rate_limit_info")
return d
if __name__ == "__main__":
which = "last" if "--last" in sys.argv else "first"
path = [a for a in sys.argv[1:] if not a.startswith("--")][0]
print(json.dumps(meter(path, which)))
- `extract.py`:從一次執行的串流中取出 token、成本、輪數和用量表,成為一筆分數紀錄。extract.py
#!/usr/bin/env python3
"""Extract one run's numbers from runs/<name>/stream.jsonl.
extract.py <outdir> <config> <task> <rep> -> prints the stream's `result` line (result.json)
extract.py <outdir> <config> <task> <rep> --score -> writes score.json, prints a one-line summary
"""
import json, os, sys
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
from meter import meter # noqa: E402
out, cfg, task, rep = sys.argv[1:5]
score_mode = "--score" in sys.argv
stream = os.path.join(out, "stream.jsonl")
lines = []
for raw in open(stream, errors="replace") if os.path.exists(stream) else []:
raw = raw.strip()
if raw.startswith("{"):
try:
lines.append(json.loads(raw))
except Exception:
pass
result = next((o for o in reversed(lines) if o.get("type") == "result"), None)
init = next((o for o in lines if o.get("type") == "system" and o.get("subtype") == "init"), {})
if not score_mode:
print(json.dumps(result or {"type": "result", "result": "", "missing": True}))
sys.exit(0)
def rd(name, default=None):
p = os.path.join(out, name)
try:
return open(p).read().strip()
except Exception:
return default
def jl(name):
try:
return json.loads(rd(name) or "null")
except Exception:
return None
cfgs = json.load(open(os.path.join(HERE, "configs.json")))
before = meter(stream, "first") if os.path.exists(stream) else None
after = jl("after.json")
r = result or {}
usage = r.get("usage") or {}
tok = {k: usage.get(k) or 0 for k in ("input_tokens", "cache_read_input_tokens", "cache_creation_input_tokens", "output_tokens")}
mu = r.get("modelUsage") or {}
assistant_models = sorted({(o.get("message") or {}).get("model") for o in lines
if o.get("type") == "assistant" and (o.get("message") or {}).get("model") not in (None, "<synthetic>")})
check_txt = (rd("check.txt", "") or "").split("\n")
verdict_line = check_txt[0] if check_txt else ""
numstat = [l.split("\t") for l in (rd("numstat.txt", "") or "").split("\n") if l.strip()]
added = sum(int(a) for a, d, f in numstat if a.isdigit())
deleted = sum(int(d) for a, d, f in numstat if d.isdigit())
changed_by_agent = (rd("pre.diff", "") or "") != (rd("post.diff", "") or "")
def strip_raw(m):
if not m:
return m
return {k: v for k, v in m.items() if not k.startswith("raw_")}
s = {
"run": os.path.basename(out), "config": cfg, "task": task, "rep": int(rep),
"expected_model": cfgs[cfg]["model"], "config_args": cfgs[cfg]["args"],
"init_model": init.get("model"), "assistant_models": assistant_models,
"model_usage_keys": sorted(mu.keys()),
"model_ok": init.get("model") == cfgs[cfg]["model"] and set(mu.keys()) <= {cfgs[cfg]["model"]} and bool(mu),
"claude_code_version": init.get("claude_code_version"),
"meter_before": strip_raw(before), "meter_after": strip_raw(after),
"n_rate_limit_events": (before or {}).get("n_events", 0),
"has_result_line": result is not None,
"subtype": r.get("subtype"), "is_error": r.get("is_error"), "stop_reason": r.get("stop_reason"),
"num_turns": r.get("num_turns"), "duration_ms": r.get("duration_ms"), "duration_api_ms": r.get("duration_api_ms"),
"total_cost_usd": r.get("total_cost_usd"), "usage": usage, "tokens": tok, "modelUsage": mu,
"wall_s": int(rd("wall_s.txt", "0") or 0), "exit_code": rd("exit_code.txt"),
"final_text": (r.get("result") or "")[-2000:],
"diff": {"files": len(numstat), "added": added, "deleted": deleted, "changed_by_agent": changed_by_agent},
"check": {"verdict": verdict_line.split(" ")[0] if verdict_line else None, "reason": verdict_line[5:] if verdict_line else None},
}
s["accepted"] = s["check"]["verdict"] == "PASS"
json.dump(s, open(os.path.join(out, "score.json"), "w"), indent=2)
mb, ma = s["meter_before"] or {}, s["meter_after"] or {}
print(f"{s['run']}: {s['check']['verdict']} turns={s['num_turns']} cost=${(s['total_cost_usd'] or 0):.3f} "
f"in={tok['input_tokens']} cr={tok['cache_read_input_tokens']} cc={tok['cache_creation_input_tokens']} out={tok['output_tokens']} "
f"wall={s['wall_s']}s 5h {mb.get('five_hour')}->{ma.get('five_hour')} 7d {mb.get('seven_day')}->{ma.get('seven_day')} "
f"model_ok={s['model_ok']} | {s['check']['reason']}")
- `check.py`:每個任務的接受或拒絕判定。check.py
#!/usr/bin/env python3
"""Deterministic accept/reject check for one run.
Usage: check.py <task> <workdir> <result.json>
result.json = the `result` line of the run's stream (only its "result" text is used).
Prints `PASS <reason>` or `FAIL <reason>`; exit 0 on PASS, 1 on FAIL.
"""
import json, os, re, subprocess, sys
HERE = os.path.dirname(os.path.abspath(__file__))
PY = os.path.join(HERE, ".venv", "bin", "python")
RUFF = os.path.join(HERE, ".venv", "bin", "ruff")
def final_text(path):
try:
r = json.load(open(path))
except Exception:
return ""
return r.get("result") or "" if isinstance(r, dict) else ""
def sh(args, cwd, env=None):
p = subprocess.run(args, cwd=cwd, capture_output=True, text=True, env=env)
return p.returncode, p.stdout + p.stderr
def suite(wd):
rc, out = sh([PY, "-m", "pytest", "-q", "-p", "no:cacheprovider", "-o", "addopts=", "tests"], wd)
m = re.search(r"(\d+) passed", out)
f = re.search(r"(\d+) failed", out)
e = re.search(r"(\d+) error", out)
return rc == 0, f"suite {m.group(1) if m else 0} passed, {f.group(1) if f else 0} failed, {e.group(1) if e else 0} errors"
def git_diff(wd, *paths):
# tracked changes vs the baseline commit + untracked files under paths
_, d = sh(["git", "diff", "HEAD", "--", *paths], wd)
_, u = sh(["git", "ls-files", "--others", "--exclude-standard", "--", *paths], wd)
return d.strip(), u.strip()
def words(s):
return re.findall(r"[A-Za-z0-9_']+", s)
def strip_fences(s):
lines = s.strip().split("\n")
if lines and lines[0].strip().startswith("```"):
lines = lines[1:]
if lines and lines[-1].strip().startswith("```"):
lines = lines[:-1]
return "\n".join(lines).strip()
def c_explore(wd, txt):
t = txt.lower()
miss = []
if "table.py" not in t:
miss.append("table.py")
if "clear_cache" not in t and "_query_cache" not in t:
miss.append("clear_cache/_query_cache")
names = ["insert_multiple", "update_multiple", "insert", "update", "upsert", "remove", "truncate"]
found = {n for n in names if re.search(r"(?<![a-z_])" + n + r"(?![a-z_])", t)}
if len(found) < 3:
miss.append(f"only {len(found)} mutating methods {sorted(found)}")
return (not miss), ("missing " + ", ".join(miss)) if miss else f"table.py + cache method + {len(found)} methods {sorted(found)}"
def c_tests(wd, txt):
ok, s = suite(wd)
d, u = git_diff(wd, "tests")
if d or u:
return False, f"tests/ modified; {s}"
return ok, s + "; tests/ untouched"
def c_lint(wd, txt):
rc, out = sh([RUFF, "check", "tinydb", "--output-format", "concise", "--no-cache"], wd)
cfg_d, _ = git_diff(wd, "ruff.toml", "pyproject.toml")
d, _ = git_diff(wd, "tinydb")
noqa = [l for l in d.split("\n") if l.startswith("+") and "noqa" in l.lower()]
ok, s = suite(wd)
why = []
if rc != 0:
found = [l for l in out.split("\n") if l.startswith("Found ")]
why.append("ruff: " + (found[0] if found else out.strip().split("\n")[-1]))
if cfg_d:
why.append("lint config changed")
if noqa:
why.append(f"{len(noqa)} noqa added")
if not ok:
why.append(s)
return (not why), ("; ".join(why) if why else f"ruff clean; {s}")
DIFF_LINE = re.compile(r"^(diff --git |index [0-9a-f]{6,}\.\.|@@ |\+\+\+ |--- a/|--- /dev/null|[+-] {3,}\S|[+-](def|class|import|from|return) )")
def c_commit(wd, txt):
msg = strip_fences(txt)
if not msg:
return False, "empty answer"
first = msg.split("\n")[0].strip()
why = []
if len(first) > 72:
why.append(f"first line {len(first)} chars")
if "push" not in first.lower():
why.append("no 'push' in first line")
if "pull" not in first.lower():
why.append("no 'pull' in first line")
dl = [l for l in msg.split("\n") if DIFF_LINE.match(l)]
if dl:
why.append(f"{len(dl)} diff lines")
return (not why), ("; ".join(why) if why else f"first line {len(first)} chars: {first!r}")
def c_refactor(wd, txt):
env = dict(os.environ, TINYDB_TREE=wd)
rc, out = sh([PY, "-m", "pytest", "-q", "-c", os.path.join(HERE, "hidden", "pytest.ini"),
"--rootdir", os.path.join(HERE, "hidden"), os.path.join(HERE, "hidden", "test_T1.py")], wd, env)
m = re.search(r"(\d+) passed", out)
hp = int(m.group(1)) if m else 0
ok, s = suite(wd)
return (hp == 5 and ok), f"hidden {hp}/5; {s}"
REVIEW_PATTERNS = [
r"limit\s*=?=\s*0\b",
r"limit\s+(of\s+)?(0|zero)\b",
r"\b(0|zero)\s+limit",
r"zero[- ]limit",
r"limit.{0,150}\b(falsy|falsey|truthy|truthiness)",
r"\b(falsy|falsey|truthy|truthiness)\b.{0,150}limit",
r"if limit:.{0,150}\b(0|zero)\b",
r"\b(0|zero)\b.{0,150}if limit:",
]
def c_review(wd, txt):
t = txt.lower().replace("`", "")
for p in REVIEW_PATTERNS:
m = re.search(p, t, re.S)
if m:
return True, f"matched {p!r}: {m.group(0)[:80]!r}"
return False, "planted limit=0 defect not mentioned"
def c_summary(wd, txt):
n = len(words(txt))
t = txt.lower()
mods = [m for m in ["database", "table", "queries", "storages", "middlewares", "operations"] if m in t]
why = []
if n > 170:
why.append(f"{n} words > 170")
if len(mods) < 4:
why.append(f"only {len(mods)} modules {mods}")
return (not why), ("; ".join(why) if why else f"{n} words, modules {mods}")
CHECKS = {"explore": c_explore, "tests": c_tests, "lint": c_lint, "commit": c_commit,
"refactor": c_refactor, "review": c_review, "summary": c_summary}
if __name__ == "__main__":
task, wd, res = sys.argv[1], os.path.abspath(sys.argv[2]), sys.argv[3]
ok, why = CHECKS[task](wd, final_text(res))
print(("PASS " if ok else "FAIL ") + why)
sys.exit(0 if ok else 1)
- `analyze.py`:把執行結果彙整成每個任務與每個設定的表格。analyze.py
#!/usr/bin/env python3
"""Aggregate runs/*/score.json into results.json and results.md.
Per config x task: accept rate, mean tokens, mean cost, mean turns, mean wall.
Per config: measured plan-meter deltas (five_hour, seven_day, in percentage points), computed two ways:
- per_run_sum: sum over the config's runs of (after - before). Quantized at 1% per run, so
runs far below 1% mostly read 0: a lower-bound-ish, very noisy number.
- segments: runs sorted by start time are cut into maximal contiguous segments of the same
config; each segment contributes (after of its last run - before of its first run). This
telescopes the quantization: one +-1 pt error per segment instead of per run. PREFERRED.
Segments/runs whose window resetsAt changed between before and after are excluded for that window.
Plus the /usage-style local heuristic: weighted = cached*1 + uncached*10 + cache_create*12.5 + output*50,
times the model tier (haiku 1 / sonnet 3 / opus 5). The probe's own Haiku tokens after each run are
counted as overhead in the heuristic (they also move the meter).
Usage: analyze.py [--runs-dir runs] [--out-prefix results]
"""
import glob, json, os, statistics, sys
HERE = os.path.dirname(os.path.abspath(__file__))
RUNS = os.path.join(HERE, sys.argv[sys.argv.index("--runs-dir") + 1]) if "--runs-dir" in sys.argv else os.path.join(HERE, "runs")
OUTP = sys.argv[sys.argv.index("--out-prefix") + 1] if "--out-prefix" in sys.argv else "results"
CFGS = json.load(open(os.path.join(HERE, "configs.json")))
TASKS = list(json.load(open(os.path.join(HERE, "tasks.json"))).keys())
W = {"cache_read_input_tokens": 1, "input_tokens": 10, "cache_creation_input_tokens": 12.5, "output_tokens": 50}
TIER = {c: v["tier"] for c, v in CFGS.items()}
def weighted(tok, tier):
raw = sum((tok.get(k) or 0) * w for k, w in W.items())
return raw, raw * tier
MU = {"cacheReadInputTokens": "cache_read_input_tokens", "inputTokens": "input_tokens",
"cacheCreationInputTokens": "cache_creation_input_tokens", "outputTokens": "output_tokens"}
def model_tier(name, fallback):
n = (name or "").lower()
return 5 if "opus" in n else 3 if "sonnet" in n else 1 if "haiku" in n else fallback
def heuristic_run(s):
"""Tiered heuristic per model actually used (modelUsage), so helper calls on another
model are weighted with their own tier; falls back to `usage` x config tier."""
mu = s.get("modelUsage") or {}
if not mu:
return weighted(s["tokens"], TIER[s["config"]])[1]
return sum(weighted({MU[k]: v.get(k) or 0 for k in MU}, model_tier(m, TIER[s["config"]]))[1] for m, v in mu.items())
def probe_tokens(run_dir):
p = os.path.join(run_dir, "probe.jsonl")
if not os.path.exists(p):
return {}
for line in reversed(open(p, errors="replace").read().strip().split("\n")):
try:
o = json.loads(line)
except Exception:
continue
if o.get("type") == "result":
u = o.get("usage") or {}
return {k: u.get(k) or 0 for k in W}
return {}
def mean(xs):
xs = [x for x in xs if x is not None]
return statistics.mean(xs) if xs else None
def window_delta(b, a, w):
if not b or not a or b.get(w) is None or a.get(w) is None:
return None, "missing"
if b.get(w + "_resets_at") != a.get(w + "_resets_at"):
return None, "reset"
return round((a[w] - b[w]) * 100, 2), "ok"
scores = []
for p in sorted(glob.glob(os.path.join(RUNS, "*", "score.json"))):
s = json.load(open(p))
if s.get("config") not in CFGS:
continue
s["_dir"] = os.path.dirname(p)
s["_start"] = (s.get("meter_before") or {}).get("ts") or os.path.getmtime(os.path.join(s["_dir"], "stream.jsonl"))
s["_probe_tok"] = probe_tokens(s["_dir"])
scores.append(s)
# meter_before.ts is written at extraction time; order by stream file creation instead
for s in scores:
st = os.stat(os.path.join(s["_dir"], "stream.jsonl"))
s["_start"] = getattr(st, "st_birthtime", st.st_mtime)
scores.sort(key=lambda s: s["_start"])
cells, per_cfg = {}, {}
for s in scores:
cells.setdefault((s["config"], s["task"]), []).append(s)
per_cfg.setdefault(s["config"], []).append(s)
cell_rows = []
for (c, t), ss in sorted(cells.items(), key=lambda kv: (list(CFGS).index(kv[0][0]), TASKS.index(kv[0][1]) if kv[0][1] in TASKS else 99)):
row = {"config": c, "task": t, "n": len(ss),
"accept_rate": sum(1 for s in ss if s.get("accepted")) / len(ss),
"mean_turns": mean([s.get("num_turns") for s in ss]),
"mean_cost_usd": mean([s.get("total_cost_usd") for s in ss]),
"mean_wall_s": mean([s.get("wall_s") for s in ss]),
"mean_tokens": {k: mean([s["tokens"].get(k) for s in ss]) for k in W},
"mean_weighted_heuristic": mean([heuristic_run(s) for s in ss]),
"model_ok_all": all(s.get("model_ok") for s in ss)}
cell_rows.append(row)
# contiguous segments in global start order
segments, cur = [], None
for s in scores:
if cur and cur["config"] == s["config"]:
cur["runs"].append(s)
else:
cur = {"config": s["config"], "runs": [s]}
segments.append(cur)
cfg_rows = []
for c, ss in per_cfg.items():
tok_sum = {k: sum(s["tokens"].get(k) or 0 for s in ss) for k in W}
probe_sum = {k: sum(s["_probe_tok"].get(k, 0) for s in ss) for k in W}
raw_w, _ = weighted(tok_sum, TIER[c])
heur = sum(heuristic_run(s) for s in ss)
probe_raw, probe_heur = weighted(probe_sum, 1)
row = {"config": c, "runs": len(ss), "accept_rate": sum(1 for s in ss if s.get("accepted")) / len(ss),
"tokens_sum": tok_sum, "probe_tokens_sum": probe_sum,
"cost_sum_usd": round(sum(s.get("total_cost_usd") or 0 for s in ss), 4),
"weighted_raw_M": raw_w / 1e6, "heuristic_M": (heur + probe_heur) / 1e6}
for w in ("five_hour", "seven_day"):
per_run, excl = 0.0, 0
for s in ss:
d, why = window_delta(s.get("meter_before"), s.get("meter_after"), w)
if d is None:
excl += 1
else:
per_run += d
seg_sum, seg_n, seg_excl = 0.0, 0, 0
for g in segments:
if g["config"] != c:
continue
d, why = window_delta(g["runs"][0].get("meter_before"), g["runs"][-1].get("meter_after"), w)
if d is None:
seg_excl += 1
else:
seg_sum += d; seg_n += 1
row[w] = {"per_run_sum_pts": round(per_run, 2), "per_run_excluded": excl,
"segments_sum_pts": round(seg_sum, 2), "segments": seg_n, "segments_excluded": seg_excl,
"quantization_error_pts": f"+-{seg_n}",
"pts_per_M_heuristic": round(seg_sum / row["heuristic_M"], 4) if row["heuristic_M"] else None,
"pts_per_M_weighted_raw": round(seg_sum / row["weighted_raw_M"], 4) if row["weighted_raw_M"] else None}
cfg_rows.append(row)
cfg_rows.sort(key=lambda r: list(CFGS).index(r["config"]))
out = {"generated_from": len(scores), "cells": cell_rows, "configs": cfg_rows,
"segments": [{"config": g["config"], "runs": [s["run"] for s in g["runs"]],
"before": g["runs"][0].get("meter_before"), "after": g["runs"][-1].get("meter_after")} for g in segments],
"heuristic_weights": {"tokens": W, "tier": TIER}}
json.dump(out, open(os.path.join(HERE, OUTP + ".json"), "w"), indent=2, default=str)
f = lambda x, d=0: "-" if x is None else (f"{x:,.{d}f}")
md = ["# Results", "", f"{len(scores)} runs. Meter deltas in percentage points of the plan window (1-pt resolution).", "",
"## Per config x task", "",
"| config | task | n | accept | turns | input | cache read | cache create | output | cost $ | wall s | heuristic (M) | model ok |",
"|---|---|---|---|---|---|---|---|---|---|---|---|---|"]
for r in cell_rows:
t = r["mean_tokens"]
md.append(f"| {r['config']} | {r['task']} | {r['n']} | {r['accept_rate']:.0%} | {f(r['mean_turns'],1)} | {f(t['input_tokens'])} | "
f"{f(t['cache_read_input_tokens'])} | {f(t['cache_creation_input_tokens'])} | {f(t['output_tokens'])} | "
f"{f(r['mean_cost_usd'],4)} | {f(r['mean_wall_s'],1)} | {f(r['mean_weighted_heuristic']/1e6,2)} | {r['model_ok_all']} |")
md += ["", "## Per config: measured meter vs local heuristic", "",
"| config | runs | accept | cost $ | heuristic (M units) | 5h pts (segments) | 5h pts (per-run sum) | 7d pts (segments) | segments | 5h pts / M heuristic | 7d pts / M heuristic |",
"|---|---|---|---|---|---|---|---|---|---|---|"]
for r in cfg_rows:
fh, sd = r["five_hour"], r["seven_day"]
md.append(f"| {r['config']} | {r['runs']} | {r['accept_rate']:.0%} | {r['cost_sum_usd']} | {r['heuristic_M']:.2f} | "
f"{fh['segments_sum_pts']} (excl {fh['segments_excluded']}) | {fh['per_run_sum_pts']} | {sd['segments_sum_pts']} (excl {sd['segments_excluded']}) | "
f"{fh['segments']} | {fh['pts_per_M_heuristic']} | {sd['pts_per_M_heuristic']} |")
md += ["", "If the /usage heuristic matched the real meter, `pts / M heuristic` would be the same for every config.",
"Each segment carries up to +-1 pt of quantization error; trust a config's number only when its segment sum is several points.", ""]
open(os.path.join(HERE, OUTP + ".md"), "w").write("\n".join(md))
print("\n".join(md))
設定:
configs.json:四個工人,附完整的模型 ID,低 effort 區塊使用--effort low。configs.json
{
"haiku-med": {"args": ["--model", "claude-haiku-5-5"], "model": "claude-haiku-5-5", "tier": 1, "note": "default effort"},
"haiku-low": {"args": ["--model", "claude-haiku-5-5", "--effort", "low"], "model": "claude-haiku-5-5", "tier": 1},
"sonnet": {"args": ["--model", "claude-sonnet-5-5"], "model": "claude-sonnet-5-5", "tier": 3, "note": "default effort"},
"opus": {"args": ["--model", "claude-opus-5-5"], "model": "claude-opus-5-5", "tier": 5, "note": "default effort"}
}
- `tasks.json`:每個任務預先植入的變更,以及套用的方式。tasks.json
{
"explore": {"inject": null, "mode": null, "kind": "answer"},
"tests": {"inject": "inject/tests.patch", "mode": "commit", "kind": "code"},
"lint": {"inject": "inject/lint.patch", "mode": "commit", "kind": "code"},
"commit": {"inject": "reference/T1.patch", "mode": "stage", "kind": "answer"},
"refactor": {"inject": null, "mode": null, "kind": "code"},
"review": {"inject": "inject/review.patch", "mode": "worktree", "kind": "answer"},
"summary": {"inject": null, "mode": null, "kind": "answer"}
}
- `ruff.toml`:lint 任務固定使用的 lint 設定。ruff.toml
# Pinned lint config for the `lint` task: a small explicit rule set.
# Upstream tinydb @ 18d73a1 is clean under it (the two per-file ignores cover
# pre-existing F401s: the __version__ re-export and middlewares' Optional).
[lint]
select = ["F401", "F541", "F841", "E722"]
[lint.per-file-ignores]
"tinydb/__init__.py" = ["F401"]
"tinydb/middlewares.py" = ["F401"]
預先植入的變更:
inject/tests.patch:翻轉Query.__ge__裡的一個比較,讓一個測試失敗。inject/tests.patch
diff --git a/tinydb/queries.py b/tinydb/queries.py
index cdab9fb..db00a2a 100644
--- a/tinydb/queries.py
+++ b/tinydb/queries.py
@@ -322,7 +322,7 @@ class Query(QueryInstance):
:param rhs: The value to compare against
"""
return self._generate_test(
- lambda value: value >= rhs,
+ lambda value: value > rhs,
('>=', self._path, rhs)
)
- `inject/lint.patch`:加入固定的 lint 設定和五個問題。inject/lint.patch
diff --git a/ruff.toml b/ruff.toml
new file mode 100644
--- /dev/null
+++ b/ruff.toml
@@ -0,0 +1,6 @@
+[lint]
+select = ["F401", "F541", "F841", "E722"]
+
+[lint.per-file-ignores]
+"tinydb/__init__.py" = ["F401"]
+"tinydb/middlewares.py" = ["F401"]
diff --git a/tinydb/operations.py b/tinydb/operations.py
index 7101a23..f8541fe 100644
--- a/tinydb/operations.py
+++ b/tinydb/operations.py
@@ -10,6 +10,7 @@ This would delete the ``foo`` field from all documents where ``foo`` equals 2.
from collections.abc import Callable, MutableMapping
+import sys
from typing import Any, Union
@@ -18,7 +19,7 @@ def delete(field: str) -> Callable[[MutableMapping], None]:
Delete a given field from the document.
"""
def transform(doc: MutableMapping):
- del doc[field]
+ removed = doc.pop(field)
return transform
diff --git a/tinydb/queries.py b/tinydb/queries.py
index cdab9fb..489a2be 100644
--- a/tinydb/queries.py
+++ b/tinydb/queries.py
@@ -227,7 +227,7 @@ class Query(QueryInstance):
:return: A :class:`~tinydb.queries.QueryInstance` object
"""
if not self._path and not allow_empty_path:
- raise ValueError('Query has no path')
+ raise ValueError(f'Query has no path')
def runner(value):
try:
diff --git a/tinydb/table.py b/tinydb/table.py
index f4027bf..eb8058c 100644
--- a/tinydb/table.py
+++ b/tinydb/table.py
@@ -818,7 +818,7 @@ class Table:
try:
raw_table = tables[self.name]
- except KeyError:
+ except:
# The table does not exist yet, so it is empty
raw_table = {}
diff --git a/tinydb/utils.py b/tinydb/utils.py
index 43cad4e..07114d5 100644
--- a/tinydb/utils.py
+++ b/tinydb/utils.py
@@ -2,6 +2,7 @@
Utility functions.
"""
+import os
from collections import OrderedDict, abc
from collections.abc import Iterator
from typing import TypeVar, Generic, Union, Optional, TYPE_CHECKING, cast
- `inject/review.patch`:一個功能 diff,帶有 `if limit:` 的邊界情況供審查。inject/review.patch
diff --git a/tinydb/table.py b/tinydb/table.py
index f4027bf..4e06ba1 100644
--- a/tinydb/table.py
+++ b/tinydb/table.py
@@ -244,10 +244,21 @@ class Table:
return list(iter(self))
- def search(self, cond: QueryLike) -> list[Document]:
+ def search(
+ self,
+ cond: QueryLike,
+ *,
+ sort_by: Optional[str] = None,
+ reverse: bool = False,
+ limit: Optional[int] = None,
+ ) -> list[Document]:
"""
Search for all documents matching a 'where' cond.
+ Results can optionally be sorted by a field (``sort_by``, descending
+ with ``reverse=True``; documents without the field always come last)
+ and truncated to the first ``limit`` results after sorting.
+
Note: a repeated search with the same ``cond`` is served from an
internal cache. The returned list is a fresh copy, but its
documents are the same objects held by the cache -- treat them as
@@ -255,8 +266,34 @@ class Table:
what a later identical search returns.
:param cond: the condition to check against
+ :param sort_by: optional field name to sort the results by
+ :param reverse: sort in descending order
+ :param limit: optional maximum number of results
:returns: list of matching documents
"""
+ if limit is not None and limit < 0:
+ raise ValueError('limit must not be negative')
+
+ # The cache always holds the plain, unsorted, untruncated result;
+ # _search returns a fresh list we are free to reorder/slice
+ docs = self._search(cond)
+
+ if sort_by is not None:
+ field = sort_by
+ present = [doc for doc in docs if field in doc]
+ missing = [doc for doc in docs if field not in doc]
+ present.sort(key=lambda doc: doc[field], reverse=reverse)
+ docs = present + missing
+
+ if limit:
+ docs = docs[:limit]
+
+ return docs
+
+ def _search(self, cond: QueryLike) -> list[Document]:
+ """
+ Plain search backed by the query cache. Returns a fresh list.
+ """
# First, we check the query cache to see if it has results for this
# query
prompt 與標準答案:
tasks/explore.md:查詢快取在哪裡失效,以及哪些公開方法會觸發它。tasks/explore.md
Where does TinyDB invalidate its query cache, and which public methods trigger it? Give file paths and function names.
- `tasks/tests.md`:pytest 失敗,修正函式庫的 bug,不要動測試。tasks/tests.md
pytest fails on this repo. Find the bug in the library code and fix it. Do not change the tests.
- `tasks/lint.md`:修正每一個 ruff 問題,且不改變行為。tasks/lint.md
ruff reports issues in tinydb/ (run `ruff check tinydb`). Fix them all without changing behaviour.
- `tasks/commit.md`:為已暫存的變更寫 commit 訊息。tasks/commit.md
Write the commit message for the staged changes. Output only the message.
- `tasks/refactor.md`:`push` 和 `pull` 操作的功能。tasks/refactor.md
I keep writing little lambdas to append things to list fields, so can you add two update operations to `tinydb.operations`, next to `increment`, `delete` and friends? `push(field, value)` should append `value` to the list stored in `field`, keeping the existing order, and `pull(field, value)` should remove every occurrence of `value` from that list, not just the first one. If a document doesn't have the field yet, `push` should create it as a one-element list, while `pull` should just leave that document alone without raising. If the field exists but isn't a list, `push` has to raise a `TypeError` instead of doing something weird with it. They should be used exactly like the existing operations, e.g. `db.update(push('tags', 'python'), User.name == 'john')`.
- `tasks/review.md`:審查未提交的變更,找出 bug 和邊界情況。tasks/review.md
Review the uncommitted changes for bugs and edge cases. List findings only.
- `tasks/summary.md`:用不超過 150 字總結儲存庫的組織方式。tasks/summary.md
Summarize how TinyDB is organised for a new contributor in at most 150 words: the main modules and what each owns.
- `tasks/explore.truth.md`:探索檢查的標準答案與比對規則。tasks/explore.truth.md
# explore: ground truth (tinydb @ 18d73a1)
- The query cache is `Table._query_cache`, an `LRUCache` (tinydb/utils.py) created in
`Table.__init__` (tinydb/table.py, `query_cache_class = LRUCache`, capacity
`default_query_cache_capacity = 10`). It is filled in `Table.search`.
- Invalidation happens in one place: `Table._update_table()` (tinydb/table.py) ends with
`self.clear_cache()`, and `Table.clear_cache()` does `self._query_cache.clear()`.
- Every public write goes through `_update_table`, so these trigger it:
`insert`, `insert_multiple`, `update` (all three branches: callable/fields, by doc_ids, by cond),
`update_multiple`, `upsert` (via `update` or `insert`), `remove` (by cond or doc_ids), `truncate`.
`Table.__init__(persist_empty=True)` also calls it. `clear_cache()` itself is public.
`TinyDB` (tinydb/database.py) forwards these via `__getattr__` to the default table;
`drop_table`/`drop_tables` do not clear a cache, they forget the `Table` instances (`self._tables`).
## Check rule (check.py explore)
PASS iff the final answer
1. contains `table.py`, AND
2. contains `clear_cache` or `_query_cache`, AND
3. names at least 3 distinct methods among
insert, insert_multiple, update, update_multiple, upsert, remove, truncate (word match, case-insensitive).
tasks/explore.truth.md on GitHub
- `tasks/review.truth.md`:審查檢查的標準答案與比對規則。tasks/review.truth.md
# review: planted defect and matching rule
The uncommitted change is the reference T4 patch (`Table.search(cond, *, sort_by, reverse, limit)`)
with one defect: the truncation guard is `if limit:` instead of `if limit is not None:`, so
`search(cond, limit=0)` returns ALL matching documents instead of `[]` (0 is falsy).
Everything else in the change is correct (missing-field docs last in both directions,
negative limit -> ValueError, cache keeps the plain result).
## Check rule (check.py review)
PASS iff the final answer (case-insensitive) matches at least one of:
- `limit\s*=?=\s*0` (e.g. "limit=0", "limit == 0")
- `limit\s+(of\s+)?(0|zero)\b` (e.g. "limit of 0", "limit zero")
- `\b(0|zero)\s+limit` / `zero[- ]limit`
- `limit` within 150 characters of `falsy|falsey|truthy|truthiness` (either order)
- `if limit:` together with `0` or `zero` within 150 characters
Decoy answers (generic review, or only the sort order) must FAIL.
tasks/review.truth.md on GitHub
週一要做的事
- 執行 Claude Code 2.1.257 或更新版本,才有 force 旗標;並使用 2.1.243 或更新版本,
/tasks才會顯示每個 subagent 的模型。 - 決定你要哪個檔案:只處理探索者就用 Explore 檔,要處理每個 subagent 就用 env 區塊。
- 把
Explore.md存成.claude/agents/Explore.md,或把 env 區塊合併進.claude/settings.json。 - 使用完整 ID
claude-haiku-5-5,不要用haiku別名,在 Bedrock、Vertex 或 Foundry 上尤其如此。 - 開一個 session,要求透過 Explore agent 搜尋程式碼,然後打開
/tasks,讀它實際用的模型和 effort。 - 任何做複雜 agentic coding 的 subagent 都留在 Sonnet 5.5 或 Opus 5.5,如果你是刻意指定了這樣的 agent,就不要設定 FORCE。
- 在相信我們的表之前,先用工具在你自己的儲存庫上重跑你自己的七個任務。
- 把
/usage讀成「哪個 skill 或 agent 在吃我的本地紀錄」,而不是額度預測。
量測結果
方案用量表,從每次 headless 執行的 rate_limit_event 讀出,解析度一個百分點。每個區塊是連續的,期間沒有任何其他東西動到這個帳號。182 次執行中,每一次的每週視窗都讀到 67%。
| 區塊 | 執行次數 | 五小時(之前) | 五小時(之後) | 每週(之前) | 每週(之後) | 牌價 |
|---|---|---|---|---|---|---|
| Haiku 5.5 低 | 70 | 21% | 21% | 67% | 67% | $0.2382 |
| Haiku 5.5 中等 | 70 | 21% | 21% | 67% | 67% | $0.3006 |
| Sonnet 5.5 | 21 | 21% | 22% | 67% | 67% | $1.2339 |
| Opus 5.5 | 21 | 22% | 23% | 67% | 67% | $2.3175 |
矩陣摘要:182 次執行,整個矩陣的牌價約 $4.09,五小時視窗兩個百分點,每週零個百分點。每個區塊最多帶有一個百分點的四捨五入誤差,所以 Sonnet 的一個百分點和 Opus 的一個百分點無法區分。在這個用量表上,任何一個模型的有範圍任務都是看不見的。關於三個模型每輪相對成本的支援頁面,沒有給出倍率 s9。
與本地明細的對照,依設定:
| 設定 | 執行次數 | 通過率 | 牌價 | /usage 啟發式(M 單位) |
五小時百分點 |
|---|---|---|---|---|---|
| haiku-med | 70 | 100% | $0.3006 | 29.48 | 0.0 |
| haiku-low | 70 | 100% | $0.2382 | 23.91 | 0.0 |
| sonnet | 21 | 100% | $1.2339 | 16.15 | 1.0 |
| opus | 21 | 100% | $2.3175 | 27.30 | 1.0 |
/usage 明細是根據你機器上的本地紀錄計算的 s3。它的公式是從客戶端執行檔讀出來的,為模型層級乘以紀錄權重。模型層級:Fable 10、Opus 5、Haiku 1、其他 3。紀錄權重:快取 token 1、未快取輸入 10、快取建立 12.5、輸出 50。這些是客戶端的本地歸因權重,不是計費,也不能證明伺服器如何計算你的額度。落差很明顯:Haiku 中等區塊得 29.48,Opus 區塊得 27.30,所以面板把 Haiku 區塊排在 Opus 區塊前面,而真正的用量表讀到的是零對一個百分點。
一個帳號一個月的紀錄把 token 放在哪裡:2,631 次 subagent 執行,42 次用 Haiku 4.5,subagent 佔全部 token 的 48.1%,Haiku 4.5 佔加權成本的 0.27%,不論模型是什麼,subagent 啟動 prompt 的中位數都是 47,117 token。
effort 在同一個被接受的功能(refactor)上改變了食量:Haiku 中等讀了 232,985 個快取讀取 token,共 13.7 輪;Haiku 低讀了 139,801 個,共 7.6 輪;Opus 讀了 87,612 個,共 8.3 輪。
延伸閱讀
- 100k 的價格懸崖:prompt 超過 100,000 token 之後,較高的價格適用於整個請求,而不是只適用於超出的部分 s8。快取 token 是否計入那 100k,是該作者的解讀,不是引用的規則。它影響 API 金鑰的經濟帳,不影響訂閱方案的用量表。定價頁面:s20。
- Tokenizer:同樣的文字在 Haiku 5.5 上比 Haiku 4.5 多算約 30% 的 token,而且 API 上預設開啟 adaptive thinking,以 effort 作為槓桿 s12。
- effort 與每個任務的 token:一次獨立的測試量到 max effort 下每個 Intelligence Index 任務約 162k 輸出 token,high effort 下約 55k,得分 38 s7。
- 一套完整的實踐者配置,附上他的數字,以及他自己的提醒:省下來的部分來自很小的主 context:s4。
- 公開的 issue #100082,在某些版本上 subagent 的 frontmatter 模型被忽略:s10。
- 公開的 issue #100154,subagent 的 transcript 低估
output_tokens,同一個任務是 461 對 15,575,伺服器端的計費不受影響 s11。由於/usage把本地紀錄的輸出乘以 50,這個低估會傳到面板上。 - 這裡沒有測試的:把 Haiku 當主模型(資料來源把 Haiku 5.5 定位為 subagent)、透過 gateway 跨供應商混用模型(不屬於 Claude Code 的設定)、1M context 的 prompt,以及 Max 方案附帶的 API 額度(屬於 Claude Platform,不是 Claude Code 額度)。
常見問題
設定 CLAUDE_CODE_SUBAGENT_MODEL 就能讓 Explore 改用 Haiku 嗎?
不行。單獨使用時,它不會改變內建的 Explore 和 Plan subagent s2。請加上 Explore agent 檔,或 force 旗標。
FORCE 會弄壞我的 Opus subagent 嗎?
它會把同一個模型套用到每個 subagent,所以會:你刻意指定 Opus 的 agent 也會跑在 Haiku 上。如果只想讓探索者便宜,請用 Explore 檔。
Haiku 5.5 夠不夠勝任 subagent 的工作?
對這七個範圍明確、在一個小儲存庫上的任務,低 effort 被接受 70 次中的 70 次,中等 effort 也是 70 次中的 70 次。Anthropic 自己仍把複雜的 agentic coding 留給 Sonnet 5.5 和 Opus 5.5 s1,Terminal-Bench 4.0 的差距是 39.2% 對 70.6%。
這樣切換能還給我多少方案額度?
對一個有範圍的任務來說,在任何模型上都不到用量表的一個百分點:整個 182 次執行的矩陣讓五小時視窗動了兩個百分點,每週視窗動了零。一週的額度真正花在哪裡,是主 context 和每個 subagent 帶著的啟動 prompt。
我可以把思考關掉來省更多嗎?
在 Claude Code 中,這些模型不行 s21。剩下的旋鈕是 effort。
AIDive