TL;DR
- Claude Codeのサブエージェントは、ファイルで指示しない限りHaiku 5.5では動きません。組み込みのExploreエージェントもカスタムサブエージェントもメインモデルを引き継ぎ、サブエージェントモデル変数だけではExploreは変わりません。
- 直す方法は2つのファイルです。探索役だけのためのプロジェクトの
Explore.mdエージェントファイル(Haiku 5.5、エフォートlow)と、すべてのサブエージェントをHaikuに押し込むCLAUDE_CODE_SUBAGENT_MODEL_FORCE入りのsettings.jsonのenvブロックです。 - 実際のリポジトリ1つで182回のマトリクスを実行しました。探索、テスト、lint、コミット、リファクタリング、レビュー、要約のすべてが、Haiku 5.5でlowでもmediumでも、隠しテストか固定ルールで合格しました(それぞれ70回中70回)。
- 7つすべてをエフォートlowのHaiku 5.5に回してください。複雑なエージェント的コーディングにはSonnet 5.5かOpus 5.5を残します。これはAnthropic自身の助言です。
- 実際のプランのメーターはほとんど動きませんでした。Haiku lowの70回で5時間ウィンドウは21%から21%、週間ウィンドウは182回すべてで67%でした。
/usageの内訳は、課金ではなくローカルの重みでモデルを順位付けしています。 - フルのモデルIDを指定してください。
haikuエイリアスは、Bedrock、Vertex、FoundryではHaiku 4.5です。
情報源が語ること
Haiku 5.5の価格は、入力が100万トークンあたり、10万トークン以下のプロンプトで$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が100万トークンあたり$4と$20とされ、Haiku 5.5は、ほかの100万コンテキストのモデルと違ってプロンプトの長さで価格が決まると記されています s20。見出しの90%は、旧Haikuのリクエストのうちプロンプトが10万トークン未満だったものの割合で、値下げではありません s1。
AnthropicはHaiku 5.5を、Opus 5.5やSonnet 5.5と並べるサブエージェントとして位置づけています。複雑なエージェント的コーディングには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エージェントはデフォルトでHaikuを使わなくなり、メインモデルを引き継ぎます s24。サブエージェントのモデルについて文書化された解決順序は、呼び出しごとのパラメータ、定義のfrontmatter、CLAUDE_CODE_SUBAGENT_MODEL、そしてメインの会話のモデルです s2。CLAUDE_CODE_SUBAGENT_MODELだけでは、組み込みのExploreとPlanのサブエージェントは変わりません s2。Exploreという名前のユーザーまたはプロジェクトのサブエージェントは組み込みを上書きし、ドキュメントはHaikuモデルを指定した定義を勧めています s2。CLAUDE_CODE_SUBAGENT_MODEL_FORCE=1は、v2.1.257以降、1つのモデルをすべてのサブエージェントに適用します s2 s24。
コストのドキュメントは、単純なサブエージェントのタスクにはmodel: haikuを勧めており、サブエージェントのリクエストも同じ使用量から引かれると明記しています s3。週間メーターは、すべてのモデルで共通の1つに加え、含まれる場合はFable用の1つです s22。ほかのモデルも同じ上限から引かれます s23。
Claude Codeでは、Haiku 5.5、Opus 5.5、Sonnet 5.5、Fableのどれでも思考をオフにできません。alwaysThinkingEnabledをfalseにしても、MAX_THINKING_TOKENS=0にしても効果はありません。エフォートはlowからmaxまでで、Haiku 5.5のデフォルトはmediumです s21。haikuエイリアスは、Anthropic APIではHaiku 5.5に、Platform on AWS、Bedrock、Vertex、FoundryではHaiku 4.5に解決されます s21。
実践者のProプランの構成(mediumのOpus 5.5がメイン、Sonnet 5.5の実装役、読み取り専用のHaiku 5.5の下調べ役、lowのHaiku 5.5の出荷役)では、10セッションで13件のPRを出し、PR1件あたり5時間ウィンドウの約6%、PR1件のセッションで週間カウンターの約1%を使ったと報告されています s4。本人の注意書きはこうです。節約は安いモデルからではなく、メインのコンテキストが小さく保たれたことから来ています。サブエージェントのトークンをOpusの料金で計算しても、増えるのは5%だけだからです s4。
2つのファイル
ファイルは2つで、どちらか片方でも両方でも構いません。プロジェクトのエージェントファイル.claude/agents/Explore.mdは名前がExploreなので、組み込みの探索役を置き換えます。フルのモデルID、エフォート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.
設定ファイル.claude/settings.jsonにはenvブロックを置きます。サブエージェントのモデルと、それをすべてのサブエージェントに適用させるforceフラグです。
settings.json
{
"env": {
"CLAUDE_CODE_SUBAGENT_MODEL": "claude-haiku-5-5",
"CLAUDE_CODE_SUBAGENT_MODEL_FORCE": "1"
}
}
どちらを誰が使うか。
- すべてのサブエージェントを安くしたいなら、envブロックを使います。FORCEは、意図的にOpusに固定したものも含め、すべてのエージェントをHaikuに引きずります。
- 探索役だけを動かしたいなら、Exploreファイルを使います。ほかのサブエージェントは自分のモデルのままです。
- FORCEなしで
CLAUDE_CODE_SUBAGENT_MODELを設定しても、Exploreには何も起きません。こちらの2回のヘッドレス実行は、メインセッションがFableで「Exploreエージェントを使ってクエリキャッシュがクリアされる場所を探す」という指示でした。設定なしではツール結果がOpus 5.5を示し、変数だけをHaiku 5.5にしてもやはりOpus 5.5でした。Exploreファイルか、2つの環境変数を使うと、Haiku 5.5と出ました。
確認方法です。/tasksは、各サブエージェントが動いたモデルとエフォートを表示します(v2.1.243以降 s24)。ヘッドレス実行のAgentツールの結果にはresolvedModelが入ります。信頼すべきはその行で、ファイルではありません。
エイリアスの罠です。haikuではなくclaude-haiku-5-5と書いてください。エイリアスがHaiku 5.5になるのはAnthropic APIだけで、Bedrock、Vertex、Foundryでは今もHaiku 4.5を意味します s21。
以前から未解決の報告に、サブエージェントのfrontmatterのモデルが黙って無視されるというものがあり s25、その後の報告では2.1.277から2.1.290で、サブエージェント1,371件のうち1,149件が親のOpusモデルで動いたとされています s10。Claude Code 2.1.293では、こちらの182回すべてが設定したモデルで動きました(結果表でmodel_okがTrue)。それでも確認してください。
ルーティング表
tinydbのコミット18d73a1で7つの日常タスクを実行しました。テストは226件、ヘッドレスのclaude -p、タスクごとに決定論的な合否チェックを1つ用意しています。各セルは、合格した実行数、1回あたりの定価の平均、所要秒数の平均です。Haikuのセルはタスクごとに10回、SonnetとOpusのセルはタスクごとに3回です。
| タスク | Haiku 5.5 medium | Haiku 5.5 low | Sonnet 5.5 | Opus 5.5 | ルーティング |
|---|---|---|---|---|---|
| 探索 | 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、エフォートlow |
| テスト | 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、エフォート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、エフォートlow |
| コミット | 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、エフォートlow |
| リファクタリング | 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、エフォートlow |
| レビュー | 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、エフォートlow |
| 要約 | 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、エフォートlow |
| 7つすべて | 70/70, 合計 $0.3006 | 70/70, 合計 $0.2382 | 21/21, 合計 $1.2339 | 21/21, 合計 $2.3175 |
「リファクタリング」のタスクは小さな機能追加です。tinydb.operationsにpushとpullの更新操作を追加するもので、隠しテストファイルで採点します。コストはHaiku lowで$0.0052、Opusで$0.1675、所要時間は17.1秒対30.3秒でした。
SonnetやOpusに残すもの。Anthropic自身の言葉でいう複雑なエージェント的コーディングです s1。この7つは、小さなリポジトリ1つでの、1つあたり1分未満の範囲を絞ったタスクです。
ハーネス
この表を作ったベンチマークです。プランのメーターは共有なので、ほかにアカウントを使うものがない状態で、普通のターミナルから実行してください。tinydbのチェックアウト、隠しテスト、参照パッチは含まれていません。自分のリポジトリに向けて、自分のチェックを書いてください。
ランナーとメーター:
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`: 1セル分。固定したモデルでのヘッドレス`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`: 順序付きのセルを1つずつ実行し、ログに追記します。 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`: 順序付きのマトリクスを書き出します。各構成は連続したブロックで、安い構成が先、2周目は逆順です。 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`: 現在のプランのメーターをJSONで出力する、Haikuの1ターン。 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`行から5時間と7日のウィンドウを読み取ります。 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`: 実行のストリームから、トークン、コスト、ターン数、メーターを取り出してスコア記録にします。 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: 4つのワーカー。フルのモデルIDと、lowブロック用の--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__の比較を反転させ、テストを1つ失敗させます。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設定と5件の指摘を追加します。 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`: レビュー対象の、`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
プロンプトと正解データ:
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が失敗します。ライブラリのバグを直し、テストには触らない。 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`: ステージした変更のコミットメッセージを書く。 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`: コミットしていない変更を、バグとエッジケースの観点でレビューする。 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フラグがあるため)。また
/tasksで各サブエージェントのモデルが見えるよう、2.1.243以降も満たす。 - どちらのファイルにするか決める。探索役だけならExploreファイル、すべてのサブエージェントならenvブロック。
-
Explore.mdを.claude/agents/Explore.mdとして保存するか、envブロックを.claude/settings.jsonにマージする。 - 特にBedrock、Vertex、Foundryでは、
haikuエイリアスではなくフルIDのclaude-haiku-5-5を使う。 - セッションを始め、Exploreエージェント経由でコードベースの検索を頼み、
/tasksを開いて動いたモデルとエフォートを読む。 - 複雑なエージェント的コーディングをするサブエージェントにはSonnet 5.5かOpus 5.5を残す。意図的にそうしたエージェントを固定している場合は、FORCEを設定しない。
- こちらの表を自分のリポジトリで信頼する前に、自分の7タスクをハーネスで再実行する。
-
/usageは、クォータの予測ではなく「どのスキルやエージェントがローカルの履歴を食っているか」として読む。
測定値
プランのメーターは、ヘッドレス実行ごとのrate_limit_eventから読んだもので、分解能は1ポイントです。各ブロックは連続しており、その間アカウントには何も触れていません。週間ウィンドウは、182回すべてで67%でした。
| ブロック | 実行回数 | 5時間(前) | 5時間(後) | 週間(前) | 週間(後) | 定価 |
|---|---|---|---|---|---|---|
| Haiku 5.5 low | 70 | 21% | 21% | 67% | 67% | $0.2382 |
| Haiku 5.5 medium | 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、5時間ウィンドウで2ポイント、週間では0ポイントです。各ブロックには最大1ポイントの丸めが含まれるため、Sonnetの1ポイントとOpusの1ポイントは区別できません。範囲を絞ったタスクは、どのモデルでもこのメーターには見えません。3つのモデルのターンあたりの相対コストについてのサポートページは、倍率を示していません s9。
ローカルの内訳との比較(構成別):
| 構成 | 実行回数 | 合格率 | 定価 | /usageのヒューリスティック(M単位) |
5時間ポイント |
|---|---|---|---|---|---|
| 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。レコードの重みは、キャッシュされたトークン1、キャッシュされていない入力10、キャッシュ作成12.5、出力50です。これらはクライアントのローカルな帰属用の重みであり、課金ではなく、サーバーがクォータをどう数えるかの証拠でもありません。その差は現れています。Haiku mediumのブロックは29.48、Opusのブロックは27.30なので、パネルはHaikuのブロックをOpusのブロックより上に並べますが、実際のメーターは0ポイント対1ポイントでした。
1つのアカウントの1か月分のログでトークンがどこに行ったか。サブエージェント実行2,631回のうちHaiku 4.5は42回、サブエージェントは全トークンの48.1%、Haiku 4.5は加重コストの0.27%で、サブエージェントの起動プロンプトの中央値はモデルを問わず47,117トークンでした。
エフォートは、同じ合格した機能追加(リファクタリング)での食欲を変えます。Haiku mediumはキャッシュ読み込みトークンを232,985、13.7ターンで読み、Haiku lowは139,801、7.6ターン、Opusは87,612、8.3ターンでした。
さらに深掘りする
- 10万トークンの価格の崖: プロンプトが10万トークンを超えると、超過分ではなくリクエスト全体に高い料金が適用されます s8。キャッシュトークンが10万に含まれるかどうかは、その著者の解釈であり、引用された規則ではありません。影響があるのはAPIキーの経済性で、サブスクリプションのメーターには関係しません。料金ページ: s20。
- トークナイザー: 同じテキストがHaiku 4.5よりHaiku 5.5で約30%多いトークンとして数えられ、APIではアダプティブシンキングがデフォルトでオンで、レバーはエフォートです s12。
- エフォートとタスクあたりのトークン: 独立した計測では、maxエフォートでIntelligence Indexのタスクあたり約16万2千の出力トークン、highエフォートで約5万5千、スコアは38でした s7。
- 数字と、節約はメインのコンテキストが小さいことから来るという本人の注意書きを含む、実践者の完全な構成: s4。
- 未解決のissue #100082、一部のビルドでサブエージェントのfrontmatterのモデルが無視される: s10。
- 未解決のissue #100154、サブエージェントのトランスクリプトが
output_tokensを過小に記録する。同じブリーフで461対15,575、サーバー側の課金には影響なし s11。/usageはローカルの履歴から出力に50を掛けるため、この過小計上はパネルに及びます。 - ここでテストしていないもの: Haikuをメインモデルにすること(情報源はHaiku 5.5をサブエージェントとして位置づけている)、ゲートウェイ経由のプロバイダーをまたぐモデルの組み合わせ(Claude Codeの設定ではない)、100万コンテキストのプロンプト、Maxプランに付属するAPIクレジット(Claude Platformのもので、Claude Codeのクォータではない)。
FAQ
CLAUDE_CODE_SUBAGENT_MODELを設定すれば、ExploreはHaikuで動きますか?
いいえ。単独では、組み込みのExploreとPlanのサブエージェントは変わりません s2。Exploreエージェントファイルか、forceフラグを追加してください。
FORCEでOpusのサブエージェントが壊れますか?
1つのモデルをすべてのサブエージェントに適用するので、そうなります。意図的にOpusに固定したエージェントもHaikuで動きます。探索役だけを安くしたいなら、Exploreファイルを使ってください。
Haiku 5.5はサブエージェントの作業に十分ですか?
小さなリポジトリ1つでの、この範囲を絞った7タスクでは、lowエフォートで70回中70回、mediumで70回中70回が合格しました。Anthropic自身も、複雑なエージェント的コーディングにはSonnet 5.5とOpus 5.5を残しており s1、Terminal-Bench 4.0の差は39.2%対70.6%です。
この切り替えで、プランのクォータはどれだけ戻りますか?
範囲を絞ったタスクなら、どのモデルでもメーターの1ポイント未満です。182回のマトリクス全体で、5時間ウィンドウは2ポイント、週間ウィンドウは0ポイントしか動きませんでした。本当の1週間が消える場所は、メインのコンテキストと、各サブエージェントが持つ起動プロンプトです。
思考をオフにして、もっと節約できますか?
Claude Codeのこれらのモデルではできません s21。残るダイヤルはエフォートです。
AIDive