Skip to content

Instantly share code, notes, and snippets.

@jlevy
Last active July 13, 2026 01:17
Show Gist options
  • Select an option

  • Save jlevy/0d6d87885f6d85f31440e58b8cfce663 to your computer and use it in GitHub Desktop.

Select an option

Save jlevy/0d6d87885f6d85f31440e58b8cfce663 to your computer and use it in GitHub Desktop.
Logical word count: a cheap, tokenizer-free measure of text length that tracks LLM token counts across languages, code, and mixed content

Logical Word Count

Logical word count is a cheap, deterministic, tokenizer-free measure of text length that stays stable across natural languages, code, math, and mixed content, and stays roughly proportional to LLM token counts — while remaining equal to ordinary word count for plain English prose.

It is defined in two rules:

  1. Wide (East Asian) characters count as ½ logical word each. This covers Chinese characters, Japanese kana, Korean Hangul, and CJK punctuation.
  2. Everything else is split on whitespace, and the word count is clamped so the average word length stays between 3 and 6 characters. Text with very long “words” (compounds, identifiers, URLs, minified code) is counted as chars / 6; text with very short words is counted as chars / 3; typical prose passes through unchanged.

One number, two uses:

  • A word count that survives any format. For ordinary English prose it equals the literal word count. On text where literal word counts break — code, mathematical notation, CJK, URLs, minified or structured data — it keeps measuring content volume instead of skewing by an order of magnitude, because average characters per logical word is confined to a narrow range by construction.
  • A token estimate. Multiply by 1.6 to approximate o200k-family (GPT-5.x) token counts within roughly ±25% on any of those formats, a bound no fixed multiplier achieves on literal word counts or raw character counts (see Token Estimation).

A reference implementation is ~15 lines with no dependencies (see Reference Implementation). Empirically, tokens per logical word stays within a ~2× band for every tokenizer tested across ten languages plus source code and machine formats, versus a 24× spread for literal word count on the same corpus (see Validation).

Motivation

Word count is the standard unit for measuring text length, but a whitespace-delimited word is a meaningful unit only for prose in spaced alphabetic scripts. The moment text departs from that format, literal word counts skew — sometimes by an order of magnitude, and in different directions for different formats.

Symbolic text: code, math, and data

Symbolic text breaks word counts in two opposite directions at once:

Symbols are rarely space-separated. The expression a+b*clamp(x,lo,hi) is one whitespace-delimited “word” containing eight semantic tokens, and the LaTeX fragment \frac{-b\pm\sqrt{b^2-4ac}}{2a} is one “word” carrying an entire quadratic formula. Minified JavaScript averages 44 characters per whitespace-delimited “word” in our test corpus; CSV rows and JSON average 10–14.

Identifiers vary wildly in length. x and calculateAverageResponseTime are both one word, but the latter contains five semantic units.

Because the two failure modes skew in opposite directions, no fixed correction factor repairs word counts for symbolic text. Logical word count bounds both at the source: the clamp pins average characters per logical word to 3–6, so an expression, a formula, and a paragraph of prose with the same number of characters receive counts of the same order.

Unspaced scripts: CJK

CJK text has few or no word-delimiting spaces; Unicode’s own segmentation standard notes that word boundaries in Chinese, Japanese, or Thai require dictionary lookup.1 A 500-character Chinese paragraph counts as a handful of whitespace-delimited “words” despite carrying the information of roughly 250 English words. In our test corpus, an 8,000-character Chinese Wikipedia extract contains just 165 whitespace-delimited tokens.

Long words: compounding and agglutinative languages

German compounds like Donaudampfschifffahrtsgesellschaft2 pack five lexical units into one “word”; agglutinative languages like Finnish average 8+ characters per whitespace-delimited word versus ~5.5 for English. Identical content, materially different word counts.

Mixed content

Documents that combine prose, code blocks, tables, links, and CJK text have no single word-counting strategy that works across all sections. A README with badges, URLs, and code samples measures 11.7 characters per “word” — double typical prose.

Why not just use token counts?

Token counts solve the normalization problem by construction, but they are not stable across model providers, they require a tokenizer vocabulary, and they are orders of magnitude more expensive to compute than a character scan.

Token counts vary across providers — and across model generations within a provider. The same 8,000-character samples measured against the latest production tokenizers (July 2026):3

Sample GPT-5.6 Claude Opus 4.8 / Fable 5 DeepSeek V4 max deviation
English Wikipedia 1,657 2,471 1,664 49%
German Wikipedia 1,816 3,695 2,068 103%
Chinese Wikipedia 5,351 7,348 4,333 70%
Japanese Wikipedia 5,097 6,403 4,343 47%
Korean Wikipedia 4,529 7,916 5,026 75%
Thai Wikipedia 3,520 7,107 3,238 119%
Python code 1,853 2,810 1,893 52%
TypeScript code 1,907 3,518 2,004 84%

Even for English the tokenizers diverge by 49%, and for German by 103%. Nor are counts stable over time: Anthropic’s newest tokenizer (shared by Claude Opus 4.8, Fable 5, and Sonnet 5) spends ~37% more tokens on the same English text than the Opus 4.6 tokenizer did, and ~2× on Thai. Academic work documents far larger gaps across languages: the same content can take up to 15× more tokens in some languages than in English.4 A “token count” is therefore not a property of a text — it is a property of a (text, model) pair. Any model-independent measure has this cross-tokenizer variance as a floor on its achievable accuracy, and that is fine: the goal is a stable measure of content volume that tokens track within a predictable band, not a replacement for a tokenizer when exact counts matter.

Token counts also require a vocabulary. Computing them means shipping or calling the target model’s tokenizer — a dependency that may not exist where length is measured (a database check constraint, a CLI, an editor plugin, a non-Python runtime) and that changes when models change.

Definition

Given a text string:

  1. Wide characters. Count the characters whose Unicode East Asian Width is Wide or Fullwidth5 — Han ideographs, Hiragana, Katakana, Hangul syllables, CJK punctuation, and fullwidth forms. Call this wide. Each contributes ½ logical word. For splitting purposes, treat each wide character as if it were surrounded by spaces.
  2. Remaining text. Split what remains on whitespace. Let words be the number of resulting tokens and chars the total count of their characters (Unicode code points). Clamp the word count so that average word length stays in [L, U] = [3, 6]:
clamped = clamp(words, chars / 6, chars / 3)
        = max(chars / 6, min(chars / 3, words))

logical_words = round(wide / 2 + clamped)

Empty or whitespace-only text has 0 logical words.

