Skip to content

Latest commit

 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 

Repository files navigation

novocab

Token-count estimation without a tokenizer. 22 character-class counters, plus a 23rd that the multilingual table prices, one left-to-right pass over a string, and a table of coefficients with one column per tokenizer generation.

tokens = max(1, round(sum over buckets of count[bucket] * coefficient[bucket]))

That is the whole algorithm. No vocabulary file, no dependency, no network, no cold start. novocab.py is a reference implementation in stdlib Python, and the prose below is complete enough that you can write your own in any language without reading it.

The tables cover 5 tokenizer generations: Claude 4.7 and later, Claude before 4.7, OpenAI o200k_base, OpenAI cl100k_base, and the SentencePiece vocabulary shared by every generative Gemini and by the Gemma open weights.

How accurate it is

Two coefficient tables ship. The default is fitted on 808 chunks of source code, JSON, technical markdown and Wikipedia in twelve languages. Mean absolute percentage error, out of fold, on those same rows:

chars/4 29 MB o200k BPE build novocab
install size 0 MB 29 MB 0 MB
floats 0 2 22
Claude 4.7+ 52.25% 11.11% 5.26%
Claude pre-4.7 43.14% 20.35% 5.29%
GPT-4o, GPT-5.x (o200k) 28.35% 5.34% 6.31%
GPT-4, GPT-3.5 (cl100k) 41.71% 40.64% 5.29%
Gemini 2.5 to 3.7 29.00% 10.90% 6.04%

The 29 MB column is a real o200k_base BPE build plus two scalars. It wins on the one generation where it is the tokenizer and loses on the other four.

The default table is not a general multilingual estimator. Its calibration corpus holds two Latin-script languages, English and Vietnamese. On the 24 Latin-script languages of a held-out 39-language Wikipedia set that the corpus does not contain, the default reads 10.4% to 42.0% against Claude 4.7+, median 27.1%, with a one-sided bias near -20% that does not cancel when you sum. German reads 28.50% and Welsh 42.03%. English on the same instrument reads 10.55%.

The multilingual table exists for that text. It adds nine continental European languages to the fit and prices a twenty-third bucket. Held out, on rows no fit has seen:

held out, MAPE Claude 4.7+ Claude pre-4.7 o200k cl100k Gemini
European prose, 20,895 chunks, default 20.70 25.20 21.51 21.87 22.70
European prose, 20,895 chunks, multilingual 8.10 9.06 9.48 8.08 8.81
Wikipedia in 39 languages, 94,274 chunks, default 19.50 22.45 20.02 20.92 20.77
Wikipedia in 39 languages, multilingual 10.48 11.23 10.77 9.97 11.39

Neither table dominates the other. The trade, with the languages that lose it named, is under the two coefficient tables.

Horizontal bar chart of mean absolute percentage error against Claude 4.7+ token counts on the 808-chunk calibration corpus, worst to best: documented rules of thumb at 52.25% and 45.46%, coding-harness estimators from 52.25% down to OpenClaw at 38.94%, estimator packages from tokenx at 40.63% to bpe-lite at 29.08%, novocab at 5.26%, and an exact tokenizer at 0.00%

Every rung is a real implementation scored on the same 808 rows under one protocol. Third-party estimators have no fitting step on this corpus, so they are evaluated straight over all 808 rows with no train split, which is the more generous treatment. novocab is the only fitted rung and is quoted out of fold.

rung Claude 4.7+ o200k what it is
chars/4 52.25 28.35 the constant OpenAI's help centre prints for English
chars/3.5 45.46 27.38 the constant Anthropic's glossary prints for English
Claude Code 52.25 28.35 CHARS_PER_TOKEN = 4
opencode 52.19 28.32 Math.max(0, Math.round(input.length / 4))
pi 52.02 28.19 Math.ceil(text.length / 4)
OpenClaw 38.94 16.76 chars / 4 with CJK characters weighted by class
tokenx 2.1.0 40.63 13.66 2 kB segment-and-rule table, calibrated for o200k
ai-tokenizer 1.0.6 31.22 0.00 30 MB BPE tables, one vocabulary per provider
bpe-lite 0.5.1 29.08 0.00 494 kB BPE, Claude-2-era Anthropic vocabulary
novocab, default profile 5.26 6.31 22 counters, 22 floats, 0 MB
ctok 0.00 exact Claude reconstruction, 808/808
tiktoken, gpt-tokenizer 0.00 o200k_base itself

The same ladder against o200k_base is in assets/benchmark-o200k.png, where two of the packages drop to 0.00% because on that target they are the tokenizer. Every bar on every chart on this page is the default profile.

Three notes on the comparands. The two BPE packages reproduce o200k_base exactly on the same rows, which is the instrument check that makes their Claude numbers readable; both document their Claude support as approximate, and bpe-lite's own accuracy report names its Anthropic provider as a reverse-engineered Claude-2-era vocabulary. tokenx states in its README that it is calibrated against o200k_base and never claims Claude, so its o200k row is its own target; tuned in its own favour with the four custom language rules its README prescribes and defaultCharsPerToken swept from 1.00 to 20.00, it reaches 9.04 on o200k. The coding-harness rows are budget heuristics that normally sit behind provider-reported usage counts, so their figures bound the error when the estimate is all there is.

Using it

from novocab import estimate, bucket_counts

estimate("hello world")                      # 3   (Claude 4.7+)
estimate("hello world", "o200k")             # 2
estimate(open("README.md").read(), "gemini")
estimate(german_text, profile="multilingual")

