88e86b3faccd
Rebuild benchmarks as a clean single-root reposi
2 months ago
| 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())
|