Log in
scripts/token_calibration_campaign.py 195 lines · 7.8 KB · blame Source
88e86b3faccd Rebuild benchmarks as a clean single-root reposi 2 months ago
1
#!/usr/bin/env python3
2
"""Measure chars-per-token factors per output style and write token_calibration.json.
3
4
Rows stay raw (char-based estimates); this campaign produces the versioned
5
factors file that REPORTING uses to convert raw char counts into calibrated
6
token estimates via oakbench.calibration. The committed
7
config/token_calibration.json is an uncalibrated placeholder; this script
8
overwrites it (or writes elsewhere via --out) with measured factors.
9
10
Samples are *.txt files in --samples-dir, categorized by filename prefix
11
matching a known style, e.g. status_output_01.txt -> status_output. For each
12
sample the factor is chars / tokens; per style we record the mean factor, a
13
bootstrap CI (oakbench.stats.bootstrap_ci, statistic "mean"), and n. A style
14
with no samples keeps null factors with a reason β€” null means unmeasured
15
(ADR-0002), never a silent default.
16
17
Token-counting methods, in preference order:
18
- tiktoken o200k_base when importable (optional, never required β€” ADR-0001)
19
- Anthropic count-tokens API: requires explicit --method anthropic AND
20
  ANTHROPIC_API_KEY; not implemented in this slice, and NEVER called
21
  implicitly β€” --method auto stays offline.
22
- composition heuristic: chars/4.0 for prose-like styles, chars/3.2 for
23
  diff-like styles (BPE-family tokenizers split diff syntax more finely).
24
25
Usage:
26
27
    python3 scripts/token_calibration_campaign.py \
28
        --samples-dir results/calibration-samples \
29
        --method auto \
30
        --calibration-version cal-2026.06-v1 \
31
        --out config/token_calibration.json
32
"""
33
34
from __future__ import annotations
35
36
import argparse
37
import json
38
import statistics
39
import sys
40
from pathlib import Path
41
from typing import Any, Callable
42
43
sys.path.insert(0, str(Path(__file__).resolve().parent))
44
45
from oakbench.calibration import SCHEMA_VERSION, available_token_counters
46
from oakbench.stats import bootstrap_ci
47
48
STYLES = ("status_output", "diff_output", "log_output", "prose")
49
50
# Documented heuristic constants (see module docstring): diff-like text
51
# tokenizes around 3.2 chars/token, prose-like around 4.0.
52
HEURISTIC_PROSE_CHARS_PER_TOKEN = 4.0
53
HEURISTIC_DIFF_CHARS_PER_TOKEN = 3.2
54
DIFF_LIKE_STYLES = frozenset({"diff_output"})
55
56
57
def heuristic_token_counter(style: str) -> Callable[[str], float]:
58
    rate = HEURISTIC_DIFF_CHARS_PER_TOKEN if style in DIFF_LIKE_STYLES else HEURISTIC_PROSE_CHARS_PER_TOKEN
59
    return lambda text: len(text) / rate
60
61
62
def tiktoken_token_counter(style: str) -> Callable[[str], float]:
63
    import tiktoken  # type: ignore[import-not-found]
64
65
    encoding = tiktoken.get_encoding("o200k_base")
66
    return lambda text: float(len(encoding.encode(text, disallowed_special=())))
67
68
69
def resolve_method(requested: str) -> tuple[str, Callable[[str], Callable[[str], float]]]:
70
    """Map the --method flag to a recorded method name and a counter factory.
71
72
    Never falls through to a network call: anthropic must be requested
73
    explicitly, and even then this slice refuses rather than calling out.
74
    """
75
    counters = available_token_counters()
76
    if requested == "anthropic":
77
        if not counters["anthropic_api"]:
78
            raise SystemExit("--method anthropic requires ANTHROPIC_API_KEY in the environment")
79
        raise SystemExit("--method anthropic: not implemented in this slice")
80
    if requested == "tiktoken":
81
        if not counters["tiktoken"]:
82
            raise SystemExit("--method tiktoken requires the optional tiktoken package (not importable)")
83
        return "tiktoken_o200k_base", tiktoken_token_counter
84
    if requested == "heuristic":
85
        return "composition_heuristic", heuristic_token_counter
86
    if requested == "auto":
87
        if counters["tiktoken"]:
88
            return "tiktoken_o200k_base", tiktoken_token_counter
89
        return "composition_heuristic", heuristic_token_counter
90
    raise SystemExit(f"unknown --method {requested!r}")
91
92
93
def collect_samples(samples_dir: Path) -> dict[str, list[Path]]:
94
    """Group *.txt samples by known-style filename prefix; warn on strays."""
95
    if not samples_dir.is_dir():
96
        raise SystemExit(f"--samples-dir not found or not a directory: {samples_dir}")
97
    by_style: dict[str, list[Path]] = {style: [] for style in STYLES}
98
    for path in sorted(samples_dir.glob("*.txt")):
99
        stem = path.stem
100
        for style in STYLES:
101
            if stem == style or stem.startswith(style + "_"):
102
                by_style[style].append(path)
103
                break
104
        else:
105
            print(f"skipping {path.name}: no known style prefix ({', '.join(STYLES)})", file=sys.stderr)
106
    return by_style
107
108
109
def measure_style(
110
    paths: list[Path], style: str, counter_factory: Callable[[str], Callable[[str], float]]
111
) -> dict[str, Any]:
112
    """Per-style factor entry: mean chars/tokens, bootstrap CI, n.
113
114
    Empty or zero-token samples are skipped with a warning; a style that ends
115
    up with no usable samples keeps null factors with a reason (ADR-0002).
116
    """
117
    count_tokens = counter_factory(style)
118
    factors: list[float] = []
119
    for path in paths:
120
        text = path.read_text(encoding="utf-8", errors="replace")
121
        chars = len(text)
122
        if chars == 0:
123
            print(f"skipping {path.name}: empty sample", file=sys.stderr)
124
            continue
125
        tokens = count_tokens(text)
126
        if tokens <= 0:
127
            print(f"skipping {path.name}: zero tokens counted", file=sys.stderr)
128
            continue
129
        factors.append(chars / tokens)
130
    if not factors:
131
        return {
132
            "chars_per_token": None,
133
            "ci_low": None,
134
            "ci_high": None,
135
            "n": 0,
136
            "reason": "no samples" if not paths else "no usable samples",
137
        }
138
    ci_low, ci_high = bootstrap_ci(factors, statistic="mean")
139
    return {
140
        "chars_per_token": float(statistics.mean(factors)),
141
        "ci_low": float(ci_low),
142
        "ci_high": float(ci_high),
143
        "n": len(factors),
144
    }
145
146
147
def parse_args() -> argparse.Namespace:
148
    parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
149
    parser.add_argument("--samples-dir", type=Path, required=True, help="Directory of style-prefixed *.txt samples.")
150
    parser.add_argument(
151
        "--method",
152
        choices=("auto", "tiktoken", "heuristic", "anthropic"),
153
        default="auto",
154
        help="Token counter; auto prefers tiktoken when importable and never calls network APIs.",
155
    )
156
    parser.add_argument("--out", type=Path, required=True, help="Path for the new token_calibration.json.")
157
    parser.add_argument(
158
        "--calibration-version",
159
        required=True,
160
        help="Version string stamped into the file and cited by every calibrated number, e.g. cal-2026.06-v1.",
161
    )
162
    return parser.parse_args()
163
164
165
def main() -> int:
166
    args = parse_args()
167
    method_name, counter_factory = resolve_method(args.method)
168
    by_style = collect_samples(args.samples_dir)
169
170
    factors = {style: measure_style(paths, style, counter_factory) for style, paths in by_style.items()}
171
    measured_ns = [entry["n"] for entry in factors.values() if entry["n"] > 0]
172
173
    document = {
174
        "schema_version": SCHEMA_VERSION,
175
        "calibration_version": args.calibration_version,
176
        "method": method_name,
177
        "samples_per_style": min(measured_ns) if measured_ns else 0,
178
        "factors": factors,
179
    }
180
    args.out.parent.mkdir(parents=True, exist_ok=True)
181
    args.out.write_text(json.dumps(document, indent=2, sort_keys=True) + "\n")
182
183
    if not measured_ns:
184
        print(f"wrote {args.out}, but no styles were measured", file=sys.stderr)
185
        return 1
186
    unmeasured = sorted(style for style, entry in factors.items() if entry["n"] == 0)
187
    print(
188
        f"wrote {args.out}: {len(measured_ns)}/{len(STYLES)} styles measured via {method_name}"
189
        + (f" (unmeasured: {', '.join(unmeasured)})" if unmeasured else "")
190
    )
191
    return 0
192
193
194
if __name__ == "__main__":
195
    raise SystemExit(main())