The clamp activates only when average characters per word falls outside [3, 6]. Most English and European prose runs 4.5–5.5 characters per whitespace-delimited word (punctuation attached), so for ordinary prose logical word count equals literal word count — the measure is backward-compatible where word count already works. Long-word text (German and Finnish compounds, identifiers, URLs, minified code, CSV rows) is counted as chars / 6; degenerate short-word text (e.g. tables of single digits) is capped at chars / 3.

In practice the upper bound does nearly all the work; the lower bound is a safety rail that real content rarely hits.

Notes on the wide-character rule

  • Primary definition: East_Asian_Width ∈ {W, F} (available in Python’s unicodedata, ICU, and most Unicode libraries). Where the property is unavailable, a range-based approximation — the Han and Hangul scripts plus the CJK punctuation-through-Katakana blocks (U+3000–30FF), Katakana phonetic extensions (U+31F0–31FF), and fullwidth forms (U+FF00–FFEF) — produces identical counts on all our CJK test samples (kana must be matched by block, not script property: ー U+30FC and ・ U+30FB are Script=Common but Wide). Rare code points (halfwidth Katakana, some symbols) may still differ between the two definitions; the effect on real text is well under 1%.
  • Korean: Hangul is space-delimited, but each syllable block encodes 2–3 jamo and tokenizers price Korean like Chinese and Japanese (~1.4 chars/token). Weighting Hangul syllables at ½ overrides the space-based count and empirically aligns Korean with every other language (K = 1.57 vs. 2.54 without the rule; see Validation). The localization industry’s GMX-V standard likewise groups Korean with the logographic scripts.6
  • Emoji are East_Asian_Width Wide, so they count ½ each. Tokenizers typically spend 1–3 tokens per emoji; at realistic emoji densities the error is negligible.

Why Half a Word per CJK Character

Independent conventions, developed for different purposes, converge on roughly two CJK characters per English-word-equivalent:

Convention Effective rate Purpose
Translation-industry rule of thumb ~1,000 Japanese chars ≈ 400–500 English words; 1,000 Chinese chars ≈ 650–750 English words7 Quoting/billing
Japanese 原稿用紙 (genkō yōshi) manuscript page 400 chars ≈ 200–250 English words8 Publishing
Twitter/X character limit CJK weighted 2× (280-unit limit = 140 CJK chars)9 Length limiting
Medium and Hugo reading speed 500 CJK chars/min vs. ~212–265 words/min ≈ 2–2.4 chars per word10 Reading time
ICU dictionary segmentation Chinese words average ~1.5–2 chars11 Linguistic segmentation
Modern LLM tokenizers (measured) one CJK char ≈ 0.7 tokens ≈ half the ~1.35 tokens of an English word Token accounting

The tokenizer alignment is the one this spec is calibrated to: weighting CJK characters at ½ word puts Chinese, Japanese, and Korean at 1.54–1.57 tokens per logical word under o200k — statistically indistinguishable from French (1.50) and German (1.57).

GMX-V, the localization industry’s published volume standard, uses somewhat higher divisors for source-word counts (Chinese ÷ 2.8, Japanese ÷ 3.0, Korean ÷ 3.3)6 — it estimates the number of words a native segmentation would find, whereas logical word count (like the billing ratios above) targets volume equivalence. Simple word-counting tools mostly land on 1× instead: Microsoft Word and memoQ count each CJK character as one word,12 and Unicode’s default word segmentation breaks between every pair of ideographs.1 Half a word per character sits deliberately between “one word per character” (too high: overweights CJK ~2× in any cross-language comparison) and GMX-V’s ~⅓ (too low for token tracking).

Why Clamp to [3, 6]

BPE tokenizers merge frequent byte sequences until each token carries roughly one “meaning-sized” chunk.13 Measured on large samples, modern large-vocabulary tokenizers (o200k, cl100k, Llama 3, Gemma 2) all land near 4.2–4.3 characters per token for English prose and 4.0–4.8 for source code;14 vendor rules of thumb agree (OpenAI: ~4 chars or ¾ of a word per token; Anthropic: ~3.5 chars; Google: ~4 chars).15 (Anthropic’s newest tokenizer is denser — ~2.7 characters per token on our English sample — but uniformly so across content types, which shifts K by a constant factor without widening the band the clamp is designed to hold.)

The bounds bracket that range without a vocabulary table:

  • U = 6 is the point where long-word content (compounds, identifiers, URLs) stops gaining count from packing more characters into each word. chars / 6 tracks the ~4–5 chars/token that tokenizers actually spend on such content, at a stable ratio of ~1.3–1.5 tokens per logical word.
  • L = 3 caps degenerate short-word content. Real prose almost never averages below 3 (even Korean, with dense syllable blocks, averages 3.5); the bound exists so that pathological inputs (single-character tables, heavily spaced tokens) cannot inflate the count more than ~1.5× over prose.
  • Everything between 3 and 6 chars/word passes through, preserving logical_words = literal words for ordinary prose in English, French, Spanish, Russian, Hebrew, and most other spaced languages.

Tightening the bounds (e.g. [3, 5]) narrows the token-tracking band another ~10% but sacrifices exact backward compatibility with English word count; loosening them (e.g. [2, 7]) admits outliers without benefit. [3, 6] with weight ½ is the recommended convention; all three constants are parameters in the reference implementation for applications that want to recalibrate.

Reference Implementation

Python (uses the East_Asian_Width property directly):

import unicodedata

def logical_word_count(text: str, lower: float = 3.0, upper: float = 6.0,
                       wide_weight: float = 0.5) -> int:
    wide = 0
    rest = []
    for c in text:
        if unicodedata.east_asian_width(c) in ("W", "F"):
            wide += 1
            rest.append(" ")
        else:
            rest.append(c)
    words = "".join(rest).split()
    chars = sum(len(w) for w in words)
    clamped = max(chars / upper, min(chars / lower, len(words)))
    return round(wide * wide_weight + clamped)

TypeScript (script-range approximation of East_Asian_Width; identical results on real-world text):

const WIDE =
  /[\p{Script=Han}\p{Script=Hangul}\u3000-\u30FF\u31F0-\u31FF\uFF00-\uFFEF]/u;

function logicalWordCount(
  text: string, lower = 3, upper = 6, wideWeight = 0.5,
): number {
  let wide = 0;
  const rest: string[] = [];
  for (const ch of text) { // iterates code points, not UTF-16 units
    if (WIDE.test(ch)) {
      wide++;
      rest.push(" ");
    } else {
      rest.push(ch);
    }
  }
  const words = rest.join("").split(/\s+/).filter(Boolean);
  const chars = words.reduce((n, w) => n + [...w].length, 0);
  const clamped = Math.max(chars / upper, Math.min(chars / lower, words.length));
  return Math.round(wide * wideWeight + clamped);
}

Both are O(n), allocation-light, and dependency-free.

Validation

Measured with the companion script (validate_lwc.py) on 8,000-character samples: Wikipedia extracts in ten languages (Hebrew and Thai are reported under Limitations), real source files, minified JS, CSV, JSON, and a mixed-content README. Token counts are GPT-5.6 via the OpenAI API (the o200k tokenizer family); K = tokens / logical words.

Sample chars literal words logical words tokens K
English Wikipedia 6,775 1,219 1,219 1,657 1.36
French Wikipedia 6,756 1,222 1,222 1,838 1.50
German Wikipedia 6,921 1,063 1,154 1,816 1.57
Russian Wikipedia 6,998 995 1,166 1,939 1.66
Finnish Wikipedia 7,104 868 1,184 2,309 1.95
Chinese Wikipedia 7,807 165 3,400 5,351 1.57
Japanese Wikipedia 7,642 300 3,300 5,097 1.54
Korean Wikipedia 6,181 1,781 2,888 4,529 1.57
Python code 5,709 862 952 1,853 1.95
TypeScript code 6,800 865 1,133 1,907 1.68
Markdown README 7,314 627 1,219 2,178 1.79
Minified JavaScript 7,825 176 1,304 2,889 2.22
CSV data 7,462 539 1,244 2,786 2.24
JSON 7,037 560 1,173 2,168 1.85

Prose, code, and CJK all land in K = 1.36–1.95 (a 1.43× band). Punctuation-dense machine formats run hotter, up to ~2.2. Compare the alternatives on the identical corpus:

Measure tokens ÷ measure, across samples spread
logical words 1.36 – 2.24 1.65×
literal words 1.36 – 32.4 24×
chars ÷ 4 0.98 – 2.9 3.0×

The same holds for the other tokenizer families. On the identical corpus (including the Hebrew and Thai limitation cases), tokens per logical word spans a 2.0× band for GPT-5.6, 2.1× for DeepSeek V4, and 2.9× for the Claude Opus 4.8 / Fable 5 tokenizer — each centered on its own K — while literal word count spans 19–24× on every one of them.

The remaining variation is largely irreducible: the tokenizers themselves disagree by 37–119% on identical text (see the table in Motivation), so no model-free measure can track any single model much more tightly than models track each other.

Token Estimation

The count itself is the word estimate: a normalized, prose-equivalent word count that needs no per-format correction. A single multiplier turns it into a token estimate for o200k-family tokenizers (GPT-5.x; DeepSeek V4 runs ~10% higher):

estimated_tokens ≈ logical_words × 1.6

This lands within roughly ±25% across prose (any script), code, and markdown. Useful refinements:

  • Clean English prose: use ×1.35 — which is the familiar “1 word ≈ 4⁄3 tokens” vendor rule,15 recovered exactly because logical words equal literal words for such text.
  • Punctuation-dense machine formats (minified code, CSV, logs): use ×2.2, or accept under-estimation.
  • Model-specific correction: apply a per-tokenizer factor — DeepSeek V4 runs ~1.1× o200k, and Anthropic’s newest tokenizer (Claude Opus 4.8 / Fable 5 / Sonnet 5) runs ~1.5–2× o200k depending on content, i.e. tokens ≈ logical_words × 2.75 overall. This correction is roughly constant per model, unlike the correction needed for raw word count, which varies by an order of magnitude with text type.

When exact counts matter (billing, hard context limits), use the model’s tokenizer; logical word count is for the much larger class of uses where a stable, cheap, model-free number is worth ±25%.

Properties

  • Format-stable: one measure covers prose, code, math, markup, and data. Average characters per logical word is confined to a narrow range by construction (3–6 for spaced text, 2 for CJK), so no format skews the count by more than a small factor.
  • Cheap: O(n) single pass, no vocabulary, no dependencies.
  • Deterministic and model-free: same input, same count, forever.
  • Backward-compatible: equals literal word count for ordinary English (and most European-language) prose.
  • Script-aware but language-blind: uses only character properties; no language detection, dictionaries, or segmentation models.
  • Monotonic: appending non-whitespace content never decreases the count.
  • Additive: splitting text at whitespace boundaries and summing the parts’ counts approximates the whole (within rounding), so counts can be computed incrementally or in parallel.
  • Bounded token ratio: every tokenizer tested spends a near-constant number of tokens per logical word — within a ~2–3× band across all content types, versus ~20–24× for literal word count.

Limitations

  • Unspaced non-CJK scripts (Thai, Lao, Khmer, Myanmar) are not covered by the wide-character rule; they fall to the clamp’s chars / 6 floor. That happens to match GMX-V’s convention for Thai (chars ÷ 6),6 but o200k still spends ~2.75 tokens per logical word on Thai (Anthropic’s newest tokenizer, ~5.6) — high fertility for these scripts is a property of the tokenizers, not of the measure. Applications centered on these scripts should add a per-script weight (Thai ≈ ¼–⅓ word/char) by the same method used here for CJK.
  • Non-Latin alphabetic scripts (Hebrew, Greek, Devanagari, …) count correctly as volume — their word counts are true word counts — but tokenizers charge them 1.9–2.3 tokens per logical word versus 1.35 for English (o200k; other tokenizer families show the same skew at their own scale). This is the well-documented tokenizer-fairness gap,4 16 and logical word count deliberately does not bend the volume measure to compensate for it.
  • Machine formats (minified code, CSV, dense JSON) run ~1.4× hotter in tokens than prose. The clamp keeps them bounded (literal word count is off by 24× here), but a single multiplier will under-estimate their token cost.
  • It is not a linguistic word count. For Chinese, ½ word per character approximates dictionary segmentation (Chinese words average 1.5–2 characters), but no claim is made that logical_words matches what a human or a segmenter would count as words in any specific language. It is a normalized volume unit, like a “standard page” — not a lexical analysis.

Prior Art

No existing term or formula was found for this specific construction (a chars-per-word clamp plus a wide-character weight), and the name “logical word count” appears to be unclaimed. ("Standard word" already means 5 keystrokes in typing measurement;17 “weighted word count” means fuzzy-match discounts in CAT tools.) The pieces have long precedents:

  • GMX-V (LISA → ETSI GS LIS 004) — the localization industry’s word/character count standard — is the closest precedent. Version 1.0 (2007) refused to segment unspaced scripts and required character counts; version 2.0 (2012) added normative per-script divisors to convert characters to words: Chinese ÷ 2.8, Japanese ÷ 3.0, Korean ÷ 3.3, Thai ÷ 6, described as “acknowledged best practice within the Localization Industry.”6 Logical word count generalizes the same move — chars ÷ factor — and extends it to code and mixed content via the clamp.
  • CJK character-as-word conventions: Microsoft Word’s “Words” statistic counts each East Asian character as a word (with a separate Asian-characters statistic); memoQ computes source words as Asian characters plus non-Asian words; WordPress locale packs switch Japanese and Chinese to character counting outright.12
  • Weighted length limits: Twitter/X’s 280-unit limit weights code points beyond a small Latin-ish set (roughly through U+10FF plus general punctuation) at 2, so CJK posts max out at 140 characters — a widely deployed precedent for weighting wide characters at 2× Latin.9
  • Reading-time estimators: Medium uses ~265 words/min for spaced languages and 500 characters/min for CJK; Hugo and the reading-time npm package (used by Gatsby, Ghost analogues) count CJK per-character against a ~2.4× higher rate.10
  • Character-based billing units: Germany’s court-interpreting law (§ 11 JVEG) prices translation per 55 keystrokes ("je angefangene 55 Anschläge"); publishing standard pages (Normseite: 1,500–1,800 chars) and typing’s 5-character “standard word” all define length by characters when word counts are unreliable or unfair.18 17 Amazon’s KENPC normalizes e-book length to a synthetic page unit for paying authors.19
  • Character length as a text statistic: the Automated Readability Index (1967) used chars/word as a machine-friendly complexity proxy precisely because characters were cheap to count.20
  • Tokenizer research: subword fertility (tokens per word) is the standard metric for tokenizer efficiency;21 the unfairness of token counts across languages is documented up to 15×.4 16 BPE itself13 solves normalization by construction, at the cost of a vocabulary and model dependence.
  • Tokenizer-free token estimators (the tokenx npm package; LangChain’s count_tokens_approximately; Semantic Kernel’s chars ÷ 4) estimate tokens for a specific model family from character ratios.22 Logical word count differs in intent: it is a stable text measure first — human-legible, word-count compatible — that happens to track tokens within a known band, rather than a model-specific predictor.

Reproducing the Numbers

All measurements in this document come from the companion script validate_lwc.py (published alongside this document), which fetches the public samples, computes literal words, logical words, and exact token counts via provider APIs, and prints the comparison tables:

uv run validate_lwc.py                  # o200k locally; other providers with API keys
uv run validate_lwc.py --lower 3 --upper 6 --wide-weight 0.5
uv run validate_lwc.py --list-samples

With no API keys, OpenAI counts fall back to local tiktoken (o200k_base — measured identical to the API counts modulo ~6 tokens of message framing). Set OPENAI_API_KEY, ANTHROPIC_API_KEY, and/or DEEPSEEK_API_KEY to reproduce the cross-model table with exact provider counts; Gemini and xAI models are supported via --models with their keys. The script’s dependencies are pinned under a two-week cooling-off policy (uv’s exclude-newer).

Notes