bucket_counts("parseConfig();")["camel"]     # 1
$ python3 novocab.py notes.txt
1284
$ cat src/*.rs | python3 novocab.py -g o200k
$ python3 novocab.py -g claude-legacy --buckets notes.txt
$ python3 novocab.py --profile multilingual artikel.txt

Generation names are claude, claude-legacy, o200k, cl100k, gemini. Profile names are default and multilingual. The empty string returns 0, the one exception to the max(1, ...) floor.

Images are priced separately and exactly, by geometry instead of by fit:

from novocab import estimate_image_tokens

estimate_image_tokens(1170, 2532)             # 3825  a phone screenshot
estimate_image_tokens(1170, 2532, "gemini")   # 1078
estimate_image_tokens(1170, 2532, "openai")   # 2304
$ python3 novocab.py --image 2560x1440
4787
$ python3 novocab.py --image 2560x1440 -g gemini
1100

The algorithm

Walk the string one code point at a time. Not bytes, not UTF-16 units. Astral characters count once.

Character classes

Define these as explicit code-point ranges. Do not use your language's isspace, isdigit, \s or \p{Nd}. Those disagree across languages in ways that break the numbers quietly: Python's isdigit() is true for U+00B2 SUPERSCRIPT TWO and for circled digits, and JavaScript's \s matches U+FEFF where Python's isspace() does not. Name the code points and the question goes away.

class code points
SPACE 0009-000D, 0020, 0085, 00A0, 1680, 2000-200A, 2028-2029, 202F, 205F, 3000
DIGIT 0030-0039, 0660-0669, 06F0-06F9
CJK 2E80-2EFF, 3040-309F, 30A0-30FF, 3100-312F, 3130-318F, 31F0-31FF, 3400-4DBF, 4E00-9FFF, A000-A4CF, AC00-D7AF, F900-FAFF, 20000-2FA1F
KANA 3040-30FF, 31F0-31FF, a subset of CJK
HANGUL 3130-318F, AC00-D7AF, a subset of CJK
CJK_PUNCT 3000-303F, FE30-FE4F, FF00-FFEF
CYRILLIC 0400-052F, 2DE0-2DFF, A640-A69F
ARABIC 0600-06FF, 0750-077F, 08A0-08FF, FB50-FDFF, FE70-FEFF
HEBREW 0590-05FF, FB1D-FB4F
OTHER_SCRIPT 0370-03FF and 1F00-1FFF (Greek); 0E00-0E7F (Thai); 0900-097F and A8E0-A8FF (Devanagari)
LATIN_ACCENTED 00C0-00FF, 0100-017F, 0180-024F, 1E00-1EFF
PUNCT_RUN 0021-0026, 0028-002F, 003A-0040, 005B-0060, 007B-007E
SYMBOL 2190-21FF, 2200-22FF, 2300-23FF, 2460-24FF, 2500-25FF, 2600-27BF, 2900-297F, 2B00-2BFF
EMOJI 1F000-1FAFF, a range that already contains regional indicators and skin-tone modifiers
VARIATION FE00-FE0F, E0100-E01EF
ZWJ 200D
MARK Unicode general category Mn. Adding Mc was tested and is worse
LETTER Unicode general category L
UPPER Unicode general category Lu

KANA and HANGUL carve CJK into three disjoint parts; whatever is in CJK and in neither subset is Han proper. SPACE and CJK_PUNCT overlap at U+3000, and the whitespace test runs first, so U+3000 is whitespace. Both orderings are part of the definition.

Write the ranges as escapes or as hex integers, never as literal characters. The literal form of the CJK class contains U+F900, which NFC-normalizes to U+8C48. A pipeline that normalized such a source would rewrite the range start and widen the class by 10,368 code points, the whole Private Use Area included. Escapes cannot be normalized.

Eight distinct script classes break words, not four: Cyrillic, Arabic, Hebrew, Greek, Thai, Devanagari, CJK, and everything else. Greek, Thai and Devanagari share one coefficient and are still three separate classes, because a Greek run followed immediately by a Thai run has to count as two words.

The pass

At each position, take the first rule that matches.

  1. Whitespace. If the code point is in SPACE, consume the whole run of consecutive SPACE code points. Add 1 to ws, since a run of any length is one unit, and add the number of U+000A inside that run to nl.
  2. CJK. If in CJK: add 1 to kana if it is in KANA, else 1 to hang if it is in HANGUL, else 1 to cjk. If in CJK_PUNCT, add 1 to cjk. One character, one unit.
  3. Combining mark. If in MARK, add 1 to mark. Marks stand alone. They never count toward a word's length.
  4. Digits. If in DIGIT, consume the whole run of d digits and add ceil(d / 3) to num.
  5. Word. If in LETTER, consume the maximal run of letters in the same script class. A script change ends the word. Let L be the run length.
    • CYRILLIC adds L to cyr. ARABIC adds L to arb. HEBREW adds L to heb. OTHER_SCRIPT adds L to other.
    • Otherwise it is a Latin-class word:
      • Accent surcharge first, before anything else. Add 1 to latacc for every code point in the run that is in LATIN_ACCENTED. This happens whether or not the word turns out to be ALL-CAPS, and the letter still counts toward the word's length below. latacc is an extra charge, not a reassignment.
      • Marker surcharge, also across the whole run. Add to latMark once for each of the six patterns in the marker table that the word matches. ALL-CAPS words included, same as latacc.
      • ALL-CAPS if L >= 2, at least one letter is cased, and every cased letter is UPPER. Add L to caps.
      • Otherwise add 1 to exactly one of word2 (L <= 2), word35 (L 3 to 5), word68 (L 6 to 8), or to none of them when L >= 9; and when L >= 9 add L to longChars.
      • In the non-ALL-CAPS case, also add 1 to camel for every position where an UPPER letter follows a cased non-UPPER letter. That is the lC boundary in parseConfig and in HTTPServer.
    • Two guards. A Latin-class run stops at U+2000, so an alphabetic code point at or above that boundary never joins a Latin word. And if the run comes out empty, as it does for U+2102 DOUBLE-STRUCK CAPITAL C, treat that code point as one wide punctuation character and advance one position. Without the second guard the pointer never moves.
  6. Symbols and emoji. If it is ZWJ, or in VARIATION, EMOJI or SYMBOL, add 1 to emoji. One coefficient covers all of them.
  7. ASCII punctuation, consumed as a run. If the code point is in PUNCT_RUN, consume the whole run of consecutive PUNCT_RUN code points. Add 1 to punct for each code point in the run, and add 1 to punctRun for the run as a whole, however long it is. Every PUNCT_RUN code point is below 128, so this rule never touches punctWide.
  8. Everything else is punctuation. Add 1 to punct. If the code point is at or above 128, add 1 to punctWide as well.

Punctuation runs

PUNCT_RUN is every ASCII punctuation and symbol character except U+0027 APOSTROPHE, which is exactly the gap between the first two ranges. The apostrophe is excluded on purpose. It lives inside English words instead of between them, and folding it in makes every contraction look like a punctuation boundary.

A punctRun is a maximal run of consecutive code points all in PUNCT_RUN. A run of nine characters scores the same 1 as a run of one.

input punctRun punct
)) 1 2
); 1 2
), 1 2
=> 1 2
}); 1 3
""" 1 3
), ( 2 3
a.b.c 2 2
don't 0 1

), ( is two runs because the space between them is not in the class. a.b.c is two runs of one character each, since the letters break them. don't is zero runs, and its single punct charge is the apostrophe falling through to rule 8.

The counter exists because real tokenizers merge adjacent punctuation. =>, """, ), and }); are each fewer pieces than their character count, and a strictly per-character price cannot represent that. Adding it is worth +0.41 to +1.16 MAPE points depending on generation, on all five at once, and the per-character punctuation price collapses by half to three quarters when it goes in.

The sum

b is the counts, c is the coefficient column.

tokens = max(1, round(
      c.ws        * b.ws
    + c.nl        * b.nl
    + c.cjk       * b.cjk           // Han plus CJK punctuation
    + c.kana      * b.kana
    + c.hang      * b.hang
    + c.num       * b.num
    + c.caps      * b.caps
    + c.punct     * (b.punct - b.punctWide)
    + c.punctWide * b.punctWide
    + c.punctRun  * b.punctRun
    + c.emoji     * b.emoji
    + c.word2     * b.word2
    + c.word35    * b.word35
    + c.word68    * b.word68
    + c.longChars * b.longChars
    + c.camel     * b.camel
    + c.mark      * b.mark
    + c.cyr       * b.cyr
    + c.latacc    * b.latacc
    + c.arb       * b.arb
    + c.heb       * b.heb
    + c.other     * b.other
    + c.latMark   * b.latMark       // 0 in the default table, which has no entry for it
))

Four surcharges are the only places a code point is charged twice, and all four are deliberate: punctWide on top of punct, punctRun on top of punct, and latacc and latMark on top of the word buckets. Note the subtraction on the punct line. The counter puts non-ASCII punctuation in both punct and punctWide; the fit saw two disjoint classes, so the sum has to take the wide characters back out of the ASCII price. Everything else partitions, and the partition is asserted on all 808 chunks by the parity harness.

The two coefficient tables

Picking between them is the one configuration decision on this page that changes accuracy by more than a point.

estimate(text)                              # default, 22 buckets
estimate(text, profile="multilingual")      # 23 buckets, nine more languages

The default table:

bucket what it counts Claude 4.7+ Claude pre-4.7 o200k cl100k Gemini
ws whitespace runs 0.15082 0.21966 0.20807 0.26741 0.05273
nl newlines inside those runs 0.88951 1.09437 0.87552 0.98286 1.42431
cjk Han plus CJK punctuation 1.14050 1.13777 0.85869 1.29798 0.70727
kana hiragana and katakana 0.81266 0.81275 0.78964 1.03003 0.46900
hang Hangul 1.33051 1.31089 0.73488 1.23908 0.70079
num digit groups of at most 3 1.64130 1.57510 1.27501 0.84841 2.04923
caps letters inside ALL-CAPS runs 0.88050 0.39795 0.17197 0.17286 0.25771
punct ASCII punctuation characters 0.30109 0.13921 0.10887 0.15759 0.11437
punctWide non-ASCII punctuation 0.30750 0.24288 0.00000 1.68743 0.07056
punctRun one per ASCII punctuation run 0.94150 0.97765 0.63549 0.56427 0.87915
emoji emoji and symbol code points 5.67236 4.88216 3.90532 2.47126 3.58867
word2 Latin words of 1 to 2 letters 0.42890 0.38281 0.91323 1.11275 0.85964
word35 Latin words of 3 to 5 letters 1.25169 0.92090 0.88736 0.81344 0.89277
word68 Latin words of 6 to 8 letters 2.06196 1.09653 0.82891 0.59397 1.29285
longChars letters in Latin words of 9+ 0.32142 0.11396 0.14146 0.15955 0.09807
camel CamelCase internal boundaries 1.81557 1.84319 0.90845 0.37484 1.15128
mark combining marks 1.71751 1.73718 0.63148 1.03969 0.20149
cyr Cyrillic letters 0.37246 0.36422 0.24104 0.47875 0.24213
latacc accented Latin letters 1.09752 1.12890 0.11085 0.97388 0.09373
arb Arabic letters 0.73599 0.72180 0.32115 0.76789 0.37441
heb Hebrew letters 0.77411 0.76283 0.41437 1.12564 0.51904
other Greek, Thai, Devanagari letters 0.72418 0.71339 0.37851 0.99794 0.40764

The multilingual table. Six floats marked F are not refitted and carry the default column's value exactly; the reason is in where the algorithm came from.

bucket Claude 4.7+ Claude pre-4.7 o200k cl100k Gemini
ws F 0.15082 0.21966 0.20807 0.26741 0.05273
nl F 0.88951 1.09437 0.87552 0.98286 1.42431
cjk 1.14310 1.13996 0.85058 1.29043 0.70238
kana 0.81086 0.81084 0.79129 1.03156 0.46972
hang 1.33073 1.31086 0.73318 1.23761 0.69969
num 1.52637 1.47540 1.49297 1.05524 2.15797
caps 0.82205 0.38407 0.15776 0.13703 0.21536
punct F 0.30109 0.13921 0.10887 0.15759 0.11437
punctWide F 0.30750 0.24288 0.00000 1.68743 0.07056
punctRun F 0.94150 0.97765 0.63549 0.56427 0.87915
emoji F 5.67236 4.88216 3.90532 2.47126 3.58867
word2 0.73601 0.59266 0.50323 0.67849 0.70466
word35 1.34254 0.84269 0.36867 0.44266 0.43391
word68 1.82737 0.98384 1.69633 1.30123 1.96863
longChars 0.34596 0.17420 0.18338 0.19689 0.15664
camel 0.98601 1.05661 0.61205 0.00000 0.64534
mark 1.71420 1.73205 0.62055 1.02896 0.19169
cyr 0.37274 0.36438 0.23960 0.47736 0.24137
latacc 0.92756 1.11371 0.54693 1.30033 0.44270
arb 0.73575 0.72164 0.32059 0.76736 0.37394
heb 0.77437 0.76303 0.41248 1.12379 0.51788
other 0.72405 0.71322 0.37746 0.99686 0.40679
latMark 3.43702 2.70713 1.30142 2.10752 1.33791

The default was fitted 2026-08-29, the multilingual table 2026-08-30. Store the date next to any estimate you keep. The floats expire, and an estimate with no record of which coefficients produced it cannot be audited later.

Five decimal places is a reproducibility artefact, not five significant digits. Copy the floats exactly, because that is what makes your implementation agree with this one bit for bit, and do not read the digits as measurement. A cluster bootstrap puts the standard error of the tightest coefficient at 0.0101 and the largest at 3.78, so none of the published floats is pinned past its first decimal place. The measurement is under earned precision.

A coefficient of 0.00000 is not a rounding artefact. Fitting is non-negative least squares, so zero means the fit found no use for that bucket on that generation. Two land there across both tables, punctWide on o200k and camel on cl100k. The near-zeros carry more information: latacc reads 0.09373 on Gemini and 0.11085 on o200k against 0.97 to 1.13 on the other three. Those two tokenizers have real merges for accented Latin, and the other three pay roughly one token per accented character, which is byte-level fallback with no merges at all.

Which column

column applies to
claude claude-opus-4-7, claude-opus-4-8, claude-opus-5, claude-sonnet-5, claude-fable-5, measured token-identical on 81,744 tokens
claude-legacy claude-sonnet-4-5, claude-haiku-4-5
o200k GPT-4o, GPT-4.1, GPT-5.x, o1/o3/o4-mini. tiktoken maps the gpt-5 prefix to o200k_base, and o200k_harmony produced byte-identical counts on 371,969 tokens, so there is no newer OpenAI encoding to target
cl100k GPT-4, GPT-3.5-turbo, text-embedding-3-*
gemini every generative Gemini the API serves: 2.5-flash, 3-flash-preview, 3.1-pro-preview, 3.1-flash-lite, 3.5-flash, 3.5-flash-lite, 3.6-flash, 3.7-flash, the -latest aliases, the omni, transcribe, robotics, computer-use, deep-research and image heads, and the Gemma 4 open weights served beside them. 21 model ids, byte-identical over a 123-probe battery. Not gemini-embedding-*

One feature set serves all five columns and only the floats differ. That is measured. Giving each generation the feature list that helps that generation was scored against the control of one shared set. The shared set wins on both Claude columns by 0.31 and 0.74 points. It loses on the two OpenAI ones by 0.20 and 0.28, both under the 0.30 decision bar.

The latMark bucket

The twenty-third bucket. Six letter patterns, each with a minimum word length at which it is allowed to fire:

pattern fires when the word minimum length
#r contains an accented letter immediately followed by r 1
^k starts with k 6
a$ ends in a 5
ei contains the pair ei anywhere 6
i$ ends in i 5
en$ ends in en 6

^ and $ anchor the word. # is any letter the ASCII fold leaves outside a-z, which on Latin-script text is an accented letter. Count the Latin-script words matching each pattern and sum, so a word scores once per pattern it carries and at most 6 in total. Length is code points in the run, the same length the word buckets are cut on, and a minimum of 1 admits every word. Case folding is ASCII A-Z only, not str.lower(), which can change a string's length and can fold an accented letter onto an ASCII one. The word run is the run every other Latin word bucket uses, ALL-CAPS words included, ending at a combining mark exactly as they do.

Per 100 running Latin words, the six fire 16.7 times on held-out European Wikipedia, 13.9 on Dutch, 3.1 on English, 2.4 on Vietnamese, 1.3 on held-out source code and 1.2 on the calibration corpus. Per pattern: en$ 11.6 on German and 8.8 on Dutch against 0.4 on English; a$ 13.7 on Italian and 11.4 on Polish; i$ 8.3 on Italian and on Turkish; ei 6.1 on German; ^k 5.2 on Turkish; #r 5.5 on Turkish.

The length gates are load-bearing. Ungated, i$ fires on 8.9% of running Vietnamese words against a 3.6% median across the eight European target languages, and a Vietnamese chunk carries 737 Latin words against a 354 corpus average. Pin the whole default table, fit one marker float at Europe scale, and an ungated set takes the Vietnamese stratum from 5.66 to 18.82 MAPE. The gated set takes it to 6.50. A minimum of 5 code points removes 88% of the Vietnamese word-final-i mass and keeps 48% to 78% of the European, because Vietnamese syllables are short and the European target words are not.

Choosing a profile

Held out, Claude 4.7+:

default multilingual
European Wikipedia, 20,895 chunks 20.70 8.10 multilingual by 12.6
European literary prose, 192 chunks 21.40 8.27 multilingual by 13.1
Wikipedia in 39 languages, 94,274 chunks 19.50 10.48 multilingual by 9.0
the 1,287-chunk validation set 11.82 6.72 multilingual by 5.1
English Wikipedia, 4,711 chunks 10.56 8.17 multilingual by 2.4
Vietnamese Wikipedia, 2,670 chunks 8.57 7.68 multilingual by 0.9
strings under 250 characters 7.22 7.07 multilingual by 0.15
source code, 13,721 chunks 7.77 7.75 even
the calibration corpus, out of fold 5.40 5.32 even
Vietnamese, 64 validation chunks 4.38 4.58 default by 0.2
Hungarian Wikipedia, 2,643 chunks 10.41 15.81 default by 5.4

For continental European prose and for mixed-language encyclopedic text, use the multilingual profile. On Claude 4.7+ it is at worst even on every published cut, and 29 of the 39 languages of the held-out Wikipedia set improve under it. On the other three generations the picture is a trade: o200k English goes 6.23 to 8.00, o200k source code 8.93 to 11.17, Gemini English 5.29 to 8.05. Keep the default for CJK-dominant text, for Hungarian, and on the non-Claude generations for English, source code and short strings.

One sentence, measured. "Physics is a natural science that investigates the fundamental phenomena of nature." is 83 characters and costs 26 Claude 4.7+ tokens; both profiles say 25 and 30. The same sentence in German is 92 characters, costs 42, and the default says 32 where the multilingual profile says 37.

Optional CJK punctuation split

CJK punctuation and fullwidth forms cost about 3 Claude tokens each against an ideograph's 1, so splitting them out of cjk is mechanically justified, and the refitted cjkPunct lands 1.8x to 3.9x above the ideograph price on every generation.

bucket Claude 4.7+ Claude pre-4.7 o200k cl100k Gemini
cjk (Han only) 1.00494 1.01816 0.76445 1.23216 0.56341
cjkPunct (3000-303F, FE30-FE4F, FF00-FFEF) 2.56856 2.39782 1.84569 1.99136 2.22289

It is off by default because it is worth +0.17 / +0.14 / -0.10 / -0.19 / +0.21 points, under the bar everywhere and negative on both OpenAI columns. Turn it on if your text is dense in fullwidth punctuation.

Images

Image pricing is geometry at all three vendors, so the formulas here are exact and the only thing that can go wrong with them is a vendor changing a constant. Measured 2026-08-29 against count_tokens (Anthropic), countTokens (Google) and usage.prompt_tokens (OpenAI). Content, codec, colour depth, alpha, EXIF orientation and GIF frame count moved no vendor's count by a single token. A 12 kB blank PNG and a 7 MB noise PNG of the same pixel dimensions cost the same, and an animated GIF is priced as one frame. Every formula is transpose-invariant.

Anthropic: a patch grid with two caps.

pw = ceil(w / 28)
ph = ceil(h / 28)
tokens = pw * ph + C

If the long edge exceeds 28 * axis_max pixels the image is resized to exactly that, both dimensions rounded half-up to whole pixels. If the resulting grid still exceeds the total patch budget, the price is the largest aspect-preserving downscale that fits, which always snaps one axis onto a multiple of 28.

generation models per axis total budget constant ceiling
anthropic opus-4-8, opus-5, sonnet-5, fable-5 92 (2576 px) 4784 +3 4787
anthropic-legacy sonnet-4-5, haiku-4-5 56 (1568 px) 1568 +4 1572

4784 patches is exactly the grid of a 2560x1440 frame, which is also the most expensive image that exists on this vendor. The widely cited 1568 px long-edge cap does not apply to the current generation, so a 2000x200 image is not resized and costs the full 579 tokens. And the price is not monotone in area: 4000x2000 is 8 megapixels and costs 4235, while 2560x1440 is 3.7 megapixels and costs 4787, because the long-edge cap crushes the short edge.

Gemini: a fixed budget, and no area term.

pw = floor(sqrt(N * w / h))
ph = floor(sqrt(N * h / w))
tokens = pw * ph                with N = 1120

This is the patch grid of the image rescaled so its area is exactly N patches. Read what is absent: w and h enter only as a ratio. The pixel dimensions do not appear in the price. A 1x1 image and an 8000x8000 image both cost 1089 tokens on Gemini 3.x, and downscaling before you send saves nothing. If an extreme aspect ratio floors one axis to zero it clamps to one patch and the other axis takes what is left of the budget, so 10000x1 costs 1120. Both floors use integer arithmetic, not a float square root: k*k <= N*w/h is equivalent to k*k <= floor(N*w/h) because k*k is an integer, so isqrt((N*w)//h) is exact.

N is set by the mediaResolution request field, and the ladder is exactly 1:2:4. LOW is 280, MEDIUM is 560 and is the Gemini 2.5 default, HIGH is 1120 and is the Gemini 3.x default. gemini-legacy is the Gemini 2.5 default path, which charges a flat 258 tokens for any image at all, measured up to 8000x8000. Which path a model sits on does not follow the version number: gemini-3-pro-image and gemini-3.1-flash-image are on the flat path, while gemini-3.5-transcribe and gemini-robotics-er-2-preview are on the 1120 path. Sort by modality, not by version.

OpenAI: patches times a multiplier, or tiles plus a base.

patch models:  tokens = floor(ceil(w / 32) * ceil(h / 32) * multiplier)
tile models:   tokens = base + ceil(w / 512) * ceil(h / 512) * tile

Patch models fit the image inside a pixel limit, cover it with 32x32 patches and shrink proportionally if the grid exceeds the budget. Tile models fit a 2048 px square, bring the shortest side down to 768 px if it exceeds it, then count 512 px tiles.

generation shape constants limits ceiling
openai-gpt-5.4 patches x 6/5 2048 px, 2500 patches 3000
openai-gpt-5.2 patches x 6/5 2048 px, 6144 patches 4915
openai-gpt-4.1-mini patches x 81/50 2048 px, 6144 patches 6635
openai-gpt-4o tiles base 85, tile 170 2048 px, short side 768 px 1445
openai-gpt-5.1 tiles base 70, tile 140 2048 px, short side 768 px 1190
openai-gpt-4o-mini tiles base 2833, tile 5667 2048 px, short side 768 px 48169

openai is an alias for openai-gpt-5.4. The multiplier is floored, not rounded up: OpenAI's published rule says round up and its published worked examples are one token high wherever the product is not already a whole number, and fourteen of twenty-four live probes contradict the published rule by flooring. The multiplier is stored as an exact fraction because at the floor boundary a float rounds the wrong way. Three commonly quoted constants are now wrong. The 1536-patch cap is gone, since gpt-4.1-mini at 2048x2048 measures 6635 tokens. detail: low is 85 tokens only on gpt-4o and gpt-4.1, against 70 on gpt-5.1 and 2833 on gpt-4o-mini. And on gpt-5.4, detail: low costs more than detail: high, because it carries a 6144-patch budget against high's 2500.

Worked examples.

generation 1170x2532 phone 2560x1440 desktop 512x512 thumbnail
anthropic 3825 4787 (ceiling) 364
anthropic-legacy 1460 1564 365
gemini 1078 1100 1089
gemini-legacy 258 258 258
openai-gpt-5.4 2304 2764 307
openai-gpt-5.2 2304 2764 307
openai-gpt-4.1-mini 3110 3732 414
openai-gpt-4o 1445 (ceiling) 1105 255
openai-gpt-5.1 1190 (ceiling) 910 210
openai-gpt-4o-mini 48169 (ceiling) 36835 8500

Fourteen of those thirty cells are direct measurements: the whole gemini and openai-gpt-5.4 rows, the 512x512 column on every generation except openai-gpt-5.2 and openai-gpt-4o-mini, and the 2560x1440 cell on both Anthropic rows.

The continuous form. For a square of side x below the caps the exact count is well approximated by a * x^2, and for any rectangle by a * w * h, since the exact form factorises. The exact count is a strict upper envelope, because ceil(w/p) * ceil(h/p) >= w*h/p^2 with equality only when both edges are exact multiples of the patch size.

Anthropic:  w*h/784 + C     <=  tokens  <=  (w+27)(h+27)/784 + C
OpenAI:     m*w*h/1024 - 1  <   tokens  <=  m*(w+31)(h+31)/1024
Gemini:     N - sqrt(N)*(sqrt(a) + 1/sqrt(a)) + 1  <  tokens  <=  N

for aspect ratio a = w/h, multiplier m, and N = 1120. Tokens per pixel squared are 1/784 for both Anthropic generations, 1.2/1024 for openai-gpt-5.4 and -5.2, 1.62/1024 for openai-gpt-4.1-mini, 170/262144 for openai-gpt-4o, 140/262144 for openai-gpt-5.1, 5667/262144 for openai-gpt-4o-mini, and zero for Gemini, which has no area term at all. The Anthropic bracket was checked against 81 uncapped real-image measurements with zero violations. Fitting a free coefficient to those 81 points returns 0.00129511 against 1/784 = 0.00127551, and the entire 1.54% difference is the ceiling edge term. Relative error decays like 1/k at the worst case x = 28k+1: 65.5% at x=57, 6.0% at x=897, 2.1% at x=2549.

Provenance and grading.

generation evidence exact rate
anthropic 138 real images, 438 synthetic points 138/138 real, 437/438 synthetic
anthropic-legacy 157 synthetic points, no real images 157/157 synthetic
gemini 275 probes, 70 of them held out 70/70 held out, 275/275 overall
gemini-legacy 60 probes across nine model ids 60/60
openai-* 24 probes across six models, 30 API requests 24/24

Re-scored against the shipped code over every logged row at every vendor, the estimator is exact on 1231 of 1232 points. The one miss is an Anthropic knife-edge at 5265x4615 where the correct grid is 73x64 and the estimator picks 74x64, a 1.37% overcount that affects roughly 0.2% of dimensions and never exceeds one patch row. A reference implementation in JavaScript agrees with this one on all 5060 cells of a 460-dimension by 11-generation cross-check.

anthropic is the strongest column, validated on real files across four image formats, six pixel modes, EXIF orientation and animated GIFs, at 100%. anthropic-legacy is derived and not re-validated: its total budget is bracketed to [1568, 1574] instead of pinned, because no probe in the searched box discriminates within that range. gemini is measured on synthetic images only, and N = 1120 is the unique integer in [1000, 1300] that produces zero misses over an 80-point aspect sweep. openai-* is verified on gpt-5.4-nano, gpt-4.1-mini, gpt-4o, gpt-5.2, gpt-5.1 and gpt-4o-mini, with gpt-5.4, gpt-5.4-mini, gpt-4.1 and gpt-5 sharing a probed model's profile and not themselves probed. Deliberately absent because nothing measured backs them: the gpt-5.5 and gpt-5.6 families, the -pro models, the deprecated patch models, every non-default detail level except gpt-5.4 at low, and Gemini's 258-per-tile path.

One implementation detail is knowingly approximate. OpenAI's own calculator applies a float32 rounding step when computing the aspect ratio for its pixel-limit resize; this implementation uses exact integer round-half-up. No probe discriminates the two, and they could differ by one patch row on a knife-edge dimension.

There is also a transport limit that is not a pricing limit. Anthropic rejects base64 image payloads above exactly 10,485,760 characters, which is 10 MiB measured on the base64 string and not on the decoded image, capping the source file at 7.5 MiB on disk. It is independent of dimensions: a 10000x10000 solid PNG passes while a 2400x1600 noise PNG does not.

What is not counted

The coefficients price content only. Every measurement behind them isolates a string from its request by subtraction, so what you get back is the cost of the text and nothing else. Not covered, by construction: the per-request message frame, which an Anthropic count_tokens call puts at 7 for claude-opus-4-8 and 9 for claude-sonnet-4-5 on a two-character message; system prompts, tool definitions, response schemas and cache boundaries; multi-message conversations, where role markers and turn structure add cost; audio and video.

Where the algorithm came from

Ground truth

One instrument per generation, with the isolation convention stated beside it.

  • Claude. POST /v1/messages/count_tokens, isolated as iso(X) = count(M + X) - count(M) with a fixed short marker M, so the request envelope cancels on both sides. The endpoint is free and rate-limited at 2,000 requests per minute and needs no messages call, so calibration costs nothing. Every later round used ctok offline instead, gated at 808/808 exact against the recorded API column before use.
  • OpenAI. Local tiktoken 0.14.0, len(encoding.encode(text)) for o200k_base and cl100k_base. Exact, no marker, no isolation, because there is no envelope to subtract.
  • Gemini. Local sentencepiece 0.2.2 over the gemma3 SentencePiece model file, pinned by the sha256 that Google's own loader pins, 262,144 pieces. Verified exact against Google's countTokens on 418 of 418 strings, a battery that included astral emoji, ZWJ sequences, combining-mark traps, control characters, byte-fallback bait and the empty string. The Gemini column is the only one whose fitting target carries no measurement error at all.

Corpora

Four corpora appear on this page. Every figure says which one it came from.

corpus chunks characters languages what it is for
calibration 808 2,160,395 12 the rows the default table was fitted on. Every default figure labelled a number of record comes from here, cross-validated inside this set
multilingual fit 1,096 2,898,907 21 the calibration corpus plus 288 European Wikipedia chunks. The rows the multilingual table was fitted on
validation 1,287 3,673,031 20 fetched 2026-08-29 from sources sharing zero groups with the calibration corpus. Never used to fit anything
large evaluation 592,284 1,263,262,482 40 plus code built to answer the corpus-size question at a scale where the answer could not be an artefact. Nothing that ships was fitted on it

Calibration corpus. 808 chunks in four length bands from 40 to 16,000 characters. 456 chunks of file content: TypeScript, Rust, JavaScript, dense JSON, English technical markdown, Chinese prose, mixed Chinese and English notes, plus Chinese, Japanese, Korean and classical Chinese chunks. 352 chunks of Wikipedia in Arabic, Chinese, English, Greek, Hebrew, Hindi, Japanese, Korean, Russian, Thai and Vietnamese. Those are content-matched: thirty English seed topics followed through interlanguage links, so the per-script figures are not confounded by subject. 179 articles, each recorded with page id and revision id, so that half is re-fetchable at the exact revision. Text is CC BY-SA 4.0. The file half is five private repositories and is not published, and the Wikipedia half is encyclopedic register only. That scope applies to every default-profile number of record on this page.

Multilingual fit corpus. The calibration corpus at full weight plus 288 Wikipedia chunks, 738,512 characters, in German, French, Spanish, Italian, Portuguese, Czech, Polish, Turkish and Dutch: 32 per language, 8 per length band, one article per chunk, at exactly the size of the eleven Wikipedia strata already there. The home corpus keeps 74.5% of the character mass. Dutch is in the fit because it is the sharpest available test of the vocabulary-wall measurement: its orthography is close to English and its whole-word hit rate is not, so a marker family selected partly on Dutch and fitted on a corpus without Dutch would be untestable at home. Finnish, Hungarian, Indonesian, Malay, Uzbek, Cebuano, Welsh and Esperanto are deliberately absent and stay in the held-out evaluation corpus as a transfer test. So does the Gutenberg register: European literary prose is in no fit, and it reads 8.27 under the profile against 21.40 under the default. No text was re-tokenized to build this corpus; every token count is one the large evaluation corpus had already recorded.

Validation corpus. 1,287 chunks, 483 source groups, the same four length bands and the same two chunkers. 863 chunks of Wikipedia in 19 languages, resolved through interlanguage links from a seed list disjoint from the calibration corpus's, every chunk carrying its page id and revision id. 32 chunks of literary Chinese from zh-classical, drawn from Special:LongPages instead of the matched topics, so that stratum is not content-matched. 224 chunks from 39 Project Gutenberg books in seven languages, boilerplate stripped by Gutenberg's own markers, public domain in the US. 168 chunks from four public repositories at pinned tags: ripgrep 14.1.1, mdBook v0.4.40, flask 3.0.3, got v14.4.2. The count of source groups it shares with the calibration corpus is asserted to be zero on every run.

The two are not the same shape, and that matters when reading any combined figure. The calibration corpus is 56% file content; the validation corpus is 80% prose.

Large evaluation corpus. 598,603 chunks and 1,263,262,482 characters of Wikipedia in 40 languages, from magibu/wikipedia-40-langs at snapshot 3b89135298addfb0292732067be584cd5aba19f1, CC BY-SA 4.0. English and Turkish are downweighted from 140,000 articles to 14,000 by a deterministic hash so all 40 enter at parity, at 4,500 chunks per language per length band; the group is the article and 15% of articles are held out. Plus 71,654 chunks and 129,225,365 characters of published open-source code from 230,345 files across 2,100 packages in local node_modules, cargo registry and uv wheel caches: TypeScript, JavaScript, Python, Rust, C, JSON and technical markdown. Bundled and minified JavaScript is identified by a mean physical line length above 200 characters and kept as its own stratum. 1,392,487,847 characters were tokenized five times over, once per generation, offline. Two things this corpus lacks that the calibration corpus has, both of which cost a refit dearly: no Thai and no Hindi. It also contains Armenian, which no bucket covers, so Armenian was measured and then excluded from every fit.

Fitting

Non-negative least squares, by projected coordinate descent, with no intercept. Both choices are measured. Plain least squares hands you negative bucket prices that cross-validate well and collapse on the next refit; an early run produced word2 = -0.77 and word68 = -1.63 before the switch. An intercept is worse: adding one costs 2.13 MAPE points on o200k and 2.21 on cl100k, because the corpus spans 40 to 16,000 characters, squared-error loss fits the constant to the large rows, and every small row then inherits it. The same defect exists in the wild: one published estimator ships an intercept of -28.83 and floors every prediction at its absolute value, so its minimum possible output is 29 tokens for any string.

Validation is five-fold cross-validation over five seeds, grouped by source, 495 groups over 808 rows, with the coefficients refitted from scratch inside every fold. Nothing is fitted on test. The multilingual table is fitted the same way over the 1,096-row corpus, with six floats pinned.

The admission bar

Fixed before any candidate ran: a candidate earns a place if it gains at least 0.30 MAPE points on at least one generation and loses more than 0.30 on none. Gains are quoted per generation and never averaged, and the full five-generation delta row is reported whether the candidate passes or fails. The bar is a decision threshold and not a significance test. Several verdicts on this page turn on differences between 0.15 and 0.42 and would be better with a fold-level spread, which was not computed.

Re-seeding the cross-validation split moves any figure on this page by up to 0.10 points, and two aggregation conventions in circulation differ by up to 0.12 on the same rows. Treat differences under about 0.1 as noise. The 0.30 bar is set at roughly three times that, deliberately.

A parity gate runs before any new number. The Python counters and the JavaScript counters must agree cell for cell on all 2,095 chunks across every bucket and every profile, and the code-point partition is asserted on every row. The current gate reads 50,280 counter cells and 31,425 estimate comparisons with zero mismatches, and the nine punctuation-run examples above are reproduced from the prose definition alone.

Sanity gates

Reject a refit if any of these fail:

  • any coefficient is negative, which means you did not use NNLS;
  • cjk falls outside 0.55 to 1.4, other outside 0.3 to 1.1, or emoji outside 2 to 12;
  • kana falls outside 0.4 to 1.2, hang outside 0.6 to 1.5, arb outside 0.25 to 1.0, or heb outside 0.35 to 1.25;
  • punctRun falls outside 0.4 to 1.2. The five shipped columns span 0.564 to 0.978, and the mechanism says it should sit near one token, since a punctuation run mostly merges into one piece;
  • more than five of the coefficients in a column are exactly zero;
  • held-out MAPE is worse than the accuracy section for text of the same kind.

latacc is exempt from the zero rule and from any lower bound. 0.00000 is a correct answer for a tokenizer with real accented-Latin merges.

A warning about the emoji gate, measured and then left in place anyway. That gate was written from an assumption about what an emoji costs, and the bucket's actual contents do not match it. Delete every emoji-bucket code point from the corpus and re-count: the marginal cost is 1.48 / 1.48 / 1.00 / 1.16 / 0.73 tokens per code point on the five generations, against fitted floats of 5.67 / 4.88 / 3.91 / 2.47 / 3.59. The reason is composition. Of the 1,481 emoji-bucket code points in the whole 2,095-chunk corpus, 1,456 are ordinary symbols, which is arrows, box-drawing and maths from technical markdown priced at 1.45 on Claude 4.7+; only 21 are true astral emoji, priced at 3.38. The gate is kept because it catches the real failure it was built for, which is the bucket collapsing when a refit dilutes it, and it has now caught that twice. Treat a value near 1.5 as a measurement and a value near 0 as the failure.

The cjk gate used to read 0.7 to 1.4. It was written when four generations were on the page, and Gemini broke it on the first try at 0.651. That was not a bad fit. It was a tokenizer that spends 1.44 characters per token on Chinese where Claude spends 0.94. If a sixth generation lands outside the widened range, widen it again and check the per-stratum error instead of rejecting the fit.

One warning the gates cannot enforce. A refit on a corpus with no CJK cannot produce a cjk coefficient, however confidently the solver reports one, because that is a singular system. The gates catch the near-singular version, which returns plausible-looking numbers. The same applies to kana, hang, arb, heb and latacc: each rests on a single language stratum in the calibration corpus, and a corpus without that language cannot price it.

How the buckets were chosen

The search protocol was fixed before it ran. At each step try every applicable tweak, adopt the best, record the gain, and stop when two consecutive adopted tweaks each gain less than 0.30 points. It was run independently on each generation, so "which buckets earn their place" is a per-generation answer instead of a claim borrowed from one column.

The Claude 4.7+ curve, starting from a 7-bucket ancestor at 20.51 MAPE: script split +8.95, word-length bins +2.14, punctuation split +1.07, camelCase +0.41, newlines +0.18, then a cliff where every remaining candidate is worth 0.06 or less. The other four generations produce the same ordering, script coverage first by a factor of four to six over anything else. Gemini, fitted last and by a different vendor with a different algorithm, ran the same protocol from 16.688 and also selected the script split first, at +3.70. That replication on a fifth tokenizer is the strongest evidence the shape is real and not fitted to one vendor's quirks.

The word-length bins look like over-engineering and are not. The measured cost of an English word is not proportional to its length and is not even monotone. A common four or five letter word costs less than a two-letter one, 1.25 tokens against 1.55, because it is a single dedicated token. No per-character coefficient and no monotone rule can bend that way, and it is why chars/4 fails hardest on short words.

Three buckets that the search could not reach were found afterwards, each because the atom set could not express the counter. Kana was swallowed by the Han ranges: the feature code's CJK list covered U+3040 to U+30FF, so no candidate the greedy could write would separate kana from Han, and splitting it takes Japanese from 14.4% to 3.9% on Claude and from 13.7% to 4.6% on Gemini. A direct frame probe measured kana at 0.815 tokens per character and the fitted coefficient landed at 0.813 without being told. Arabic and Hebrew were pulling opposite ways inside one bucket, Arabic at +25.3% bias against Hebrew's -12.9% on cl100k; separating them is worth +0.10 / +0.11 / +0.42 / +1.33 / +1.00 and takes Arabic on cl100k from 25.3% to 2.9%. And latacc turned out to be a contamination guard instead of a price: 99.5% of the calibration corpus's accented-Latin mass sits in one 32-chunk Vietnamese stratum, deleting that stratum takes the counter's value to -0.01 on every generation, and the gain lands on English, Chinese prose, JSON and code, strata with no accented Latin in them at all. Without the bucket, Vietnamese's 22,496 accented characters are priced through the shared Latin word-length bins, and those bins become a compromise between two languages. A bucket carried for one language's benefit is not dead weight, because it stops the other coefficients from being compromises.

A candidate's measured value is a property of the host shape and not of the candidate. punctRun was measured twice by the same harness against two different host shapes. The first reading was +0.05 / +0.08 / +0.49 / +0.01 and it was filed as an o200k-only curiosity. The second reading, inside the current shape, is +0.41 / +0.72 / +0.47 / +0.38 / +1.16. Both readings reproduce exactly and nothing about the counter changed. Two things about its surroundings did: the Gemini column, where it was always worth +1.17, did not exist yet, and latacc had not been added, so the Latin word coefficients were still absorbing Vietnamese along with whatever run structure punctRun would later explain. Every disposition on this page is a disposition as measured inside the shipped shape. One second-order effect from the same change is worth recording: word2 was exactly 0.00000 on both Claude columns before punctRun went in and is 0.42890 and 0.38281 after, because punctRun took over run structure that ws had been absorbing, which freed the word bins to price words again.

The marker set

The twenty-third bucket came out of a different search, because the greedy protocol above cannot express a letter pair.

What it is for. The default table under-counts continental European prose by about a fifth, and a refit on 733 times the corpus closes that gap while costing English 10.56 to 23.77 and source code 7.77 to 14.55. Ten complete refits at mixtures from 90% Wikipedia down to none of it trace a smooth curve, and no point on it reaches the corner where both halves are good. Fitting each population on its own half and scoring on the other shows why: the shipped coefficients are already within 0.5 points of the best a single vector could do on English and on code, and the refit is within 0.3 of the best on Wikipedia. The residual is capacity, and the capacity is one bucket. Splitting the four Latin word buckets by a language label the counters cannot see takes English Wikipedia from 23.77 back to 8.74 and source code from 14.55 back to 8.08 while leaving European prose alone. A label is not a counter and cannot ship.

What a letter pair recovers that a label does. Ten letter pairs pooled into a single counter, added to the same twenty-two buckets on the same 592,284-row fit, take held-out English from 23.77 to 8.33, which is past the label's 8.74, and held-out source code from 14.55 to 11.78. The frontier width goes from 13.18 to 8.10 against the label's 8.74. Three sets of ten random letter pairs drawn from the same 25,230-candidate space and mass-matched pair by pair to the winning set return the baseline to two decimal places, 23.77 / 23.77 / 23.79, with their coefficients pinned at zero. The six strongest English-marking pairs return exactly 23.77 in both additive forms, which isolates the non-negativity limit: a counter that fires on the cheap side can only add cost, so the fit has no use for it. The effect is these letters and not the extra float.

The selection adversary, which is where the shipped set comes from. A marker that helps Europe can also fire at home, and non-negative least squares makes the fired cost strictly additive with nothing to pay it back. The first selection ranked candidates on a key that scored the median over 25 expensive Latin languages minus the maximum of English and code, and Vietnamese sat on the reward side of that key. Under it, i$ ranked 2nd of 25,230. Move Vietnamese to the penalty side, change nothing else, and the same marker falls to 25,195th. The consequence is measurable to the token: pin the whole default table, fit one marker float at Europe scale, and the 32-chunk Vietnamese stratum gains 255 tokens on a 1,895-token chunk and goes from 5.66 to 18.82 MAPE.

The fix was to the adversary and not to the marker. Candidates were ranked on discovery counts from a train split alone. Sets were fitted on 88,957 European training rows and scored on 30,042 held-out European selection rows. A set was admitted only if it held 25 named home strata inside 0.30 + 2 SE of their own out-of-fold noise on all five generations at once. The 25 are the 22 calibration strata plus the 40-to-250 and 250-to-1,000 character bands plus the corpus as a whole, so nothing in the calibration corpus is unwatched. Under that cap, between 1,435 and 2,073 admissible sets exist at every size from two to eight patterns. The shipped six are one point in that region, chosen for held-out European recovery.

The cap is not an independent test of a set selected to pass it, so cuts that no fit and no cap touched carry the check instead. Under a full freeze at Europe scale, held-out Vietnamese Wikipedia goes from 8.57 under the default table to 11.26 with an ungated four-pattern set and to 7.18 with the gated six. The validation corpus's 64 Vietnamese chunks go from 4.38 to 15.82 and to 5.37. On the same freeze, held-out European goes to 9.03 with the ungated set and to 8.60 with the gated one, so the guards cost nothing on the population they were meant to serve.

Six floats are deliberately not refitted and carry the default column's value exactly: ws, nl, punct, punctWide, punctRun and emoji. Those six are identified by the calibration corpus's file registers, and the European extension contains no telegraph, no emoji, no JSON and almost no newlines, so refitting them on it moves them without evidence. Measured: letting them move costs the calibration corpus 0.28 points out of fold, takes four home strata past their measured noise instead of one, and drives emoji to 1.24 / 0.00 / 1.45 on o200k / cl100k / Gemini, outside the gate this page publishes.

The vocabulary wall

"Capacity" names a phenomenon without explaining it, so the phenomenon was decomposed. English is cheap because the vocabulary was built on it. Priced word by word on the four exact tokenizers, frequency-weighted over running text and matched to 4-to-8-letter words, the share of words the tokenizer knows as one whole piece:

English code identifiers median of 11 other Latin languages
Claude 4.7+ 38.3% 42.3% 4.6%
Claude pre-4.7 81.8% 81.4% 38.0%
o200k_base 88.1% 88.9% 67.9%
cl100k_base 86.9% 89.3% 34.2%

Everything else follows from that row. Decomposing the chars-per-token gap into named channels of word length, sub-word splitting, diacritics and capitalisation puts 97% to 120% of it in splitting on every generation, with diacritics between -7% and +6% and length pushing the other way. Source identifiers are English, measured instead of by analogy: 42.3% against 38.3%, and 2.657 characters per token against 2.641 on the same word-level instrument.

The tell you would reach for first points the wrong way. An accented spelling is usually the one in the vocabulary, so stripping the accents makes a word more expensive: on o200k_base the Polish word for enterprise costs 2 pieces spelled correctly and 5 with its diacritics removed, and the Vietnamese word for person costs 1 and 2.

The strongest version of the test restricts both sides to words that carry no observable mark at all: lowercase, pure ASCII, four to eight letters, no consonant run of four or more, vowel ratio at least 0.35, so that a counter looking at code points cannot tell them apart. The gap does not shrink.

Claude 4.7+, chars/token vs English German Dutch Polish Finnish
all 4-8 letter words -33.4% -29.1% -31.9% -37.2%
orthographically indistinguishable subset -38.8% -38.4% -44.2% -46.9%

Whole-word hit rate on that subset: English 47.9%, Dutch 6.2%, German 0.7%, Polish 0.7%, Finnish 0.2%. Dutch carries 2.5 accented words per thousand against English's 1.4, statistically the same page, and runs 2.136 characters per token against English's 2.750 on those same rows.

One sentence of interpretation, marked as interpretation because it is not measured. A vocabulary is built by frequency statistics over scraped text, and nothing in that pipeline is told what a language is; the dead tokens that survive in released vocabularies, reachable by no input, are the documented trace of a process with no such notion. So a counter of character statistics is closer to the thing that actually happened than a language label is.

Earned precision

The corpus is 808 chunks, which is small. That is defensible only if the coefficients had stopped moving, so it was tested. The corpus was expanded to 2,095 chunks with 1,287 new chunks sharing no source with the fit. Frozen against 511 held-out chunks in the same eleven languages and the same register, the shipped coefficients score 4.6 / 4.4 / 5.8 / 4.2 / 5.7 against their published 5.26 / 5.29 / 6.31 / 5.29 / 6.04, better on text they have never seen. Refitting all twenty-two buckets on the doubled corpus makes those rows worse by 1.7 to 3.5 points.

Two panels. Left: bootstrap standard error of all 22 coefficients against corpus size on log-log axes, nine rungs from 792 to 534,892 chunks, every trace falling along a 1/sqrt(N) slope with dashed reference lines at the 2nd, 3rd, 4th and 5th decimal places; the traces cross the 2nd and 3rd lines and still stop far above the 5th. Right: ten refits of the whole model at different calibration mixtures, plotted as error on English prose and source code against error on 39-language Wikipedia, tracing a curve that never enters the low-error corner, with the shipped coefficients marked separately

Whether the numbers printed in the table have converged is a different and stricter question. It was measured with a stratified cluster bootstrap: 2,000 resamples of the calibration corpus drawn as whole source groups, so that correlated chunks from one file or one article move together, with all twenty-two buckets refitted from scratch on each draw.

Not one published float is pinned at the precision it is printed to. A coefficient is pinned to d decimal places when its standard error is below half of the last digit, SE < 0.5e-d. Of the 110 floats in the default table, zero reach five places; 41 reach one place, 62 reach none, and 7 do not pin even the units digit. The 41 are cjk, kana, hang, cyr, arb, heb and other on all five generations, plus longChars on o200k, cl100k and Gemini, and ws, punctWide and mark on o200k. The 7 are emoji on all five generations and camel on both Claude columns.

The smallest standard error on the page is other on o200k at 0.37851, SE 0.0101, a 2.7% coefficient of variation; on Claude 4.7+ the same bucket is smallest at 0.72418 with SE 0.0140. The largest is emoji on Claude 4.7+, SE 3.78 against a coefficient of 5.67236, whose 95% interval runs from 2.07 to 14.05. The split is structural: the seven buckets counting characters in a single script are the tight ones at 1.3% to 5.5% on every generation, and they carry 48% of the predicted tokens between them. The word and punctuation buckets are the loose ones, mostly 20% to 55%.

More text buys half a decimal digit per factor of ten, and that law was tested. Fitting the standard error against corpus size over seven rungs from 200 to 1,154 chunks with the composition held constant gives a median log-log slope of -0.52 against the -0.50 that 1/sqrt(N) predicts. A corpus ten times the size therefore divides every standard error by 3.16, which moves 51 of the 110 floats up one decimal place and none of them up two. To earn the fifth printed decimal the median coefficient needs a corpus about 200 million times larger, roughly 5e14 characters.

That extrapolation was then checked against a corpus 733 times larger and it was right. The same estimator ran over nine rungs from 792 to 534,892 chunks at 300 resamples per rung, three decades of corpus size instead of half of one, and the median slope came out at -0.491. Across that 675-fold increase the law predicts a 26.0x fall in every standard error, and the measured per-bucket falls run from 24.7x for punctRun to 33.3x for cyr. What that bought: 16 of the 110 floats reach two decimal places, 55 reach one, 32 reach none, and 3 still do not pin the units digit. The median float gained exactly one place. Two buckets sit far off the law and both are named: emoji at -0.147 and camel at -0.369, because Wikipedia carries almost none of either and their draws sit on the non-negativity boundary.

What the uncertainty costs the answer is much smaller than what it costs the floats. Scoring all 2,000 bootstrap coefficient vectors as estimators, 95% of them land inside these bands. The shipped column is the in-fold figure, because that is what a vector fitted on all 808 rows and scored on all 808 produces:

generation shipped, in fold 95% of refits on a fresh 808 chunks width split-noise floor
Claude 4.7+ 5.04 5.05 to 5.66 0.61 0.12
Claude pre-4.7 5.02 4.98 to 5.98 1.00 0.12
o200k 6.02 5.84 to 7.12 1.28 0.12
cl100k 5.21 4.83 to 6.20 1.37 0.12
Gemini 5.73 5.70 to 6.72 1.02 0.12

The reason the output is so much better determined than its parameters is that the loose floats trade against each other. In the bootstrap draw distribution word35 and latacc sit at r = -0.875 on Claude 4.7+, and their sum is 2.1 times better determined than the two floats separately; punct and punctRun sit at -0.769 for 1.46x, and cjk and kana at -0.845 on o200k for 2.51x. The word35 and latacc trade is the contamination-guard mechanism arriving from a third direction: the fit cannot separate an accent price from a European word price, so it prices whichever the objective prefers.

Sampling more text of the same kind does not move these coefficients. Changing the distribution does. Both halves are measured, and neither is an inference from the other.

what changed median shift, in units of the coefficient's own standard error buckets past 2 SE, out of 22
a fresh, fully independent 346-chunk sample at the same composition, sharing no source with the fit 0.55 to 0.92 1 to 2, which is the rate chance alone produces
a refit at 1,154 chunks, 43% past the published corpus, same twelve languages, composition held 0.52 to 0.90 1 to 4
a refit at 2,095 chunks, adding 512 rows in eight new languages 1.13 to 1.92 6 to 9

The first row is the convergence claim as a number. An independently drawn sample from the same distribution, with zero shared sources, reproduces the published coefficients inside their own error bars on 20 or 21 of 22 buckets. The exception repeats on every generation and is named: cjk moves 2.5 to 4.0 standard errors, from 1.141 to 1.251 on Claude 4.7+, because the fresh CJK rows include classical Chinese and Japanese Wikipedia that price denser than the calibration corpus's CJK. So the convergence claim ships conditioned on the calibration distribution.

Error versus length

The accuracy tables are per chunk. This is per corpus, and it is the receipt for "the longer the text, the better the estimate" and for the ceiling on that claim.

Chart of estimation error against text length on a logarithmic axis from 200 characters to 1 megabyte. A shaded envelope between the 10th and 90th percentiles of the signed error narrows from a range of about minus 13 to plus 10 percent at 200 characters down to under one percent at a megabyte, closing on a bias floor of minus 0.27 percent instead of on zero. A solid line tracks the median absolute error from 5.6 percent down to 0.4 percent, with the other four tokenizer generations as light lines behind it

Draw chunks length-proportionally from the 2,095-chunk corpus until the text reaches length L, take a random window from the final piece, truncate to exactly L, then run the real tokenizer on the concatenation and estimate on the same string. 13,040 samples, thirteen lengths from 200 characters to 1 MB, four content mixes, seed 20260830, no API calls. The rows below are the honest out-of-sample mix: the calibration corpus's own file-to-prose split and language balance, rebuilt from text no coefficient has seen. Median and p90 of the absolute signed error, with k the mean number of source documents per sample:

text length k Claude 4.7+ Claude pre-4.7 o200k cl100k Gemini
200 chars 1.0 5.6 / 14.5 4.9 / 12.7 5.7 / 16.3 4.8 / 13.5 4.7 / 11.9
1,000 1.1 3.9 / 11.4 2.9 / 8.0 5.3 / 13.2 3.9 / 12.0 3.5 / 9.9
4,000 1.3 2.9 / 9.0 2.8 / 7.9 4.0 / 13.8 3.3 / 11.2 3.0 / 8.1
16,000 3.3 2.2 / 5.5 1.9 / 5.1 3.0 / 7.1 2.1 / 5.8 2.3 / 6.5
64,000 10.7 1.2 / 3.4 1.0 / 3.4 1.9 / 4.7 1.2 / 3.9 1.4 / 3.2
256,000 41.1 0.7 / 1.7 0.7 / 1.8 1.1 / 2.7 0.8 / 2.0 0.9 / 2.1
1,000,000 158.4 0.4 / 0.8 0.5 / 1.0 0.7 / 1.7 0.5 / 1.0 0.7 / 1.2

Length at which the mean absolute error, the same convention as the headline 5.26, first reaches each level on this mix:

Claude 4.7+ Claude pre-4.7 o200k cl100k Gemini
5% 2.1 kB 0.5 kB 5.6 kB 2.3 kB 0.8 kB
3% 11.9 kB 8.1 kB 28.8 kB 11.2 kB 16.1 kB
1% 167 kB 154 kB 557 kB 268 kB 275 kB
0.1% never never never never never

So the headline figure is the error of a paragraph. On this out-of-sample mix the mean absolute error equals each generation's published out-of-fold figure between 200 and 1,200 characters, and scored on native corpus chunks instead of cut windows the crossing sits near 800. That offset was measured at 0.7 to 0.8 points, so every crossing above is a mild under-estimate. Read them to one significant figure: the rungs roughly double, so a crossing is located to within a factor of about 1.4 by construction.

The floor, and why 0.1% is out of reach. The error is a random part that shrinks with length plus a bias that does not, and the bias belongs to the content mix. Mean signed error at 1 MB, which is that mix's aggregate error:

mix Claude 4.7+ Claude pre-4.7 o200k cl100k Gemini
calibration corpus, in sample -0.03 -0.03 +0.07 +0.05 -0.03
held out, same mix, new text -0.27 -0.40 +0.62 +0.03 -0.67
70% English and code -3.55 -3.98 -2.01 -1.80 -3.81
50% German, French, Spanish, Italian, Portuguese, Czech, Polish, Turkish -8.86 -10.01 -7.11 -6.56 -9.55

Out of sample the fitted floor is 0.32% to 0.68%, above 0.1, so no length reaches a tenth of a percent. In sample the floor fits at 0.07% to 0.23% and 0.1% would need on the order of 10^10 characters. The aggregate error quoted elsewhere is not the error at infinite length. It is the floor, and a half-European corpus sets that floor thirty times higher.

Length buys precision, not accuracy. On the half-European mix the median error rises from 6.0% at 8 kB to 9.5% at 128 kB, and on the English-heavy mix it bottoms at 2.6% near 32 kB and returns to 3.5% at 1 MB. Averaging turns a wide distribution straddling zero into a narrow one centred on the mix bias, so once the bias is worse than the median single-document error, more text makes the median worse. The p90 falls monotonically on every mix; the median does not. The claim is about the tail, and about a mix whose bias is small.

sqrt(a^2/L + b^2) describes the curve only above about 16 kB, where its worst relative residual is 8% to 34%. Across the whole range it is off by 81% to 129%, because below 16 kB a sample is one document and there is nothing to average; there the error decays as about L^-1/3.

One caveat for anyone summing per-file estimates. estimate is a sum of counters, so it adds across files. A tokenizer does not, because a merge can cross the join. Measured on 3,000 document pairs, tokenising the concatenation costs 0.739 fewer tokens per join on both Claude generations, 0.124 on o200k, 0.119 on cl100k and 0.038 on Gemini. The OpenAI encodings pre-split with a regular expression so 86% to 87% of joins change nothing; Claude has no such barrier and 70% of its joins change. Summing per-file estimates therefore over-counts by about 0.74 * files tokens on Claude, which is under 0.02% at typical file sizes and worth attention only across thousands of small files.

The error distribution up close

Every figure in this subsection is the default profile, out of fold on the calibration corpus, under the grouped cross-validation above. The aggregation convention is one out-of-fold prediction per row at seed 1, with the metrics computed on the complete 808-row vector.

MAPE / median / p90 / share within +/-10% / aggregate error:

n Claude 4.7+ Claude pre-4.7 o200k cl100k Gemini
everything 808 5.26 / 3.8 / 11.5 / 87% / -0.1 5.29 / 3.7 / 11.6 / 87% / -0.1 6.31 / 4.9 / 13.9 / 82% / -0.3 5.29 / 3.7 / 12.2 / 86% / -0.4 6.04 / 4.4 / 13.7 / 82% / -0.1

A second aggregation convention is in circulation, the mean over 25 fold-and-seed pairs of each fold's MAPE, and it reads 5.25 / 5.30 / 6.37 / 5.41 / 6.05 on the same rows. The two are never mixed inside one cell. A third figure appears wherever novocab has to be scored the way an unfitted comparand is scored, fitted on all 808 rows and evaluated on all 808: that reads 5.04 / 5.02 / 6.02 / 5.21 / 5.73 and is labelled in fold at every site where it is used.

Signed error is (estimate - true) / true * 100, so a negative percentile is an under-count, and the percentiles are nearest-rank on the signed values instead of on their absolute value.

generation p10 p50 p90 p95 worst under worst over within 5% within 10% within 20%
Claude 4.7+ -8.7 +0.0 +7.3 +9.5 -55.4 +33.3 61% 87% 98%
Claude pre-4.7 -9.1 +0.2 +7.3 +10.0 -49.1 +37.5 62% 87% 97%
o200k -9.7 +0.1 +9.4 +12.7 -32.8 +57.1 51% 82% 97%
cl100k -8.9 +0.0 +8.0 +10.7 -38.3 +57.1 62% 86% 98%
Gemini -8.3 +0.9 +10.0 +13.6 -30.9 +44.4 57% 82% 96%

The median chunk is right to within a percent and the distribution is close to symmetric, which is why the aggregate stays under half a percent: the tails cancel when you sum. That makes the formula useful for sizing a directory and unsuitable for gating one short string against a budget. One chunk in twenty is off by more than 10%, and the worst chunk of 808 is off by half, so budget against the p90. The worst 5% are concentrated instead of spread evenly: on Claude 4.7+ 58% of them are in the 40 to 250 character band, and on o200k and Gemini the tail is dominated by classical Chinese, 15 of 40 chunks each.

By text kind, MAPE / signed aggregate bias:

text n Claude 4.7+ Claude pre-4.7 o200k cl100k Gemini
source code (TS) 56 4.8 / +3.1 4.0 / +1.9 4.4 / +0.7 3.6 / +0.6 4.7 / +1.7
source code (Rust) 56 5.4 / -1.1 4.6 / -0.6 5.6 / +3.1 5.7 / +2.3 3.0 / -0.3
source code (JS) 56 4.8 / -1.9 4.8 / -1.8 5.2 / -1.7 6.2 / -0.2 4.6 / -1.7
English technical markdown 56 5.9 / +3.7 7.2 / +6.1 6.5 / +2.1 6.4 / +0.1 6.2 / +4.3
dense JSON 56 5.9 / -1.2 6.7 / -1.8 7.2 / -1.3 6.7 / -2.1 5.6 / -0.9
mixed CN/EN notes 56 5.5 / -3.2 6.0 / -3.6 5.4 / -3.5 6.0 / -3.0 4.9 / -3.1
Chinese prose 56 6.6 / +2.7 6.4 / +2.2 6.8 / +5.2 7.4 / +7.2 6.6 / +4.0
English 32 6.2 / -1.8 6.8 / -2.9 5.0 / +3.0 5.5 / +2.7 3.4 / +0.3
Chinese 32 4.1 / +0.9 4.0 / +1.0 6.6 / -3.4 7.4 / -4.4 5.2 / -0.3
Japanese 32 3.9 / +0.5 4.1 / +0.7 3.3 / -0.2 3.9 / +0.2 4.6 / -0.5
Korean 32 3.0 / +0.1 3.2 / +0.0 6.2 / -0.7 4.4 / -0.3 5.6 / -0.6
Russian 32 9.5 / -0.0 9.3 / +0.1 8.6 / +0.0 5.5 / -0.3 8.6 / +0.3
Arabic 32 3.3 / -0.2 3.3 / -0.2 6.6 / -0.7 2.8 / -0.3 6.1 / -0.2
Greek 32 3.3 / +0.2 3.4 / +0.4 6.1 / +0.7 1.2 / +0.1 5.2 / +0.8
Hebrew 32 3.5 / +0.2 3.4 / +0.1 4.2 / -0.2 2.4 / -0.2 3.6 / -0.2
Hindi 32 4.1 / +0.3 4.1 / +0.2 6.6 / +1.9 2.6 / -0.1 6.9 / +0.6
Thai 32 4.0 / -0.5 4.1 / -0.6 7.3 / -0.8 3.6 / +0.2 11.1 / +0.3
Vietnamese 32 7.1 / -0.6 6.9 / -0.7 6.7 / -1.2 4.7 / -0.6 4.6 / -1.4
Japanese source files 14 3.3 / +1.3 2.5 / +0.3 6.1 / +2.3 4.4 / +0.3 10.7 / +8.1
classical Chinese 17 13.7 / -12.5 13.0 / -12.0 20.0 / -19.3 14.0 / -13.6 23.7 / -23.6

The thinnest rows above were re-checked at two to three times the sample, same coefficients, frozen, on validation-corpus chunks from sources the fit never saw. Claude 4.7+, old rows / new rows / combined: Japanese 3.8 / 5.3 / 4.7 at n=110, Korean 2.3 / 3.2 / 2.8 at n=111, Vietnamese 5.7 / 4.4 / 4.8 at n=96, Arabic 3.4 / 2.7 / 3.0 and Hebrew 3.5 / 2.2 / 2.9 at n=64 each, classical Chinese 13.1 / 11.8 / 12.3 at n=49. Every one holds. Russian is the exception and keeps its name: at n=128 it reads 11.0 against the 9.5 above, because fresh Russian Wikipedia scores 5.2 while 32 chunks of Russian literary prose score 21.8 and pull the combined figure up. Literary Russian costs 1.97 characters per token against encyclopedic Russian's 2.64, and Cyrillic is priced per letter with no run term of its own, so it has the least structure of any script to absorb a change of register.

Accuracy of a single chunk depends strongly on that chunk's length, monotonically:

chunk length Claude 4.7+ Claude pre-4.7 o200k cl100k Gemini
4,000 to 16,000 chars 3.8% 3.8% 4.6% 3.5% 4.2%
1,000 to 4,000 4.3% 4.2% 5.2% 4.2% 4.9%
250 to 1,000 5.2% 5.1% 6.4% 5.3% 6.1%
40 to 250 7.6% 7.9% 8.9% 8.0% 8.8%
25 to 40 (below the fitted floor) 15.7% * 17.1% * 12.5% 14.2% 8.4%
15 to 25 (below the fitted floor) 21.3% * 19.8% * 15.8% 12.4% 14.6%
5 to 15 (below the fitted floor) 29.0% * 28.9% * 18.0% 17.5% 18.9%

The two starred columns are inherited 16-bucket figures and are not re-measured. The 150 sub-40-character slices reconstruct deterministically, but their Claude ground truth was computed in-process by an earlier round and never written to disk. On the three generations whose ground truth is local and exact, the current shape improves the below-floor bands by 2.4 to 10.2 points against those same 16-bucket figures, so the starred numbers are almost certainly pessimistic.

Refitting on your own text

The default coefficients were fitted on five private repositories plus Wikipedia. If your text is unlike that, refit; chat transcripts, OCR output, minified bundles and base64 are all outside the calibrated register. Collect chunks of your real text, get the true count for each with the instruments above, isolate each string as count(marker + text) - count(marker) with a fixed short marker, and solve for the coefficients by non-negative least squares.

Fit on N chunks, score the rest, median of 40 draws:

N Claude 4.7+ p90 Gemini p90
25 22.8% 476% 27.4% 597%
40 12.8% 22.5% 15.6% 89.8%
60 9.2% 14.1% 10.4% 23.8%
100 6.7% 7.9% 7.6% 10.7%
200 6.0% 6.3% 6.9% 7.7%
400 5.5% 5.9% 6.3% 6.7%

Use at least 100 chunks. 200 gets you within half a point of the floor. Never fewer than 60. This guidance has survived three shape changes and gets more necessary each time: the N=25 tail now runs between 389% and 597%, so one refit in ten at that sample size is catastrophic on some generation. Twenty-five samples against twenty-two buckets is three degrees of freedom. If you are refitting on European text specifically, take the capitalisation counter first and the accent split second, and expect the accent split to cost you on source code.

The procedure above is the whole procedure, and a comparable corpus reaches comparable coefficients. That is measured twice. A fully independent 346-chunk sample at the same composition, sharing no source group with the fit, reproduces the published floats inside their own bootstrap error bars on 20 or 21 of 22 buckets, which is the rate chance alone produces. And at 200 chunks a fresh fit lands within half a point of the floor on both the tightest and the loosest generation. The calibration corpus itself is private, so its composition and scale are given in full above instead of the text; nothing in the fitting recipe depends on the particular rows.

What else was tried and why it lost

Everything here was implemented and scored under the protocol above. The gain column is the best any single generation saw.

Constants, labels and per-generation tables

candidate number that killed it
a single vendor chars-per-token constant chars/4 reads 52.25 on Claude 4.7+ and 28.35 on o200k on the same rows. Both documented rules are stated for English and neither lands within 10% of either Claude generation on English: on content-matched English Wikipedia chars/4 reads 23.51 with a -23.05% bias and chars/3.5 reads 13.02
one constant that serves two vendors Claude 4.7+ spends 3.078 characters per token on English against o200k's 4.910, and 4.910 / 3.078 = 1.60 falls either side of 4, so each vendor's rule is wrong for the other in the opposite direction and by almost the same magnitude
a language label instead of counters it is not a counter, and at zero Wikipedia in the mixture it scores 80.54 on English because those coefficients then have nothing to be fitted on
a per-generation feature set instead of one shared set -0.74 on Claude pre-4.7. Where swapping wins it wins by 0.196 and 0.277, both under the bar, and it costs four counting implementations that all have to stay correct forever
a per-message constant (intercept) -2.13 on o200k, -2.21 on cl100k

More data

candidate number that killed it
refit on 592,284 chunks and 1.24 billion characters held-out English Wikipedia 10.56 to 23.77, source code 7.77 to 14.55, the calibration corpus 5.04 to 10.42. It does close the language gap, taking held-out European from 20.70 to 6.99 and wiki-40 from 19.50 to 9.12
a better mixture weight for that refit ten complete refits from 90% Wikipedia down to none of it trace a smooth curve and no point on it reaches the corner where both halves are good. Neither end is inefficient: the shipped coefficients are within 0.5 points of the best a single vector could do on English and on code, and the refit is within 0.3 of the best on Wikipedia
a larger corpus without a coverage audit Hindi goes to 29.93 and Thai to 27.98 under that refit, because wiki-40 contains neither language and the other bucket was priced on Greek alone
refit on the doubled 2,095-chunk corpus the 511 held-out rows in the calibrated languages get worse by 1.7 to 3.5 points, and the refit breaches the sanity gates on three of five generations with emoji between 0.000 and 1.203, cjk at 1.413 and punctRun at 0.228

Bigger feature sets

candidate number that killed it
25 honest orthographic counters: word buckets split by accent, consonant run and vowel ratio, plus capitalisation, run and length counters recovers 30% of the English error a language label recovers and 24% of the code error, while regressing 0.35 to 0.53 points on the calibration corpus. It closes the frontier by 0.07 points of 4.44
word buckets crossed with accent presence, 4 extra floats English 23.77 to 20.54, and source code goes the wrong way at -8.9% recovery
the best honest 47-bucket shape under NNLS 19.23 on held-out English. Unconstrained least squares on the identical design reaches 16.33 with 13 of its 47 floats negative, so the constraint costs 19 points of achievable recovery here. On the shipped 22 the same experiment moves nothing
a 23rd bucket for letters in scripts no class covers +1.25 on cl100k, and it clears the bar only on a corpus containing an uncovered script
latacc split at U+1E00, Vietnamese apart from European accents +0.76 at 2,095 rows, and at 592,284 rows carrying twenty accent-bearing languages it is worth +0.10 at most
the emoji pool split into its six atoms +0.00. Per code point every emoji class sits at 2.5 to 4.5
Arabic and Hebrew pooled as one abjad bucket +0.15. The value is in separating them, not in grouping them
Greek split out as well, or all five scripts separate +0.11. Once Arabic and Hebrew are apart the remaining three price alike
a CJK run count, one unit per unbroken CJK run +0.17 on Claude 4.7+ and -0.26 on o200k, -0.50 on cl100k. It takes classical Chinese from 16.2% to 7.6% on Claude 4.7+, so if you only ever score Claude it is the single highest-value change you can make to this formula
whitespace split into free seams and paid whitespace +0.09 on Gemini, under the bar even there
a capitalised-word marker +0.09 here, +0.50 on the 592,284-row corpus where German is present. The mechanism is German noun capitalisation, which both costs a token and evicts the word from the cased vocabulary, and it carries 19% of the English-to-German gap on Claude 4.7+
accented Latin priced per word span instead of per letter +0.00. Marginally better on cl100k, worse on both Claude columns
a "first accent per 15 ASCII letters" non-linear clamp -0.04. NNLS prefers the corpus-weighted average to the conditional
a per-digit-run constant on top of the 3-digit groups +0.01
a sentence-boundary count +0.00. Its mass is already inside the punctuation bucket
total UTF-8 byte length +0.32 on Claude 4.7+ only, and it duplicates the script buckets
other-script letters priced by UTF-8 bytes -2.66. The five scripts in that bucket split 2-byte against 3-byte, so the rewrite re-weights the coefficient the wrong way
short ALL-CAPS acronyms priced apart; a per-character term beyond 5 letters never selected

The marker experiments

candidate number that killed it
ten random letter pairs, mass-matched to the winning set English 23.77 / 23.77 / 23.79 against the winner's 8.33, coefficients pinned at zero. Under the admission rule the three controls score +0.000, +0.000 and -0.001 against the selected set's +1.681
the six strongest English-marking pairs, s$ e$ ^t th d$ ^c exactly 23.77 in both additive forms. A counter that fires on the cheap side can only add cost under non-negativity
a float per marker instead of one pooled float English 9.48 against the pooled float's 8.33, and it fails the admission rule at -0.202 on the calibration corpus. This is the learned n-gram table in its smallest form, and it is worse than the single float
an unguarded pooled marker on a full freeze of the default table the Vietnamese stratum goes 5.66 to 18.82 against a 2.10 bar, Japanese source files +2.66 against 0.47, Chinese file prose +2.71 against 1.96, the 40-to-250 band 7.22 to 8.34 and held-out source code 7.77 to 10.66
the gated marker on a full freeze, refitting nothing else it holds the home block, 0 / 0 / 0 / 1 / 0 of the 25 watched strata over their bars. It loses every held-out cut to the frozen-six refit that ships: European 8.60 against 8.10, source code 8.66 against 7.75, 39-language Wikipedia 10.76 against 10.48, the validation set 7.19 against 6.72
an unguarded four-pattern set as the shipped profile at the profile's own rung it puts 1 / 1 / 8 / 8 / 11 of the 25 watched home strata over their noise bars across the five generations, against 1 / 0 / 2 / 0 / 4 for the gated six. Under a full freeze of the whole default table it is 6 / 8 / 7 / 11 / 10 against 0 / 0 / 0 / 1 / 0
re-running the selection with 39-language Wikipedia as the objective instead of the eight European targets wiki-40 closes to 9.95 and held-out European opens to 9.38. No set is weakly better on both published numbers, so the trade is a property of the language roster and not of the guards

Where it got worse

Russian gets consistently worse with punctRun, by 0.83 to 1.32 points on every generation: 8.6 to 9.5 on Claude 4.7+, 7.2 to 8.6 on o200k, 4.7 to 5.5 on cl100k. Cyrillic is priced per letter, has no run term of its own, and encyclopedic Russian is the corpus's least punctuation-dense register, so it absorbs a little more of the shared fit. Vietnamese, Chinese and classical Chinese each moved against by under 0.75 in the same change. No generation-level regression exists there: all five improved overall by 0.38 to 1.15 points, which is the unit the admission rule is written in.

Three strata on the Claude 4.7+ column are worse under the shipped 22-bucket shape than under a 16-bucket one: Chinese at 4.1 against 3.4, Russian at 9.5 against 8.9, and Vietnamese at 7.1 against 7.0, while seventeen improved, several by more than five points. Widening the shared feature set from four tokenizer columns to five costs exactly two cells: Chinese on Claude 4.7+ from 3.39 to 3.72, and TypeScript on o200k from 5.93 to 6.35.

Two counters were found sitting unused, and the finding is about process. shift counts a capitalised not-all-caps Latin word. The feature code had computed it from the start and nothing ever scored it. On the 592,284-row corpus it is worth +0.500 and +0.305 on the two Claude columns, and it is near zero on the 808-row corpus, which is why it does not ship. The loader discarded 54 of the 87 atoms the measurement had already produced, and nothing in the harness printed what was being thrown away.

Limits

Vocabulary knowledge is invisible to buckets, and it is the largest single fact about this formula. Within English, at five letters a common word costs 1.20 tokens and a rare one 2.55, a factor of 2.1 that depends on nothing except whether the tokenizer has seen the word. That difference is exactly the content of the 29 MB table this formula is refusing to carry, and a length-binned coefficient can only choose where to sit between the two. Across Latin-script languages the same wall is the whole story. The blind-subset evidence is in the vocabulary wall. Restrict both populations to lowercase pure-ASCII words with no consonant run of four or more and a vowel ratio above 0.35, so that no code-point counter can tell them apart. The German, Dutch, Polish and Finnish gaps to English then grow from a -29% to -37% band up to -38% to -47%. The wall appears symbol by symbol as well: U+2192 RIGHTWARDS ARROW costs 1 Claude token and U+2191 UPWARDS ARROW costs 4; U+00B7 MIDDLE DOT costs 1 and U+25E6 WHITE BULLET costs 4. Some symbols got a dedicated merge and their neighbours did not, and no code-point range separates them.

The default profile's language coverage is narrow, and the multilingual profile's is a different narrow. The default reads 10.4% to 42.0% across the 24 Latin-script languages of a held-out 39-language Wikipedia set that its calibration corpus does not contain, with a one-sided bias near -20%. The multilingual profile takes 29 of those 39 languages below the default. It does not serve every Latin-script language. On that held-out set it is worse than the default on Hungarian by 5.4 points, 10.41 to 15.81.

Which languages a marker set serves is itself a choice inside the admissible region. Measured against an unguarded set that was scored and not shipped, the six gated patterns improve German (10.28 to 8.87), Italian (9.19 to 8.01), English (8.72 to 7.66), Vietnamese (8.64 to 7.68) and Spanish (7.60 to 6.71). They cost Dutch (8.58 to 12.72), Malay (9.12 to 14.27), Indonesian (8.43 to 12.02), Norwegian (7.71 to 10.61), Danish (7.84 to 10.13), French (7.87 to 10.05) and Welsh (29.30 to 33.98). Neither set serves all of them.

The same ladder chart, measured instead on the combined 2,095-chunk corpus that includes the eight uncalibrated European languages. Every rung sits lower than on the calibration corpus, and novocab moves from 5.04 to 9.21 percent

That chart is what the coverage gap does to the benchmark. Every rung was re-run over the combined 2,095 chunks under the same protocol with zero API calls, and the packages were executed this time instead of quoted. novocab's rows are the frozen shipped default coefficients evaluated in fold, so the 808-row column reads 5.04 instead of the out-of-fold 5.26. The chart carries every rung; the table below excerpts the four that carry the argument.

rung Claude 4.7+, 808 Claude 4.7+, 2,095 o200k, 808 o200k, 2,095
chars/4 52.25 49.86 28.35 25.21
OpenClaw 38.94 39.47 16.76 16.22
bpe-lite 0.5.1 29.08 26.93 0.00 0.00
novocab, default, in fold 5.04 9.21 6.02 9.36

The direction of that change is the opposite of what it looks like. Adding 512 European chunks makes chars/4 better, from 52.03 on the non-European rows to 43.17 on the European ones, because European languages run 2.06 to 2.47 characters per token, far closer to 4 than the calibrated corpus's centre of gravity: classical Chinese runs 0.78, Chinese 0.92, Korean 1.05, Japanese 1.09, Thai 1.16, Hebrew 1.46, Hindi 1.46, Greek 1.53, Arabic 1.55. novocab is the only rung in the table that degrades on European text, from 5.69 to 20.09, and every one of the 45 rung-by-target gaps narrowed because novocab moved. On the 1,583 non-European chunks the gap to chars/4 is intact at 52.03 against 5.69.

The marker set is conditional on a distribution. It was selected on Wikipedia in 39 languages under a cap measured against a 12-language calibration corpus, and both of those rosters are choices. Between 1,435 and 2,073 admissible sets exist at every size from two to eight patterns, so the shipped six are one point in a large region and not a unique answer. Re-running the selection with the 39-language mixture as the objective instead of the eight European targets gets held-out wiki-40 to 9.95 and opens held-out European to 9.38, and no set is weakly better on both. The gates are also normalisation-dependent in one place: #r reads an accented code point inside a word run, and a word run ends at a combining mark, so an NFD corpus would decompose accented letters into an ASCII base plus a standalone mark and #r would stop firing. Every number here is on an NFC corpus.

A script no bucket covers is priced as a Latin word, silently. The script classifier enumerates Han, kana, Hangul, Cyrillic, Greek, Arabic, Hebrew, Thai and Devanagari. Every other alphabetic code point falls through to the Latin path, so Armenian, Georgian, Bengali, Tamil and the rest are counted as English words of the same length. Measured on 2,351 Armenian Wikipedia chunks, the error is 50.9 / 68.3 / 44.4 / 86.0 / 62.6 MAPE across the five generations, three to eight times the error on any covered script. Nothing warns you: the code-point partition audit still passes, because a misclassification is not a leak.

Strings under 40 characters degrade. Roughly 8% at 40 to 250 characters, 8% to 17% at 25 to 40, 12% to 21% at 15 to 25, and 17% to 29% at 5 to 15, with the bias turning negative. Below 15 characters this is not fit for per-string decisions. A worked failure: a 25-character string made of three emoji and three short words costs 18 Claude tokens and this formula says 23, because a string dominated by emoji is entirely at the mercy of an averaged emoji coefficient.

The emoji coefficient is the least trustworthy float shipped. The bucket pools six atoms, four of them zero on all 808 corpus chunks, so the fitted number is carried entirely by ordinary symbols (116 chunks) and astral emoji (11 chunks) and describes ordinary symbols. Its bootstrap 95% interval runs from 2.07 to 14.05 against a published 5.67236. Per-class costs for ZWJ sequences, skin tones and regional indicators are known from direct frame probes and are consistent with the pooled figure per code point, but nothing in the fit measures them, so any claim about a ZWJ family sequence from this coefficient is extrapolation.

Classical Chinese is the worst stratum on all five generations, at 13.7% on Claude and 23.7% on Gemini, biased 12% to 24% low; on 49 rows it reads 12.3. Outside it the weakest cells are Japanese source files on Gemini at 10.7% and Thai on Gemini at 11.1%, and on the Claude columns Russian at 9.5%, or 11.0 across two registers at n=128.

Several buckets rest on thin evidence. In the calibration corpus latacc rests on 32 Vietnamese chunks and no other accented-Latin language, so the shipped float is a Vietnamese float. The validation corpus answers that with 110,002 accented-Latin characters across nine languages, and the answer is mixed: the bucket still earns its place with Vietnamese deleted, but a direct probe measures the mechanical cost of an accent on Claude 4.7+ at close to zero against a shipped coefficient of 1.09752, and the bootstrap agrees from a third direction. The Arabic and Hebrew split rests on 64 rows of one register here; it was re-checked on 64 fresh rows and held, and confirmed again at 592,284 rows. Vocalised Arabic and pointed Hebrew were never re-probed after the split.

The published floats are worth about one decimal place each. None is pinned at the five places it is printed to, seven are not pinned even at the units digit, and the 1/sqrt(N) law says no achievable corpus changes that. Copy them exactly for reproducibility; do not treat a fourth decimal as information, do not compare two coefficients that differ in the third, and do not carry a difference of 0.001 into an argument.

When to stop trusting these numbers

  • A Claude model id appears that is not in the ids listed under "which column". Do not assume a new id joins the newer family. The break between sonnet-4-5 and opus-4-7 moved English by 45%, and nothing in the version number said so.
  • A Gemini model returns a count the gemma3 SentencePiece file does not predict. The check is exact and takes one call: countTokens(text) == len(gemma3.encode(text)) + 1. That held on 418 of 418 strings, and a separate 123-probe battery returned byte-identical counts on all 21 generative model ids. Google's own google-genai SDK routes Gemini 3.1 and 3.5 to a different tokenizer loaded from HuggingFace, and measured against the live API that routing is wrong today on all 38 corpus chunks where the two artefacts disagree. The mechanism is a lossy conversion and not a new vocabulary: both artefacts hold 262,144 pieces with identical ids, and the HTML-tag pieces that differ are declared USER_DEFINED in the SentencePiece file, a flag the conversion did not carry over. Trust the probe, not the SDK's table.
  • OpenAI ships an encoding that is not o200k_base. Check tiktoken's model map instead of a release announcement. o200k_harmony looked like a new encoding and changes no merge.
  • Your text drifts out of the calibrated register. The corpora are code, technical markdown, JSON and encyclopedic prose. Dialogue, OCR noise, minified code and base64 are unmeasured, and register alone is worth 10 to 20 points.
  • Your text is in a Latin-script language neither profile was fitted on, or in a script the classifier does not enumerate. See the limits above.
  • The number has to be right instead of close. This is an estimator. If a billing decision or a hard context limit hangs on it, count properly.
  • The coefficients go stale when vendors change tokenizers. The default was fitted 2026-08-29, the multilingual table 2026-08-30.

Closing

Twenty-two counters get within about 5% of four tokenizers and 6% of the fifth, on the register they were fitted on. A twenty-third counter buys nine more languages, at the cost stated in the profile table. Every number above names the corpus it came from and the instrument that produced it. The measurements that went the wrong way sit in the same tables as the ones that went the right way.

References

Exact counters, when you need a count and not an estimate:

  • sanderland/ctok. Reconstructs Claude token counts offline by least-pieces tiling over a measured vocabulary. It reproduced both Claude ground-truth columns on all 808 chunks with zero tokens of error out of 2.37 M, so on this corpus it is not an estimator; it is the tokenizer. It also served as the oracle for every later round here, which removes the API from the loop entirely. Two conditions: pin the family by probe instead of by version string, and match the isolation convention.
  • openai/tiktoken and gpt-tokenizer for the OpenAI encodings.
  • google/sentencepiece over the gemma3 model file published in google/gemma_pytorch, for Gemini and Gemma.

Other estimators, all read as primary sources and measured above:

  • tokenx. 2 kB, segment-and-rule-table, calibrated against o200k_base.
  • bpe-lite and ai-tokenizer. Real BPE with shipped vocabularies per provider, 494 kB to 30 MB.
  • infinigence/tokenestimate. Ten-bucket linear regression over character classes, and the source of the one non-linear idea in the field.
  • petasbytes/token-approx. Four features plus a fitting methodology. Small, and its shape is not bad.
  • grohan/ctoc. Counts Claude tokens across a directory the way cloc counts lines, from a greedy longest-match vocabulary.

Background:

  • Schmidt et al., Tokenization Is More Than Compression, arXiv:2402.18376. Introduces PathPiece, which segments text into the minimum number of tokens for a given vocabulary, and reports that fewer tokens do not lead to better downstream performance.
  • MinGram: A Minimalist Unigram Tokenizer with High Compression and Competitive Morphological Alignment, arXiv:2606.27019. Minimum-token-path segmentation with a unigram score as tiebreak.

Least-pieces tiling of that kind is what ctok implements, which is why an exact Claude reconstruction is possible at all without the merge table.

License

MIT. See LICENSE.

About

High accuracy token counting without the vocabulary.

Topics

Resources

Stars

4 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages