@trazum/core 1.8.0 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/tokenizer.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * This is NOT a real tokenizer: it is a heuristic calibrated per character
5
5
  * class. It is built to keep the typical error on ordinary text
6
- * (English/Spanish, markdown, code) inside ±15%, which is plenty for comparing
6
+ * (English/Spanish, markdown, code) inside ±10%, which is plenty for comparing
7
7
  * two versions of the same prompt — but do NOT bill anyone from it.
8
8
  *
9
9
  * **That band is a design target that has not been measured.** It is printed on
@@ -21,12 +21,140 @@
21
21
  * endpoint, which is free) or pass your own `TokenCounter`.
22
22
  */
23
23
 
24
+ import { detectTextLanguage } from './language.js';
24
25
  import { SAFE_FETCH_INIT, checkedEndpoint } from './net.js';
25
26
 
27
+ /**
28
+ * The error band this estimator is published under, as a percentage.
29
+ *
30
+ * **Measured, not chosen, and it has moved three times.** It was `15` for eight
31
+ * releases as a design target nobody had checked; the first measurement found two
32
+ * of eight samples outside it and it went to `25`; fixing the digit divisor and
33
+ * calibrating per language brought it back to `15`. This is the fourth value and
34
+ * the first one that is comfortably above what the corpus actually shows.
35
+ *
36
+ * ```
37
+ * worst measured error 6.4% (code-heavy, which nothing is fitted to)
38
+ * published band 10%
39
+ * ```
40
+ *
41
+ * **The margin is deliberate and it is not slack.** 6.4 rounded up is 7, and
42
+ * publishing 7 would be a tighter claim than twenty-one samples across six text
43
+ * types can support: the corpus has no Korean, no Arabic, no Cyrillic prose, no
44
+ * mixed-script document, and a seventh text type could easily land at eight. A
45
+ * band that becomes false the first time somebody measures something new is the
46
+ * exact fault this whole exercise was fixing. Overstating the uncertainty is the
47
+ * safe direction for a tool that reports money.
48
+ *
49
+ * What earned the drop from 15 was **splitting kana from han**. Every CJK
50
+ * character was charged one token, which put Japanese at +11.2% — the worst error
51
+ * anywhere in the corpus — while Chinese sat at −3.2% under the same rule. Kana
52
+ * measure 0.75 tokens per character and han 1.05, and that pair takes the two
53
+ * samples to −1.5% and +1.3%. See `KANA_TOKENS_PER_CHAR`.
54
+ *
55
+ * **Which samples the band rests on matters more than the number.** Eight of the
56
+ * twenty-one had a constant fitted to them — seven Latin divisors and the digit
57
+ * divisor — so their residuals are optimistic by construction. The two worst
58
+ * errors in the corpus, `code-heavy` at 6.4% and `punctuation-heavy` at 5.7%, are
59
+ * fitted to nothing at all, and they are what sets this figure.
60
+ *
61
+ * Exported so every report, README and tool description reads the same number. It
62
+ * was a literal in twenty-four files before this, with the only machine-readable
63
+ * copy in a test, and `token-band.test.js` now fails any file that states a
64
+ * different one.
65
+ */
66
+ export const ESTIMATE_ERROR_BAND_PCT = 10;
67
+
26
68
  const CJK = /[぀-ヿ㐀-䶿一-鿿가-힯]/;
69
+
70
+ /**
71
+ * Kana, separated from the rest of CJK because they do not cost the same.
72
+ *
73
+ * **This was the largest error left in the corpus.** Every CJK character was
74
+ * charged one token, and measured against the counting endpoint that put Japanese
75
+ * at **+11.2%** — the worst figure anywhere in twenty-one samples — while Chinese
76
+ * came out at −3.2% under the identical rule. One constant cannot be right for
77
+ * both, and the reason is visible in the samples: the Japanese one is 58% kana and
78
+ * the Chinese one is 0%.
79
+ *
80
+ * Kana are a small syllabary that appears in every sentence, so the merge table
81
+ * covers runs of them and several characters share a token. Han are tens of
82
+ * thousands of rare characters; a merge table cannot cover them and they cost
83
+ * about one each, sometimes more.
84
+ *
85
+ * The signal needs no detector. A character is kana or it is not, and the two
86
+ * samples separate perfectly — 58.3% against 0.00% — so this is a property of the
87
+ * character rather than a guess about the document. That is the difference between
88
+ * this and `language.ts`, which has to decide and is allowed to refuse.
89
+ */
90
+ const KANA = /[぀-ヿ]/;
27
91
  const LETTER = /[A-Za-zÀ-ɏͰ-ϿЀ-ӿ]/;
28
92
  const DIGIT = /[0-9]/;
29
93
 
94
+ /**
95
+ * Characters per token, per language.
96
+ *
97
+ * Measured, one entry at a time, against `test/fixtures/token-ground-truth.json`.
98
+ * English keeps 4 because it measures +1.0% there; the others are lower because
99
+ * they are thinner in the merge table and cost more tokens for the same text.
100
+ *
101
+ * **Every one of these now has a held-out test.** Each language was calibrated on
102
+ * support prompts and then measured again on a code-review prompt — a different
103
+ * register, different vocabulary, different length — and the divisors held:
104
+ * English +1.0% then +0.4%, German -9.2% then -8.5%, French -1.2% then -5.8%,
105
+ * Spanish -6.2% then -9.7% under the previous values. That is the evidence that
106
+ * these fit a language rather than a template, and it is why the band can rest on
107
+ * them now.
108
+ *
109
+ * `en` stays at a round 4 rather than the 4.05 the search prefers: a hundredth of
110
+ * a divisor is precision the twenty-one samples cannot support, and it changes no
111
+ * estimate.
112
+ *
113
+ * A language absent from this table falls through to `DEFAULT_DIVISOR`, which is
114
+ * the English number and the behaviour this estimator has always had. Adding a
115
+ * language means measuring it, not guessing it.
116
+ */
117
+ const DIVISOR_BY_LANGUAGE: Readonly<Record<string, number>> = {
118
+ en: 4,
119
+ es: 2.8,
120
+ fr: 3.05,
121
+ de: 2.25,
122
+ it: 2.8,
123
+ pt: 3.05,
124
+ nl: 2.65,
125
+ };
126
+
127
+ /**
128
+ * Tokens per character for the two halves of CJK, measured.
129
+ *
130
+ * `0.75` and `1.05` come from a search over the two CJK samples in
131
+ * `test/fixtures/token-ground-truth.json`, and they take that pair from
132
+ * +11.2% / −3.2% to −1.5% / +1.3%.
133
+ *
134
+ * **Two samples fitted two constants, so those residuals are in-sample and
135
+ * optimistic by construction** — the same caveat the Latin divisors carry, stated
136
+ * for the same reason. What makes them worth having anyway is the size of the
137
+ * error they replace and the fact that they move in opposite directions: a single
138
+ * constant could not have been within four points of both, whatever it was set to.
139
+ * The honest test is a third CJK sample, and the corpus grows one at a time.
140
+ *
141
+ * Hangul keeps the old cost of 1, because nothing here measures Korean. Guessing
142
+ * it from Japanese would be inventing a figure — the two scripts have nothing in
143
+ * common that would make one predict the other.
144
+ */
145
+ const KANA_TOKENS_PER_CHAR = 0.75;
146
+ const HAN_TOKENS_PER_CHAR = 1.05;
147
+
148
+ /**
149
+ * What a prompt gets when the language could not be told.
150
+ *
151
+ * The English value on purpose. An unknown-language prompt is most often English
152
+ * or code, and lowering this would inflate every estimate that the detector
153
+ * declined to classify — a change that helps nothing measured and hurts the two
154
+ * samples the estimator gets right.
155
+ */
156
+ const DEFAULT_DIVISOR = 4;
157
+
30
158
  /** Effective word length: non-ASCII characters split into more tokens. */
31
159
  function effectiveLength(word: string): number {
32
160
  let len = 0;
@@ -49,7 +177,17 @@ function effectiveLength(word: string): number {
49
177
  export function estimateTokens(text: string): number {
50
178
  if (!text) return 0;
51
179
 
180
+ // Detected once for the whole text, not per word. A prompt is one document; a
181
+ // per-word guess would be noise, and this runs on every call.
182
+ const language = detectTextLanguage(text);
183
+ const divisor = (language === null ? undefined : DIVISOR_BY_LANGUAGE[language]) ?? DEFAULT_DIVISOR;
184
+
52
185
  let total = 0;
186
+ /**
187
+ * CJK is summed separately because it is the one class counted in fractions of
188
+ * a token. Everything else is whole tokens by the character class it belongs to.
189
+ */
190
+ let cjkTokens = 0;
53
191
  let i = 0;
54
192
  const chars = Array.from(text);
55
193
 
@@ -72,12 +210,19 @@ export function estimateTokens(text: string): number {
72
210
  }
73
211
 
74
212
  if (CJK.test(ch)) {
75
- let n = 0;
213
+ /**
214
+ * Accumulated as a fraction and rounded once, at the end.
215
+ *
216
+ * Rounding up per run was the first attempt and it was wrong by five
217
+ * points. Ordinary Japanese alternates kana and han inside every sentence,
218
+ * so the runs are short and there are many of them — and a `Math.ceil` per
219
+ * run charges most of a token for each boundary. That is an artefact of
220
+ * where the loop happens to break, not of what the text costs.
221
+ */
76
222
  while (i < chars.length && CJK.test(chars[i]!)) {
77
- n++;
223
+ cjkTokens += KANA.test(chars[i]!) ? KANA_TOKENS_PER_CHAR : HAN_TOKENS_PER_CHAR;
78
224
  i++;
79
225
  }
80
- total += n;
81
226
  continue;
82
227
  }
83
228
 
@@ -87,7 +232,7 @@ export function estimateTokens(text: string): number {
87
232
  word += chars[i]!;
88
233
  i++;
89
234
  }
90
- total += Math.max(1, Math.ceil(effectiveLength(word) / 4));
235
+ total += Math.max(1, Math.ceil(effectiveLength(word) / divisor));
91
236
  continue;
92
237
  }
93
238
 
@@ -97,7 +242,20 @@ export function estimateTokens(text: string): number {
97
242
  n++;
98
243
  i++;
99
244
  }
100
- total += Math.ceil(n / 3);
245
+ /**
246
+ * 1.5 digits per token, not 3.
247
+ *
248
+ * The 3 was a guess and it was the single worst constant in this file:
249
+ * measured against the counting endpoint, the numeric-heavy sample came out
250
+ * **30.6% under** — by far the largest error in the corpus. Claude's
251
+ * tokenizer splits long digit runs far more finely than prose, because a
252
+ * merge table cannot cover every number.
253
+ *
254
+ * Corrected in isolation, which is why it can be trusted: changing only
255
+ * this takes that sample from -30.6% to -5.0% and moves nothing else more
256
+ * than four points. See `test/fixtures/token-ground-truth.json`.
257
+ */
258
+ total += Math.ceil(n / 1.5);
101
259
  continue;
102
260
  }
103
261
 
@@ -133,7 +291,8 @@ export function estimateTokens(text: string): number {
133
291
  }
134
292
  }
135
293
 
136
- return total;
294
+ // Rounded once, over the whole document, rather than per run — see the CJK branch.
295
+ return total + Math.ceil(cjkTokens);
137
296
  }
138
297
 
139
298
  /** Asynchronous token counter, for remote sources. */
package/src/types.ts CHANGED
@@ -44,6 +44,7 @@ export type AdvisorySeverity = 'info' | 'opportunity' | 'warning';
44
44
  /** Every advisory the core can emit. */
45
45
  export type AdvisoryId =
46
46
  | 'context-overflow'
47
+ | 'context-near-limit'
47
48
  | 'prompt-caching'
48
49
  | 'prompt-caching-not-worth-it'
49
50
  | 'below-cache-minimum'