Footnotes

  1. Unicode Standard Annex #29, “Unicode Text Segmentation,” §4 Word Boundaries: default rules break between every pair of ideographs (rule WB999), and “reliable detection of word boundaries in languages such as Thai, Lao, Chinese, or Japanese requires the use of dictionary lookup or other mechanisms.” https://unicode.org/reports/tr29/ 2

  2. Danube–steam–ship–journey–company; the Erste Donau-Dampfschiffahrts-Gesellschaft (founded 1829) is the textbook example of German nominal compounding. https://en.wikipedia.org/wiki/Donaudampfschiffahrtsgesellschaft

  3. Measured 2026-07-12 with validate_lwc.py on 8,000-character truncations of Wikipedia articles and open-source code. GPT-5.6 = OpenAI chat completions API usage.prompt_tokens (the three GPT-5.6 variants — luna, sol, terra — return identical counts, matching tiktoken’s o200k_base within ~6 tokens of message framing); Claude = Anthropic count_tokens API (Claude Opus 4.8, Fable 5, and Sonnet 5 return identical counts — one shared tokenizer); DeepSeek = OpenAI-compatible completions API (deepseek-v4-pro and v4-flash identical). An earlier five-provider run that included Gemini 3.1 Pro and Grok-4 showed the same picture (35–79% spread).

  4. Petrov, La Malfa, Torr, and Bibi, “Language Model Tokenizers Introduce Unfairness Between Languages,” NeurIPS 2023. arXiv:2305.15425. “The same text translated into different languages can have drastically different tokenization lengths, with differences up to 15 times in some cases” (Shan vs. English under cl100k; even the cheapest languages carry a ~50% premium over English). https://arxiv.org/abs/2305.15425 2 3

  5. Unicode Standard Annex #11, “East Asian Width.” https://www.unicode.org/reports/tr11/

  6. ETSI GS LIS 004 V2.0.0 (2012-07), “GMX-V: Global Information Management Metrics eXchange — Volume” (successor to the LISA standard), clause 4.2.13 “Logographic Scripts”: word counts for unspaced scripts are derived from character counts via fixed factors — Chinese 2.8, Japanese 3.0, Korean 3.3, Thai 6.0 — “based on acknowledged best practice within the Localization Industry.” https://www.etsi.org/deliver/etsi_gs/lis/001_099/004/02.00.00_60/gs_lis004v020000p.pdf 2 3 4

  7. E.g. AnyCount, “Word count in Oriental languages”: 1,000 Chinese characters ≈ 650–750 English words (https://www.anycount.com/word-count-worldwide/word-count-in-oriental-languages/); 1StopAsia gives the same Chinese figures and recommends character counts for Chinese/Japanese/Thai (https://www.1stopasia.com/blog/word-count-vs-character-count-for-asian-languages/).

  8. SWET (Society of Writers, Editors and Translators, Tokyo), “What’s in a Page?”: the 400-character genkō yōshi manuscript page corresponds to roughly 200–250 English words. https://swet.jp/articles/article/whats_in_a_page/_C31

  9. X developer documentation, “Counting characters,” and the open-source twitter-text library config (v3.json): code points U+0000–10FF plus selected general-punctuation ranges weigh 1; everything else (all CJK, Hangul, emoji) weighs 2; URLs count as 23. https://docs.x.com/fundamentals/counting-characters, https://github.com/twitter/twitter-text/blob/master/config/v3.json 2

  10. Medium Help Center, “Read time”: “roughly 265 WPM… For posts in Chinese, Japanese and Korean, it’s a function of number of characters (500 characters/min).” Hugo (hugolib/page__content.go) uses the same 500 chars/min for CJK vs. ~213 wpm otherwise, and counts runes per CJK token when hasCJKLanguage is set. The reading-time npm package counts each CJK character as one word. https://help.medium.com/hc/en-us/articles/214991667, https://github.com/gohugoio/hugo, https://github.com/ngryman/reading-time 2

  11. ICU segments Chinese and Japanese with a frequency dictionary (cjdict) rather than Unicode default rules; dictionary-segmented Chinese words average roughly 1.5–2 characters. https://unicode-org.github.io/icu/userguide/boundaryanalysis/

  12. Microsoft Word: “In East Asian languages, each character counts as a word” (NumChars documentation), with a separate wdStatisticFarEastCharacters statistic (https://learn.microsoft.com/en-us/office/vba/api/word.wdstatistic). memoQ: “Source words = Asian characters + non-Asian words” for Chinese/Japanese sources (https://docs.memoq.com/current/en/Workspace/statistics.html). WordPress: locale packs translate the word-count type to character counting for Japanese and Chinese (WP_Locale::get_word_count_type()). 2

  13. Sennrich, Haddow, and Birch, “Neural Machine Translation of Rare Words with Subword Units,” ACL 2016 — the original BPE-for-NLP paper. https://aclanthology.org/P16-1162/ 2

  14. Measured on ~300k characters of English prose (Pride and Prejudice) and ~165k characters of Python stdlib source: cl100k_base 4.24/4.81 chars per token (prose/code), o200k_base 4.28/4.77, Llama 3 4.25/4.81, Gemma 2 4.29/4.03. Shorter, punctuation- or link-heavy samples run lower (3.1–3.6 in the validation corpus).

  15. OpenAI: “1 token ≈ 4 characters… ≈ ¾ of a word; 100 tokens ≈ 75 words” (https://help.openai.com/en/articles/4936856) and “1 token is approximately 4 characters or 0.75 words for English text” (https://developers.openai.com/api/docs/concepts). Anthropic: “a token approximately represents 3.5 English characters” (https://platform.claude.com/docs/en/docs/resources/glossary). Google: “a token is equivalent to about 4 characters… 100 tokens is equal to about 60-80 English words” (https://ai.google.dev/gemini-api/docs/tokens). 2

  16. Ahia et al., “Do All Languages Cost the Same? Tokenization in the Era of Commercial Language Models,” EMNLP 2023. arXiv:2305.13707. Documents ~5× token-count and API-cost premiums for non-Latin-script languages. https://aclanthology.org/2023.emnlp-main.614/ 2

  17. Words-per-minute measurement standardizes a “word” as 5 characters or keystrokes (including spaces and punctuation). https://en.wikipedia.org/wiki/Words_per_minute 2

  18. § 11 JVEG (Justizvergütungs- und -entschädigungsgesetz): translation honoraria “für jeweils angefangene 55 Anschläge des schriftlichen Textes” (per commenced 55 keystrokes). https://www.gesetze-im-internet.de/jveg/__11.html. Normseite: 30 lines × 60 keystrokes = 1,800 characters (publishing convention); VG Wort uses 1,500. https://de.wikipedia.org/wiki/Normseite

  19. Amazon KDP, “Royalties in Kindle Unlimited”: KENPC (Kindle Edition Normalized Page Count) normalizes page count using standard formatting settings; the formula is unpublished (author analyses estimate ~170–200 words per page). https://kdp.amazon.com/en_US/help/topic/G201541130

  20. Senter and Smith, “Automated Readability Index,” AMRL-TR-66-220, 1967. Formula: ARI = 4.71 × (chars/words) + 0.5 × (words/sentences) − 21.43. https://apps.dtic.mil/sti/tr/pdf/AD0667273.pdf

  21. Ács, “Exploring BERT’s Vocabulary” (2019) defined subword fertility — average subwords per word — borrowing the term from statistical MT (Brown et al. 1993); Rust et al., “How Good is Your Tokenizer?” (ACL 2021) established it as the standard tokenizer-efficiency metric. https://juditacs.github.io/2019/02/19/bert-tokenization-stats.html, https://aclanthology.org/2021.acl-long.243/

  22. tokenx: character-ratio estimation with per-language overrides, “~96% accuracy” (https://github.com/johannschopplich/tokenx). LangChain count_tokens_approximately: chars ÷ 4 plus per-message overhead. Microsoft Semantic Kernel TextChunker: length ÷ 4.

#!/usr/bin/env python3
"""Validate logical-word-count constants against real tokenizer output.
Fetches diverse text samples (Wikipedia articles in nine languages, source code,
minified JS, CSV, JSON, markdown), measures literal word count, logical word
count, and token count via multiple model tokenizers, then prints comparison
tables showing how stable the K = tokens / logical_words ratio is across text
types — including a head-to-head against the two common baselines (literal word
count and chars/4).
Self-contained: all samples are fetched from public URLs (and cached in
--cache-dir), so this script can be published and run standalone. Only the
OpenAI tokenizer (tiktoken) runs locally with no API key; other providers need
their API key set and are skipped otherwise.
Usage:
uv run validate_lwc.py
uv run validate_lwc.py --models gpt-5 claude-opus-4-6
uv run validate_lwc.py --lower 3 --upper 6 --wide-weight 0.5
uv run validate_lwc.py --samples en-wiki zh-wiki python-code
uv run validate_lwc.py --list-samples
uv run validate_lwc.py --sample-url my-file https://example.com/text.txt
Tokenizer backends (per provider):
OpenAI (gpt-*) Exact count via the chat completions API (usage.prompt_tokens)
when OPENAI_API_KEY is set; otherwise tiktoken locally
(gpt-5.x uses o200k_base — measured identical to the API
count modulo ~6 tokens of message framing).
Anthropic (claude-*) Anthropic count_tokens API — needs ANTHROPIC_API_KEY.
Gemini (gemini/*) Google Generative AI count_tokens — needs GEMINI_API_KEY or GOOGLE_API_KEY.
Grok (xai/*) OpenAI-compat completions API (reads usage.prompt_tokens) — needs XAI_API_KEY.
DeepSeek (deepseek/) OpenAI-compat completions API (reads usage.prompt_tokens) — needs DEEPSEEK_API_KEY.
Other No tokenizer known — token count shown as n/a.
"""
# /// script
# requires-python = ">=3.13"
# dependencies = [
# "tiktoken>=0.13",
# "anthropic>=0.112",
# "google-genai>=2.10",
# "httpx>=0.28.1",
# "rich>=15",
# ]
# [tool.uv]
# # Two-week cooling-off policy: resolve no package released after this date
# # (bump the date when updating dependencies).
# exclude-newer = "2026-06-28T00:00:00Z"
# ///
from __future__ import annotations
import argparse
import os
import re
import sys
import unicodedata
from enum import Enum
from pathlib import Path
import anthropic # type: ignore[import-untyped]
import httpx
import tiktoken # type: ignore[import-untyped]
from google import genai # type: ignore[import-untyped]
from rich.console import Console
# ---------------------------------------------------------------------------
# Logical word count — reference implementation
# ---------------------------------------------------------------------------
def is_wide(c: str) -> bool:
"""True for Unicode East_Asian_Width Wide or Fullwidth characters.
Covers Han ideographs, Hiragana, Katakana, Hangul syllables, CJK
punctuation, and fullwidth forms. (Emoji are also Wide; they are rare
enough in typical content not to matter.)
"""
return unicodedata.east_asian_width(c) in ("W", "F")
def logical_word_count(
text: str, lower: float = 3.0, upper: float = 6.0, wide_weight: float = 0.5
) -> int:
"""Tokenizer-free measure of text length; see the logical-word-count spec.
Wide (East Asian) characters each count as `wide_weight` logical words.
The remaining text is whitespace-split and its word count is clamped to
[chars/upper, chars/lower] so the average characters per word stays
within [lower, upper].
"""
wide = 0
rest: list[str] = []
for c in text:
if is_wide(c):
wide += 1
rest.append(" ")
else:
rest.append(c)
words = "".join(rest).split()
chars = sum(len(w) for w in words)
actual = len(words)
clamped = max(chars / upper, min(chars / lower, actual))
return round(wide * wide_weight + clamped)
def count_literal_words(text: str) -> int:
return len(text.split())
def count_nonws_chars(text: str) -> int:
return sum(1 for c in text if not c.isspace())
# ---------------------------------------------------------------------------
# Default sample sources — override with --sample-url NAME URL
# ---------------------------------------------------------------------------
WIKIPEDIA_API = "https://{lang}.wikipedia.org/w/api.php"
WIKIPEDIA_PARAMS = {
"action": "query",
"prop": "extracts",
"explaintext": "true",
"formatversion": "2",
"format": "json",
}
# Wikipedia requires a descriptive User-Agent per their bot policy
HTTPX_HEADERS = {
"User-Agent": "validate-lwc/2.0 (logical-word-count research)"
}
def wiki(lang: str, title: str, description: str) -> dict:
return {"description": description, "type": "wikipedia", "lang": lang, "title": title}
def raw(url: str, description: str) -> dict:
return {"description": description, "type": "raw_url", "url": url}
DEFAULT_SAMPLES: dict[str, dict] = {
# Natural language — Latin scripts
"en-wiki": wiki("en", "Python_(programming_language)", "English Wikipedia: Python"),
"fr-wiki": wiki("fr", "Python_(langage)", "French Wikipedia: Python"),
"de-wiki": wiki("de", "Python_(Programmiersprache)", "German Wikipedia: Python (compounding)"),
"fi-wiki": wiki("fi", "Python_(ohjelmointikieli)", "Finnish Wikipedia: Python (agglutinative)"),
# Natural language — non-Latin alphabetic scripts
"ru-wiki": wiki("ru", "Python", "Russian Wikipedia: Python (Cyrillic)"),
"he-wiki": wiki("he", "פייתון", "Hebrew Wikipedia: Python"),
# Natural language — CJK and unspaced scripts
"zh-wiki": wiki("zh", "Python", "Chinese Wikipedia: Python"),
"ja-wiki": wiki("ja", "Python", "Japanese Wikipedia: Python"),
"ko-wiki": wiki("ko", "파이썬", "Korean Wikipedia: Python"),
"th-wiki": wiki("th", "ประเทศไทย", "Thai Wikipedia: Thailand (unspaced, not CJK)"),
# Code
"python-code": raw(
"https://raw.githubusercontent.com/psf/requests/main/src/requests/api.py",
"Python code: requests/api.py (docstring-heavy)",
),
"ts-code": raw(
"https://raw.githubusercontent.com/vitejs/vite/main/packages/vite/src/node/config.ts",
"TypeScript code: vite config.ts",
),
"js-minified": raw(
"https://code.jquery.com/jquery-3.7.1.min.js",
"Minified JavaScript: jquery.min.js (pathological: no spaces)",
),
# Machine formats and mixed content
"csv-data": raw(
"https://raw.githubusercontent.com/datasets/gdp/main/data/gdp.csv",
"CSV data: country GDP table",
),
"json-config": raw(
"https://raw.githubusercontent.com/microsoft/vscode/main/package.json",
"JSON: vscode package.json",
),
"md-readme": raw(
"https://raw.githubusercontent.com/tiangolo/fastapi/master/README.md",
"Markdown: FastAPI README (mixed prose/code/links)",
),
}
# Default models to measure — override with --models (e.g. gemini/gemini-*,
# xai/grok-* with the matching API key). Verified current as of 2026-07:
# gpt-5.6-{luna,sol,terra} share one tokenizer (o200k); claude-opus-4-8,
# claude-fable-5, and claude-sonnet-5 share one tokenizer.
DEFAULT_MODELS = [
"gpt-5.6-sol",
"claude-opus-4-8",
"claude-fable-5",
"deepseek/deepseek-v4-pro",
]
# ---------------------------------------------------------------------------
# Provider detection and tokenizer dispatch
# ---------------------------------------------------------------------------
class Provider(Enum):
OPENAI = "openai"
ANTHROPIC = "anthropic"
GEMINI = "gemini"
XAI = "xai"
DEEPSEEK = "deepseek"
@classmethod
def for_model(cls, model: str) -> Provider | None:
namespace = model.split("/")[0].lower() if "/" in model else ""
bare = model.split("/")[-1].lower()
for provider, (namespaces, bare_prefixes) in _PROVIDER_PATTERNS.items():
if namespace in namespaces or any(bare.startswith(p) for p in bare_prefixes):
return provider
return None
def count_tokens(self, text: str, model: str) -> int | None:
bare = model.split("/")[-1]
if self is Provider.OPENAI:
return _openai_count(text, bare)
if self is Provider.ANTHROPIC:
return _anthropic_count(text, bare)
if self is Provider.GEMINI:
return _gemini_count(text, model)
if self is Provider.XAI:
return _xai_count(text, bare)
if self is Provider.DEEPSEEK:
return _deepseek_count(text, bare)
return None
# Registry: provider → (recognized namespaces, recognized bare-name prefixes).
# Unqualified model names (no "/") use namespace="" and match on bare prefix only.
_PROVIDER_PATTERNS: dict[Provider, tuple[tuple[str, ...], tuple[str, ...]]] = {
Provider.ANTHROPIC: (("anthropic",), ("claude-",)),
Provider.GEMINI: (("gemini",), ("gemini-",)),
Provider.XAI: (("xai",), ("grok-",)),
Provider.DEEPSEEK: (("deepseek",), ("deepseek-",)),
Provider.OPENAI: (("openai", ""), ("gpt-", "o1", "o3")),
}
def _tiktoken_encoding(model: str) -> str:
try:
return tiktoken.encoding_for_model(model).name
except KeyError:
return "o200k_base" # gpt-5 and newer
def _tiktoken_count(text: str, model: str) -> int:
enc = tiktoken.get_encoding(_tiktoken_encoding(model))
return len(enc.encode(text, disallowed_special=()))
def _openai_count(text: str, model: str) -> int:
"""Exact OpenAI count via API when a key is set; tiktoken fallback otherwise.
gpt-5.x reasoning models reject max_tokens and need a small completion
budget; older models want max_tokens. Try both shapes.
"""
api_key = os.environ.get("OPENAI_API_KEY")
if not api_key:
return _tiktoken_count(text, model)
messages = [{"role": "user", "content": text}]
for params in (
{"max_completion_tokens": 16, "reasoning_effort": "none"},
{"max_completion_tokens": 16},
{"max_tokens": 1},
):
try:
resp = httpx.post(
"https://api.openai.com/v1/chat/completions",
headers={"Authorization": f"Bearer {api_key}"},
json={"model": model, "messages": messages, **params},
timeout=120,
)
resp.raise_for_status()
return resp.json()["usage"]["prompt_tokens"]
except httpx.HTTPStatusError:
continue
return _tiktoken_count(text, model)
def _anthropic_count(text: str, model: str) -> int | None:
if not os.environ.get("ANTHROPIC_API_KEY"):
return None
response = anthropic.Anthropic().messages.count_tokens(
model=model,
messages=[{"role": "user", "content": text}],
)
return response.input_tokens
def _gemini_count(text: str, model: str) -> int | None:
api_key = os.environ.get("GEMINI_API_KEY") or os.environ.get("GOOGLE_API_KEY")
if not api_key:
return None
bare = model.split("/")[-1] # "gemini/gemini-3.1-pro-preview" → "gemini-3.1-pro-preview"
client = genai.Client(api_key=api_key)
return client.models.count_tokens(model=bare, contents=text).total_tokens
def _openai_compat_count(text: str, model: str, base_url: str, api_key: str) -> int:
"""Exact token count via OpenAI-compatible chat completions (max_tokens=1, reads usage.prompt_tokens)."""
resp = httpx.post(
f"{base_url}/chat/completions",
headers={"Authorization": f"Bearer {api_key}"},
json={"model": model, "messages": [{"role": "user", "content": text}], "max_tokens": 1},
timeout=120,
)
resp.raise_for_status()
return resp.json()["usage"]["prompt_tokens"]
def _xai_count(text: str, model: str) -> int | None:
api_key = os.environ.get("XAI_API_KEY")
if not api_key:
return None
return _openai_compat_count(text, model, "https://api.x.ai/v1", api_key)
def _deepseek_count(text: str, model: str) -> int | None:
api_key = os.environ.get("DEEPSEEK_API_KEY")
if not api_key:
return None
return _openai_compat_count(text, model, "https://api.deepseek.com/v1", api_key)
def count_tokens(text: str, model: str) -> int | None:
"""Return exact token count from the provider's API, or None if unsupported."""
provider = Provider.for_model(model)
if provider is None:
return None
return provider.count_tokens(text, model)
# ---------------------------------------------------------------------------
# Fetching
# ---------------------------------------------------------------------------
def fetch_sample(spec: dict, cache_dir: Path | None, name: str) -> str:
if cache_dir:
cached = cache_dir / f"{name}.txt"
if cached.exists():
return cached.read_text(encoding="utf-8")
kind = spec["type"]
if kind == "wikipedia":
params = {**WIKIPEDIA_PARAMS, "titles": spec["title"]}
url = WIKIPEDIA_API.format(lang=spec["lang"])
resp = httpx.get(url, params=params, timeout=20, follow_redirects=True, headers=HTTPX_HEADERS)
resp.raise_for_status()
text = resp.json()["query"]["pages"][0].get("extract", "")
elif kind == "raw_url":
resp = httpx.get(spec["url"], timeout=20, follow_redirects=True, headers=HTTPX_HEADERS)
resp.raise_for_status()
text = resp.text
else:
raise ValueError(f"Unknown sample type: {kind!r}")
if cache_dir and text:
cache_dir.mkdir(parents=True, exist_ok=True)
(cache_dir / f"{name}.txt").write_text(text, encoding="utf-8")
return text
# ---------------------------------------------------------------------------
# Markdown rendering
# ---------------------------------------------------------------------------
FLOAT_FMT = "{:.2f}"
def ratio_str(tc: int | None, denom: float) -> str:
return "n/a" if tc is None or not denom else FLOAT_FMT.format(tc / denom)
def tok_str(tc: int | None) -> str:
return "n/a" if tc is None else f"{tc:,}"
def abbrev_model(m: str) -> str:
name = m.split("/")[-1]
return re.sub(r"-\d{8}$", "", name) # drop date suffixes like -20250514
def md_table(headers: list[str], rows: list[list[str]], aligns: list[str] | None = None) -> str:
"""Render a GFM markdown table. aligns: one of 'l', 'r', 'c' per column."""
if aligns is None:
aligns = ["l"] * len(headers)
sep = {"l": ":---", "r": "---:", "c": ":---:"}
lines = [
"| " + " | ".join(headers) + " |",
"| " + " | ".join(sep[a] for a in aligns) + " |",
] + ["| " + " | ".join(row) + " |" for row in rows]
return "\n".join(lines)
def band_str(values: list[float]) -> str:
lo, hi = min(values), max(values)
return f"{FLOAT_FMT.format(lo)}–{FLOAT_FMT.format(hi)} ({FLOAT_FMT.format(hi / lo)}×)" if lo else "n/a"
def render_report(results: list[dict], models: list[str], lower: float, upper: float,
wide_weight: float) -> str:
abbrevs = [abbrev_model(m) for m in models]
sections: list[str] = []
# ── §1: raw token counts per model + cross-model % variation ──────────
headers = ["sample"] + abbrevs + ["% variation", "low → high"]
aligns = ["l"] + ["r"] * len(abbrevs) + ["r", "l"]
rows = []
for r in results:
known = [(m, tc) for m in models if (tc := r["tokens"].get(m)) is not None]
row = [r["name"]] + [tok_str(r["tokens"].get(m)) for m in models]
if len(known) >= 2:
counts = [tc for _, tc in known]
lo, hi = min(counts), max(counts)
dev = (hi - lo) / lo if lo else 0
lo_name = abbrev_model(next(m for m, tc in known if tc == lo))
hi_name = abbrev_model(next(m for m, tc in known if tc == hi))
row += [f"{dev:.0%}", f"{lo_name} → {hi_name}" if dev >= 0.05 else ""]
else:
row += ["n/a", ""]
rows.append(row)
sections.append("## §1 Token counts across models\n\n" + md_table(headers, rows, aligns))
# ── §2: word-count metrics ─────────────────────────────────────────────
headers2 = ["sample", "chars", "wide", "literal words", "logical words", "lw / lit"]
rows2 = [
[r["name"], f"{r['chars']:,}", f"{r['wide']:,}", f"{r['literal']:,}", f"{r['logical']:,}",
FLOAT_FMT.format(r["logical"] / r["literal"]) if r["literal"] else "—"]
for r in results
]
sections.append(
f"## §2 Word count metrics (L={lower}, U={upper}, wide weight={wide_weight})\n\n"
+ md_table(headers2, rows2, ["l", "r", "r", "r", "r", "r"])
)
# ── §3: K = tokens / logical_words per model ───────────────────────────
headers3 = ["sample"] + abbrevs
rows3 = [
[r["name"]] + [ratio_str(r["tokens"].get(m), r["logical"]) for m in models]
for r in results
]
sections.append("## §3 K = tokens / logical_words\n\n" + md_table(headers3, rows3, ["l"] + ["r"] * len(abbrevs)))
# ── §4: estimator stability — logical words vs the two baselines ──────
# For each model, the spread (max/min across samples) of tokens divided by
# each candidate length measure. A perfectly proportional measure has 1.0×.
estimators = [
("tokens / logical_words", lambda r: r["logical"]),
("tokens / literal_words", lambda r: r["literal"]),
("tokens / (chars ÷ 4)", lambda r: r["chars"] / 4),
]
headers4 = ["model"] + [name for name, _ in estimators]
rows4 = []
for m, ab in zip(models, abbrevs):
row = [ab]
for _, denom in estimators:
ks = [tc / denom(r) for r in results
if (tc := r["tokens"].get(m)) is not None and denom(r)]
row.append(band_str(ks) if ks else "n/a")
rows4.append(row)
sections.append(
"## §4 Ratio stability: range of tokens/measure across samples (spread ×)\n\n"
+ md_table(headers4, rows4, ["l", "r", "r", "r"])
)
return "\n\n".join(sections)
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("--samples", "-s", nargs="+", metavar="NAME", default=list(DEFAULT_SAMPLES),
help="Sample names to include (default: all). See --list-samples.")
p.add_argument("--list-samples", action="store_true", help="Print available sample names and exit.")
p.add_argument("--sample-url", nargs=2, metavar=("NAME", "URL"), action="append", default=[],
help="Override the URL for a named sample, or add a new raw-URL sample.")
p.add_argument("--models", "-m", nargs="+", metavar="MODEL", default=DEFAULT_MODELS,
help=f"Model names to count tokens with (default: {' '.join(DEFAULT_MODELS)}).")
p.add_argument("--lower", type=float, default=3.0, metavar="L",
help="Lower bound on avg chars/logical-word (default: 3.0).")
p.add_argument("--upper", type=float, default=6.0, metavar="U",
help="Upper bound on avg chars/logical-word (default: 6.0).")
p.add_argument("--wide-weight", type=float, default=0.5, metavar="W",
help="Logical words per wide (East Asian) character (default: 0.5; 0 disables).")
p.add_argument("--truncate", type=int, default=8000, metavar="N",
help="Truncate each sample to N characters before measuring (default: 8000; 0 = no truncation).")
p.add_argument("--cache-dir", default=".lwc-samples", metavar="DIR",
help="Directory to cache fetched samples (default: .lwc-samples; '' disables).")
return p.parse_args()
def print_report(report: str) -> None:
"""Print report as rendered markdown to a TTY, or raw markdown when piped/redirected."""
if sys.stdout.isatty():
from rich.markdown import Markdown # type: ignore[import-untyped]
Console().print(Markdown(report))
else:
print(report)
def main() -> None:
args = parse_args()
# Progress and errors go to stderr so they never pollute the markdown output.
progress = Console(stderr=True)
samples = dict(DEFAULT_SAMPLES)
for name, url in args.sample_url:
samples[name] = raw(url, samples[name]["description"] if name in samples else f"Custom: {url}")
if args.list_samples:
rows = [[name, spec["description"]] for name, spec in samples.items()]
print_report(md_table(["name", "description"], rows))
return
selected = {k: samples[k] for k in args.samples if k in samples}
unknown = [k for k in args.samples if k not in samples]
if unknown:
progress.print(f"[red]Unknown samples: {unknown}. Use --list-samples.[/red]")
sys.exit(1)
cache_dir = Path(args.cache_dir) if args.cache_dir else None
results = []
with progress.status("Fetching samples and counting tokens..."):
for name, spec in selected.items():
progress.log(f"Fetching [bold]{name}[/bold] — {spec['description']}")
try:
text = fetch_sample(spec, cache_dir, name)
except Exception as e:
progress.log(f" [red]SKIP: {e}[/red]")
continue
if not text.strip():
progress.log(" [red]SKIP: empty sample[/red]")
continue
if args.truncate:
text = text[: args.truncate]
results.append({
"name": name,
"chars": count_nonws_chars(text),
"wide": sum(1 for c in text if is_wide(c) and not c.isspace()),
"literal": count_literal_words(text),
"logical": logical_word_count(text, args.lower, args.upper, args.wide_weight),
"tokens": {model: count_tokens(text, model) for model in args.models},
})
print_report(render_report(results, args.models, args.lower, args.upper, args.wide_weight))
if __name__ == "__main__":
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment