Log in
scripts/token_calibration.py 295 lines · 11.2 KB · python Blame
1
#!/usr/bin/env python3
2
"""Calibrate char/4 token estimates against real tokenizer behavior.
3
4
The benchmark harnesses estimate tokens as characters divided by four. That is
5
biased for VCS output: hex hashes tokenize at roughly 2-3 chars/token and
6
diff/JSON syntax tokenizes worse than prose, so char/4 UNDERESTIMATES real
7
tokens β€” and by a different factor per subject depending on output style.
8
Comparing Oak vs Git token deltas without knowing each side's bias risks
9
publishing the wrong number.
10
11
This tool reports, per input sample:
12
13
- chars and the naive char/4 estimate
14
- a composition-calibrated estimate from documented per-class rates
15
- the exact token count when a real tokenizer is importable
16
  (`tiktoken`, optional; never required)
17
- the calibration factor relative to char/4
18
19
Feed it captured command output (for example agent-workflow transcript
20
artifacts under results/agent-workflow/artifacts, or any saved stdout).
21
Group files with --label to get per-subject or per-operation factors.
22
23
Usage:
24
25
    python3 scripts/token_calibration.py --self-test
26
    python3 scripts/token_calibration.py path/to/outputs/*.log --label oak_diff
27
    python3 scripts/token_calibration.py results/agent-workflow/artifacts --label claude_runs
28
"""
29
30
from __future__ import annotations
31
32
import argparse
33
import json
34
import re
35
import sys
36
from pathlib import Path
37
from typing import Any
38
39
# Approximate chars-per-token rates by character class, derived from how
40
# BPE-family tokenizers (cl100k/o200k and Claude's tokenizer behave similarly
41
# in aggregate) split common VCS output. These are documented approximations
42
# for bias *direction and rough magnitude*; use a real tokenizer for exact
43
# counts. Sources: hex strings split into 2-3 char fragments; punctuation
44
# rarely merges across symbols; prose with spaces averages ~4.4 chars/token.
45
CLASS_RATES = {
46
    "hex_run": 2.4,        # commit hashes, object ids, checksums
47
    "digits": 2.7,         # line numbers, counts, sizes
48
    "punctuation": 2.0,    # diff markers, JSON syntax, path separators
49
    "prose": 4.4,          # words plus the spaces that merge into them
50
}
51
52
HEX_RUN = re.compile(r"[0-9a-fA-F]{8,}")
53
DIGITS = re.compile(r"[0-9]")
54
PUNCT = re.compile(r"[^\w\s]")
55
WORD_OR_SPACE = re.compile(r"[\w\s]")
56
57
DEFAULT_MAX_BYTES = 5 * 1024 * 1024
58
59
SELF_TEST_SAMPLES = {
60
    "git_diff_like": (
61
        "diff --git a/app/pricing.py b/app/pricing.py\n"
62
        "index 3f9c2ab8e1d44c7a..b82f1c9d0e3a55f1 100644\n"
63
        "--- a/app/pricing.py\n"
64
        "+++ b/app/pricing.py\n"
65
        "@@ -1,4 +1,4 @@\n"
66
        " def apply_discount(cents: int, percent: int) -> int:\n"
67
        "-    return cents - percent\n"
68
        "+    return cents - ((cents * percent) // 100)\n"
69
    ) * 8,
70
    "hash_heavy_log": (
71
        "commit 4f2a9c8be1d34f7a9b82f1c9d0e3a55f19c8be1d\n"
72
        "commit a9b82f1c9d0e3a55f19c8be1d34f7a9b82f1c9d0\n"
73
        "commit 0e3a55f19c8be1d34f7a9b82f1c9d04f2a9c8be1\n"
74
    ) * 12,
75
    "prose_readme": (
76
        "The benchmark suite measures how much the version control model helps "
77
        "or hurts real coding agent workflows without baking in assumptions "
78
        "about a specific model provider or interface. "
79
    ) * 10,
80
}
81
82
83
def classify_chars(text: str) -> dict[str, int]:
84
    counts = {"hex_run": 0, "digits": 0, "punctuation": 0, "prose": 0}
85
    remainder_parts: list[str] = []
86
    last_end = 0
87
    for match in HEX_RUN.finditer(text):
88
        counts["hex_run"] += match.end() - match.start()
89
        remainder_parts.append(text[last_end : match.start()])
90
        last_end = match.end()
91
    remainder_parts.append(text[last_end:])
92
    remainder = "".join(remainder_parts)
93
    counts["digits"] = len(DIGITS.findall(remainder))
94
    counts["punctuation"] = len(PUNCT.findall(remainder))
95
    counts["prose"] = len(remainder) - counts["digits"] - counts["punctuation"]
96
    return counts
97
98
99
def calibrated_estimate(text: str) -> tuple[float, dict[str, int]]:
100
    counts = classify_chars(text)
101
    estimate = sum(counts[name] / CLASS_RATES[name] for name in counts)
102
    return estimate, counts
103
104
105
def exact_tokens(text: str) -> tuple[int | None, str | None]:
106
    try:
107
        import tiktoken  # type: ignore[import-not-found]
108
    except ImportError:
109
        return None, None
110
    for encoding_name in ("o200k_base", "cl100k_base"):
111
        try:
112
            encoding = tiktoken.get_encoding(encoding_name)
113
        except Exception:
114
            continue
115
        return len(encoding.encode(text, disallowed_special=())), encoding_name
116
    return None, None
117
118
119
def analyze_text(name: str, text: str) -> dict[str, Any]:
120
    chars = len(text)
121
    char_div_4 = max(1, (chars + 3) // 4) if chars else 0
122
    calibrated, composition = calibrated_estimate(text)
123
    exact, tokenizer = exact_tokens(text)
124
    best = exact if exact is not None else calibrated
125
    return {
126
        "sample": name,
127
        "chars": chars,
128
        "char_div_4_estimate": char_div_4,
129
        "composition": composition,
130
        "calibrated_estimate": round(calibrated, 1),
131
        "exact_tokens": exact,
132
        "tokenizer": tokenizer,
133
        "best_estimate": round(float(best), 1),
134
        "calibration_factor_vs_char_div_4": round(float(best) / char_div_4, 3) if char_div_4 else None,
135
    }
136
137
138
def iter_input_files(paths: list[Path]) -> list[Path]:
139
    files: list[Path] = []
140
    for path in paths:
141
        if path.is_dir():
142
            files.extend(sorted(p for p in path.rglob("*") if p.is_file()))
143
        elif path.is_file():
144
            files.append(path)
145
        else:
146
            raise SystemExit(f"Input not found: {path}")
147
    return files
148
149
150
def aggregate(results: list[dict[str, Any]], label: str | None) -> dict[str, Any]:
151
    total_chars = sum(int(item["chars"]) for item in results)
152
    total_naive = sum(int(item["char_div_4_estimate"]) for item in results)
153
    total_best = sum(float(item["best_estimate"]) for item in results)
154
    exact_count = sum(1 for item in results if item["exact_tokens"] is not None)
155
    return {
156
        "label": label,
157
        "samples": len(results),
158
        "chars": total_chars,
159
        "char_div_4_estimate": total_naive,
160
        "best_estimate": round(total_best, 1),
161
        "calibration_factor_vs_char_div_4": round(total_best / total_naive, 3) if total_naive else None,
162
        "exact_tokenizer_samples": exact_count,
163
        "method": "exact_tokenizer" if exact_count == len(results) and results else (
164
            "mixed" if exact_count else "composition_heuristic"
165
        ),
166
        "note": (
167
            "factor > 1.0 means char/4 UNDERESTIMATES real tokens for this output style; "
168
            "apply the factor before comparing token deltas across subjects with different styles"
169
        ),
170
    }
171
172
173
# Representative tool-call framing per provider family. The command itself is
174
# excluded (zero-length placeholder) so the measurement isolates ENVELOPE
175
# overhead: what the model pays per call beyond the command text and beyond
176
# the raw stdout. Used to validate oakbench.tokens envelope constants.
177
ENVELOPE_TEMPLATES = {
178
    "anthropic_tool_use": json.dumps(
179
        {
180
            "type": "tool_use",
181
            "id": "toolu_01A1B2C3D4E5F6G7H8J9K0L1",
182
            "name": "Bash",
183
            "input": {"command": "", "description": "Run version control command"},
184
        }
185
    ),
186
    "anthropic_tool_result": json.dumps(
187
        {
188
            "type": "tool_result",
189
            "tool_use_id": "toolu_01A1B2C3D4E5F6G7H8J9K0L1",
190
            "content": "",
191
        }
192
    ),
193
    "openai_function_call": json.dumps(
194
        {
195
            "id": "call_a1B2c3D4e5F6g7H8j9K0l1M2",
196
            "type": "function",
197
            "function": {"name": "shell", "arguments": json.dumps({"command": [""]})},
198
        }
199
    ),
200
    "openai_function_result": json.dumps(
201
        {"role": "tool", "tool_call_id": "call_a1B2c3D4e5F6g7H8j9K0l1M2", "content": ""}
202
    ),
203
}
204
205
206
def envelope_report() -> dict[str, Any]:
207
    """Measure tool-call envelope token cost per provider template.
208
209
    Compares against the additive envelope constants in oakbench.tokens so a
210
    drifting provider format is caught by re-running this tool, not by a stale
211
    constant silently mispricing every row.
212
    """
213
    try:
214
        from oakbench.tokens import (
215
            TOOL_CALL_ENVELOPE_EMITTED_TOKENS,
216
            TOOL_RESULT_ENVELOPE_INGESTED_TOKENS,
217
        )
218
        constants: dict[str, Any] = {
219
            "per_call_emitted": TOOL_CALL_ENVELOPE_EMITTED_TOKENS,
220
            "per_call_ingested": TOOL_RESULT_ENVELOPE_INGESTED_TOKENS,
221
        }
222
    except ImportError:
223
        constants = {}
224
    samples = {name: analyze_text(name, text) for name, text in ENVELOPE_TEMPLATES.items()}
225
    return {
226
        "harness_constants": constants,
227
        "templates": samples,
228
        "note": (
229
            "best_estimate per template is the per-call envelope token cost for that "
230
            "provider framing, excluding command text and stdout. If these drift far "
231
            "from harness_constants, update oakbench.tokens and note it in the run metadata."
232
        ),
233
    }
234
235
236
def parse_args() -> argparse.Namespace:
237
    parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
238
    parser.add_argument("paths", nargs="*", type=Path, help="Files or directories of captured command output.")
239
    parser.add_argument("--label", help="Group label recorded in the aggregate, e.g. subject or operation name.")
240
    parser.add_argument("--max-bytes", type=int, default=DEFAULT_MAX_BYTES, help="Per-file read cap.")
241
    parser.add_argument("--per-file", action="store_true", help="Include per-file results, not just the aggregate.")
242
    parser.add_argument("--self-test", action="store_true", help="Run on embedded diff/hash/prose samples.")
243
    parser.add_argument(
244
        "--envelope",
245
        action="store_true",
246
        help="Measure tool-call envelope overhead per provider template and compare to harness constants.",
247
    )
248
    parser.add_argument("--compact", action="store_true")
249
    return parser.parse_args()
250
251
252
def main() -> int:
253
    args = parse_args()
254
    results: list[dict[str, Any]] = []
255
256
    if args.envelope:
257
        report = envelope_report()
258
        if args.compact:
259
            print(json.dumps(report, sort_keys=True, separators=(",", ":")))
260
        else:
261
            print(json.dumps(report, indent=2, sort_keys=True))
262
        return 0
263
264
    if args.self_test:
265
        for name, text in SELF_TEST_SAMPLES.items():
266
            results.append(analyze_text(name, text))
267
    elif args.paths:
268
        for path in iter_input_files(args.paths):
269
            try:
270
                text = path.read_bytes()[: args.max_bytes].decode("utf-8", "replace")
271
            except OSError as exc:
272
                print(f"skipping {path}: {exc}", file=sys.stderr)
273
                continue
274
            if not text.strip():
275
                continue
276
            results.append(analyze_text(str(path), text))
277
    else:
278
        raise SystemExit("Provide input paths or --self-test. See --help.")
279
280
    if not results:
281
        raise SystemExit("No non-empty input samples found.")
282
283
    report: dict[str, Any] = {"aggregate": aggregate(results, args.label)}
284
    if args.per_file or args.self_test:
285
        report["samples"] = results
286
287
    if args.compact:
288
        print(json.dumps(report, sort_keys=True, separators=(",", ":")))
289
    else:
290
        print(json.dumps(report, indent=2, sort_keys=True))
291
    return 0
292
293
294
if __name__ == "__main__":
295
    raise SystemExit(main())