AIDive

Video-Paket

Claude-Code-Subagents auf Haiku 5.5 routen: die Tabelle, die zwei Dateien, das Harness

14 Min. Lesezeit

TL;DR

  • Claude-Code-Subagents laufen nicht auf Haiku 5.5, solange es keine Datei vorgibt. Der integrierte Explore-Agent und jeder eigene Subagent erben das Hauptmodell, und die Subagent-Modell-Variable allein lässt Explore unberührt.
  • Zwei Dateien beheben es: eine Projekt-Agent-Datei Explore.md (Haiku 5.5, Effort low) nur für den Explorer, und ein settings.json-env-Block mit CLAUDE_CODE_SUBAGENT_MODEL_FORCE, der jeden Subagent auf Haiku zwingt.
  • Unsere Matrix, 182 Läufe auf einem echten Repo: Erkunden, Tests, Lint, Commit, Refactor, Review und Zusammenfassung, alle von einem versteckten Test oder einer festen Regel auf Haiku 5.5 akzeptiert, bei low und bei medium Effort (je 70 von 70).
  • Leite alle sieben an Haiku 5.5 mit Effort low. Behalte Sonnet 5.5 oder Opus 5.5 für komplexes agentisches Coding, was Anthropics eigener Rat ist.
  • Der echte Plan-Zähler bewegte sich kaum: Fünf-Stunden-Fenster 21 % auf 21 % über 70 Haiku-low-Läufe, Wochenfenster 67 % bei allen 182 Läufen. Die /usage-Aufschlüsselung ordnet Modelle mit lokalen Gewichten ein, nicht nach Abrechnung.
  • Pinne die vollständige Modell-ID. Der Alias haiku ist bei Bedrock, Vertex und Foundry Haiku 4.5.

Was die Quellen sagen

Haiku 5.5 kostet 0,10 $ pro Million Input-Tokens bis zu Prompts von 100k Tokens und 0,50 $ darüber, beim Output 0,50 $ und 2,50 $. Haiku 4.5 kostet 1,00 $ und 5,00 $, Sonnet 5.5 kostet 2,00 $ und 10,00 $ s1. Die Preisseite führt Opus 5.5 mit 4 $ und 20 $ pro Million Tokens und vermerkt, dass Haiku 5.5, anders als die übrigen Modelle mit 1M Kontext, nach Prompt-Länge bepreist wird s20. Die Schlagzeile von 90 % ist der Anteil der Anfragen des vorherigen Haiku mit Prompts unter 100k Tokens, keine Preissenkung s1.

Anthropic positioniert Haiku 5.5 als Subagent neben Opus 5.5 und Sonnet 5.5 und sagt, Sonnet 5.5 und Opus 5.5 blieben die bessere Wahl für komplexes agentisches Coding, Haiku 5.5 für eng umrissene Arbeit s1. Auf Terminal-Bench 4.0 nennt der Launch-Beitrag 39,2 % für Haiku 5.5 gegenüber 70,6 % für Sonnet 5.5 s1.

Nichts geht standardmäßig an Haiku. Seit v2.1.198 nimmt der integrierte Explore-Agent nicht mehr standardmäßig Haiku, sondern erbt das Hauptmodell s24. Die dokumentierte Auflösungsreihenfolge für das Modell eines Subagents lautet: der Parameter pro Aufruf, das Frontmatter der Definition, CLAUDE_CODE_SUBAGENT_MODEL, dann das Modell der Hauptkonversation s2. CLAUDE_CODE_SUBAGENT_MODEL allein ändert die integrierten Subagents Explore und Plan nicht s2. Ein Benutzer- oder Projekt-Subagent namens Explore überschreibt den integrierten, und die Doku schlägt vor, einen mit Haiku-Modell zu definieren s2. CLAUDE_CODE_SUBAGENT_MODEL_FORCE=1 wendet ein Modell auf jeden Subagent an, ab v2.1.257 s2 s24.

Das Kosten-Dokument empfiehlt model: haiku für einfache Subagent-Aufgaben und hält fest, dass Subagent-Requests weiterhin dieselbe Nutzung verbrauchen s3. Die Wochenzähler sind einer für alle Modelle plus einer für Fable, wo enthalten s22, und andere Modelle zehren von denselben Limits s23.

In Claude Code lässt sich Thinking für Haiku 5.5, Opus 5.5, Sonnet 5.5 und Fable nicht abschalten: alwaysThinkingEnabled auf false und MAX_THINKING_TOKENS=0 haben keine Wirkung. Der Effort reicht von low bis max, und Haiku 5.5 steht standardmäßig auf medium s21. Der Alias haiku löst in der Anthropic API zu Haiku 5.5 auf und auf Platform on AWS, Bedrock, Vertex und Foundry zu Haiku 4.5 s21.

Das Pro-Plan-Setup eines Praktikers (Opus 5.5 als Hauptmodell mit medium, ein Sonnet-5.5-Implementer, ein schreibgeschützter Haiku-5.5-Scoper, ein Haiku-5.5-Shipper mit low) meldet 13 PRs über zehn Sitzungen bei jeweils etwa 6 % des Fünf-Stunden-Fensters und etwa 1 % des Wochenzählers für eine Ein-PR-Sitzung s4. Sein eigener Vorbehalt: Die Ersparnis kommt davon, dass der Hauptkontext klein bleibt, nicht vom günstigeren Modell, da die Tokens der Subagents zu Opus-Preisen nur 5 % aufschlagen s4.

Die zwei Dateien

Zwei Dateien, nimm eine oder beide. Die Projekt-Agent-Datei .claude/agents/Explore.md heißt Explore und ersetzt damit den integrierten Explorer. Vollständige Modell-ID, Effort low, nur Lese- und Suchwerkzeuge.

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.

Explore.md on GitHub

Die Settings-Datei .claude/settings.json enthält den env-Block: das Subagent-Modell plus das Force-Flag, das dafür sorgt, dass es für jeden Subagent gilt.

settings.json

{
  "env": {
    "CLAUDE_CODE_SUBAGENT_MODEL": "claude-haiku-5-5",
    "CLAUDE_CODE_SUBAGENT_MODEL_FORCE": "1"
  }
}

settings.json on GitHub

Welche für wen:

  • Nimm den env-Block, wenn jeder Subagent günstig sein soll. FORCE zieht jeden Agent auf Haiku, auch die, die du bewusst auf Opus gepinnt hast.
  • Nimm die Explore-Datei, wenn nur der Explorer wechseln soll. Die anderen Subagents behalten ihre eigenen Modelle.
  • CLAUDE_CODE_SUBAGENT_MODEL ohne FORCE bewirkt für Explore nichts. Zwei unserer Headless-Läufe, Hauptsitzung auf Fable, "nutze den Explore-Agent, um zu finden, wo der Query-Cache geleert wird": Das Tool-Ergebnis nannte Opus 5.5 ohne Einstellung und wieder Opus 5.5 mit der auf Haiku 5.5 gesetzten Variable und sonst nichts. Mit der Explore-Datei oder mit den beiden env-Variablen stand dort Haiku 5.5.

Die Kontrolle: /tasks zeigt Modell und Effort, auf denen jeder Subagent lief (v2.1.243 und später s24), und das Agent-Tool-Ergebnis eines Headless-Laufs trägt resolvedModel. Vertraue dieser Zeile, nie der Datei.

Die Alias-Falle: Schreibe claude-haiku-5-5, nicht haiku. Der Alias ist Haiku 5.5 nur in der Anthropic API; bei Bedrock, Vertex und Foundry meint er weiterhin Haiku 4.5 s21.

Ein älterer offener Bericht sagt, das Frontmatter-Modell eines Subagents sei stillschweigend ignoriert worden s25, und ein späterer zählt 1.149 von 1.371 Subagents, die auf 2.1.277 bis 2.1.290 auf dem Opus-Modell des Parents liefen s10. Auf Claude Code 2.1.293 trugen alle unsere 182 Läufe das konfigurierte Modell (model_ok True in der Ergebnistabelle). Prüfe trotzdem nach.

Die Routing-Tabelle

Sieben alltägliche Aufgaben auf tinydb bei 18d73a1, 226 Tests, headless claude -p, pro Aufgabe ein deterministischer Accept- oder Reject-Check. Jede Zelle zeigt akzeptierte Läufe, mittleren Listenpreis pro Lauf, mittlere Laufzeit in Sekunden. Haiku-Zellen sind 10 Läufe pro Aufgabe, Sonnet- und Opus-Zellen 3 Läufe pro Aufgabe.

Aufgabe Haiku 5.5 medium Haiku 5.5 low Sonnet 5.5 Opus 5.5 Route
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
Alle sieben 70/70, gesamt 0,3006 $ 70/70, gesamt 0,2382 $ 21/21, gesamt 1,2339 $ 21/21, gesamt 2,3175 $

Die Aufgabe "refactor" ist das kleine Feature: die Update-Operationen push und pull zu tinydb.operations hinzufügen, bewertet mit einer versteckten Testdatei. Sie kostete 0,0052 $ auf Haiku low und 0,1675 $ auf Opus, in 17,1 s gegenüber 30,3 s.

Was auf Sonnet oder Opus bleibt: komplexes agentisches Coding, in Anthropics eigenen Worten s1. Diese sieben sind eng umrissene Aufgaben von jeweils unter einer Minute auf einem kleinen Repo.

Das Harness

Der Benchmark, der die Tabelle erzeugt hat. Führe ihn aus einem normalen Terminal aus, ohne dass etwas anderes den Account nutzt, denn der Plan-Zähler wird geteilt. Der tinydb-Checkout, die versteckten Tests und die Referenz-Patches sind nicht enthalten: Richte es auf dein eigenes Repo und schreibe eigene Checks.

Runner und Zähler:

  • README.md: was das Harness ist und was weggelassen wurde.

    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.

README.md on GitHub

- `run.sh`: eine Zelle, headless `claude -p` mit dem gepinnten Modell, einer frischen Arbeitskopie, einer Messung des Zählers danach, dann der Check.

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

run.sh on GitHub

- `matrix.sh`: führt die geordneten Zellen nacheinander aus und hängt an ein Log an.

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

matrix.sh on GitHub

- `gen_matrix.py`: schreibt die geordnete Matrix, jede Konfiguration als zusammenhängender Block, günstige Konfigurationen zuerst, zweiter Durchgang in umgekehrter Reihenfolge.

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)")

gen_matrix.py on GitHub

- `prep.sh`: baut eine frische Arbeitskopie und wendet die eingepflanzte Änderung der Aufgabe an.

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

prep.sh on GitHub

- `env.sh`: Auth und Umgebung, geteilt von jedem Aufruf.

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"

env.sh on GitHub

- `probe.sh`: ein Haiku-Turn, der den aktuellen Plan-Zähler als JSON ausgibt.

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"

probe.sh on GitHub

- `meter.py`: liest das Fünf-Stunden- und das Sieben-Tage-Fenster aus den `rate_limit_event`-Zeilen eines Streams.

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)))

meter.py on GitHub

- `extract.py`: zieht Tokens, Kosten, Turns und den Zähler aus dem Stream eines Laufs in einen Score-Eintrag.

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']}")

extract.py on GitHub

- `check.py`: die Accept-oder-Reject-Entscheidung pro Aufgabe.

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)

check.py on GitHub

- `analyze.py`: aggregiert die Läufe zu den Tabellen pro Aufgabe und pro Konfiguration.

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))

analyze.py on GitHub

Konfiguration:

  • configs.json: die vier Worker, mit vollständigen Modell-IDs und --effort low für den Low-Block.

    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"}
}

configs.json on GitHub

- `tasks.json`: pro Aufgabe die eingepflanzte Änderung und wie sie angewendet wird.

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"}
}

tasks.json on GitHub

- `ruff.toml`: die gepinnte Lint-Konfiguration für die Lint-Aufgabe.

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"]

ruff.toml on GitHub

Eingepflanzte Änderungen:

  • inject/tests.patch: dreht einen Vergleich in Query.__ge__ um, sodass ein Test fehlschlägt.

    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/tests.patch on GitHub

- `inject/lint.patch`: fügt die gepinnte Lint-Konfiguration und fünf Befunde hinzu.

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/lint.patch on GitHub

- `inject/review.patch`: ein Feature-Diff mit einem `if limit:`-Grenzfall zum Prüfen.

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

inject/review.patch on GitHub

Prompts und Ground Truth:

  • tasks/explore.md: wo der Query-Cache invalidiert wird und welche öffentlichen Methoden das auslösen.

    tasks/explore.md

Where does TinyDB invalidate its query cache, and which public methods trigger it? Give file paths and function names.

tasks/explore.md on GitHub

- `tasks/tests.md`: pytest schlägt fehl, behebe den Bibliotheksfehler, lasse die Tests in Ruhe.

tasks/tests.md

pytest fails on this repo. Find the bug in the library code and fix it. Do not change the tests.

tasks/tests.md on GitHub

- `tasks/lint.md`: behebe jeden ruff-Befund, ohne das Verhalten zu ändern.

tasks/lint.md

ruff reports issues in tinydb/ (run `ruff check tinydb`). Fix them all without changing behaviour.

tasks/lint.md on GitHub

- `tasks/commit.md`: schreibe die Commit-Message für die gestageten Änderungen.

tasks/commit.md

Write the commit message for the staged changes. Output only the message.

tasks/commit.md on GitHub

- `tasks/refactor.md`: das Feature mit den Operationen push und 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/refactor.md on GitHub

- `tasks/review.md`: prüfe die uncommitteten Änderungen auf Fehler und Grenzfälle.

tasks/review.md

Review the uncommitted changes for bugs and edge cases. List findings only.

tasks/review.md on GitHub

- `tasks/summary.md`: fasse die Organisation des Repos in höchstens 150 Wörtern zusammen.

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/summary.md on GitHub

- `tasks/explore.truth.md`: Ground Truth und Matching-Regel für den Explore-Check.

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`: Ground Truth und Matching-Regel für den Review-Check.

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

Das machst du am Montag

  • Nutze Claude Code 2.1.257 oder neuer, damit das Force-Flag existiert, und 2.1.243 oder neuer, damit /tasks das Modell jedes Subagents zeigt.
  • Entscheide, welche Datei du willst: die Explore-Datei nur für den Explorer, den env-Block für jeden Subagent.
  • Speichere Explore.md als .claude/agents/Explore.md oder führe den env-Block in .claude/settings.json ein.
  • Nutze die vollständige ID claude-haiku-5-5, nicht den Alias haiku, besonders bei Bedrock, Vertex oder Foundry.
  • Starte eine Sitzung, bitte über den Explore-Agent um eine Codebase-Suche, öffne dann /tasks und lies ab, mit welchem Modell und Effort er lief.
  • Lass Sonnet 5.5 oder Opus 5.5 bei jedem Subagent, der komplexes agentisches Coding macht, und setze FORCE nicht, wenn du einen solchen Agent absichtlich gepinnt hast.
  • Lass deine eigenen sieben Aufgaben noch einmal durch das Harness laufen, bevor du unserer Tabelle auf deinem Repo vertraust.
  • Lies /usage als "welcher Skill oder Agent frisst meinen lokalen Verlauf", nicht als Quota-Prognose.

Messungen

Der Plan-Zähler, abgelesen aus dem rate_limit_event jedes Headless-Laufs, mit einem Punkt Auflösung. Jeder Block ist zusammenhängend, ohne dass etwas anderes den Account berührte. Das Wochenfenster zeigte bei allen 182 Läufen 67 %.

Block Läufe Fünf-Stunden vorher Fünf-Stunden nachher Woche vorher Woche nachher Listenpreis
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 $

Zusammenfassung der Matrix: 182 Läufe, die gesamte Matrix etwa 4,09 $ zum Listenpreis, zwei Punkte eines Fünf-Stunden-Fensters und null Punkte der Woche. Jeder Block trägt bis zu einem Punkt Rundung, sodass sich Sonnets Punkt und Opus' Punkt nicht unterscheiden lassen. Die eng umrissene Aufgabe ist auf diesem Zähler bei jedem der Modelle unsichtbar. Die Support-Seite zu den relativen Kosten pro Turn der drei Modelle nennt keinen Multiplikator s9.

Gegen die lokale Aufschlüsselung, pro Konfiguration:

Konfiguration Läufe Akzeptiert Listenpreis /usage-Heuristik (Mio. Einheiten) Fünf-Stunden-Punkte
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

Die /usage-Aufschlüsselung wird aus dem lokalen Verlauf auf deinem Rechner berechnet s3. Ihre aus der Client-Binärdatei gelesene Formel ist ein Modell-Tier mal einem Eintragsgewicht. Modell-Tier: Fable 10, Opus 5, Haiku 1, alles andere 3. Eintragsgewicht: gecachte Tokens 1, ungecachter Input 10, Cache-Erzeugung 12,5, Output 50. Das sind die lokalen Attributionsgewichte des Clients, keine Abrechnung und kein Beleg dafür, wie der Server dein Kontingent zählt. Die Lücke zeigt sich: Der Haiku-medium-Block erreicht 29,48 und der Opus-Block 27,30, das Panel stuft den Haiku-Block also über den Opus-Block ein, während der echte Zähler null gegen einen Punkt las.

Wohin ein Monat Logs eines Accounts die Tokens legte: 2.631 Subagent-Läufe, 42 auf Haiku 4.5, Subagents bei 48,1 % aller Tokens, Haiku 4.5 bei 0,27 % der gewichteten Kosten und ein medianer Subagent-Launch-Prompt von 47.117 Tokens, egal welches Modell.

Der Effort verändert den Appetit beim selben akzeptierten Feature (refactor): Haiku medium las 232.985 Cache-Read-Tokens über 13,7 Turns, Haiku low 139.801 über 7,6 Turns, Opus 87.612 über 8,3 Turns.

Weiter geht es

  • Die Preisklippe bei 100k: Über 100.000 Prompt-Tokens gilt die höhere Stufe für die gesamte Anfrage, nicht nur für den Überschuss s8. Ob Cache-Tokens zu den 100k zählen, ist die Lesart dieses Autors, keine zitierte Regel. Es zählt für die Wirtschaftlichkeit mit API-Key, nicht für den Zähler eines Abos. Preisseite: s20.
  • Tokenizer: Derselbe Text zählt auf Haiku 5.5 etwa 30 % mehr Tokens als auf Haiku 4.5, und adaptives Thinking ist in der API standardmäßig an, mit dem Effort als Hebel s12.
  • Effort und Tokens pro Aufgabe: Ein unabhängiger Lauf maß etwa 162k Output-Tokens pro Intelligence-Index-Aufgabe bei max Effort und etwa 55k bei high Effort für einen Score von 38 s7.
  • Ein vollständiges Setup eines Praktikers, mit seinen Zahlen und seinem eigenen Vorbehalt, dass die Ersparnis von einem kleinen Hauptkontext kommt: s4.
  • Offenes Issue #100082, Frontmatter-Modell des Subagents wird in einigen Builds ignoriert: s10.
  • Offenes Issue #100154, Subagent-Transkripte melden output_tokens zu niedrig, 461 gegen 15.575 für denselben Auftrag, serverseitige Abrechnung unberührt s11. Da /usage den Output aus dem lokalen Verlauf mit 50 multipliziert, erreicht diese Untererfassung das Panel.
  • Hier nicht getestet: Haiku als Hauptmodell (die Quellen rahmen Haiku 5.5 als Subagent), Modellmischungen über Anbieter hinweg per Gateway (keine Claude-Code-Einstellungen), Prompts mit 1M Kontext und die API-Guthaben, die in Max-Plänen enthalten sind (Claude Platform, nicht Claude-Code-Quota).

FAQ

Setzt CLAUDE_CODE_SUBAGENT_MODEL den Explore-Agent auf Haiku?

Nein. Für sich allein ändert sie die integrierten Subagents Explore und Plan nicht s2. Füge die Explore-Agent-Datei oder das Force-Flag hinzu.

Zerschießt FORCE meine Opus-Subagents?

Es wendet ein Modell auf jeden Subagent an, also ja: Ein Agent, den du absichtlich auf Opus gepinnt hast, läuft ebenfalls auf Haiku. Nimm die Explore-Datei, wenn nur der Explorer günstig sein soll.

Ist Haiku 5.5 gut genug für Subagent-Arbeit?

Für diese sieben eng umrissenen Aufgaben auf einem kleinen Repo wurde es 70 von 70 Mal bei low Effort und 70 von 70 Mal bei medium akzeptiert. Anthropic selbst behält Sonnet 5.5 und Opus 5.5 für komplexes agentisches Coding vor s1, und die Terminal-Bench-4.0-Lücke beträgt 39,2 % gegen 70,6 %.

Wie viel Plan-Quota gibt der Wechsel zurück?

Bei einer eng umrissenen Aufgabe weniger als einen Punkt des Zählers auf jedem Modell: Die gesamte Matrix aus 182 Läufen bewegte das Fünf-Stunden-Fenster um zwei Punkte und das Wochenfenster um null. Wohin eine echte Woche geht, sind der Hauptkontext und der Launch-Prompt, den jeder Subagent mitträgt.

Kann ich Thinking abschalten, um mehr zu sparen?

Nicht bei diesen Modellen in Claude Code s21. Der Effort ist der Regler, der bleibt.

Dateien