@trazum/core 1.81.0 → 1.83.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.
Files changed (84) hide show
  1. package/README.md +34 -18
  2. package/dist/advisories.d.ts +33 -1
  3. package/dist/advisories.d.ts.map +1 -1
  4. package/dist/advisories.js +130 -20
  5. package/dist/advisories.js.map +1 -1
  6. package/dist/band.d.ts +131 -0
  7. package/dist/band.d.ts.map +1 -0
  8. package/dist/band.js +210 -0
  9. package/dist/band.js.map +1 -0
  10. package/dist/conform.d.ts +1 -1
  11. package/dist/conform.d.ts.map +1 -1
  12. package/dist/conform.js +84 -1
  13. package/dist/conform.js.map +1 -1
  14. package/dist/helicone.d.ts +92 -0
  15. package/dist/helicone.d.ts.map +1 -0
  16. package/dist/helicone.js +194 -0
  17. package/dist/helicone.js.map +1 -0
  18. package/dist/i18n/en.d.ts.map +1 -1
  19. package/dist/i18n/en.js +16 -3
  20. package/dist/i18n/en.js.map +1 -1
  21. package/dist/i18n/es.d.ts.map +1 -1
  22. package/dist/i18n/es.js +17 -3
  23. package/dist/i18n/es.js.map +1 -1
  24. package/dist/i18n/types.d.ts +60 -1
  25. package/dist/i18n/types.d.ts.map +1 -1
  26. package/dist/index.d.ts +16 -3
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +9 -3
  29. package/dist/index.js.map +1 -1
  30. package/dist/langsmith.d.ts +104 -0
  31. package/dist/langsmith.d.ts.map +1 -0
  32. package/dist/langsmith.js +270 -0
  33. package/dist/langsmith.js.map +1 -0
  34. package/dist/levers.d.ts +1 -1
  35. package/dist/levers.js +2 -2
  36. package/dist/levers.js.map +1 -1
  37. package/dist/litellm.d.ts +98 -0
  38. package/dist/litellm.d.ts.map +1 -0
  39. package/dist/litellm.js +207 -0
  40. package/dist/litellm.js.map +1 -0
  41. package/dist/measured-profile.d.ts +4 -0
  42. package/dist/measured-profile.d.ts.map +1 -1
  43. package/dist/measured-profile.js +22 -1
  44. package/dist/measured-profile.js.map +1 -1
  45. package/dist/pricing.d.ts +98 -3
  46. package/dist/pricing.d.ts.map +1 -1
  47. package/dist/pricing.js +393 -35
  48. package/dist/pricing.js.map +1 -1
  49. package/dist/receipt.d.ts +253 -0
  50. package/dist/receipt.d.ts.map +1 -0
  51. package/dist/receipt.js +111 -0
  52. package/dist/receipt.js.map +1 -0
  53. package/dist/schemas.d.ts.map +1 -1
  54. package/dist/schemas.js +36 -0
  55. package/dist/schemas.js.map +1 -1
  56. package/dist/tokenizer.d.ts +45 -49
  57. package/dist/tokenizer.d.ts.map +1 -1
  58. package/dist/tokenizer.js +100 -51
  59. package/dist/tokenizer.js.map +1 -1
  60. package/dist/types.d.ts +108 -1
  61. package/dist/types.d.ts.map +1 -1
  62. package/dist/verdict-bridge.d.ts +113 -0
  63. package/dist/verdict-bridge.d.ts.map +1 -0
  64. package/dist/verdict-bridge.js +127 -0
  65. package/dist/verdict-bridge.js.map +1 -0
  66. package/package.json +1 -1
  67. package/src/advisories.ts +166 -19
  68. package/src/band.ts +213 -0
  69. package/src/conform.ts +100 -1
  70. package/src/helicone.ts +227 -0
  71. package/src/i18n/en.ts +19 -3
  72. package/src/i18n/es.ts +20 -3
  73. package/src/i18n/types.ts +63 -1
  74. package/src/index.ts +31 -2
  75. package/src/langsmith.ts +315 -0
  76. package/src/levers.ts +3 -3
  77. package/src/litellm.ts +243 -0
  78. package/src/measured-profile.ts +26 -2
  79. package/src/pricing.ts +430 -30
  80. package/src/receipt.ts +351 -0
  81. package/src/schemas.ts +36 -0
  82. package/src/tokenizer.ts +101 -51
  83. package/src/types.ts +104 -0
  84. package/src/verdict-bridge.ts +200 -0
@@ -0,0 +1 @@
1
+ {"version":3,"file":"verdict-bridge.js","sourceRoot":"","sources":["../src/verdict-bridge.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trazum/core",
3
- "version": "1.81.0",
3
+ "version": "1.83.0",
4
4
  "description": "Trazum core: priced advisories for LLM prompts (caching, model tier, batching, schemas), plus deterministic trimming, token counting and pricing.",
5
5
  "license": "MIT",
6
6
  "author": "David Mu\u00f1oz Rey",
package/src/advisories.ts CHANGED
@@ -2,12 +2,19 @@ import { analyzeCachePrefix } from './cache.js';
2
2
  import { getMessages } from './i18n/index.js';
3
3
  import type { Locale } from './i18n/types.js';
4
4
  import { COMPLEX_SIGNALS, SIMPLE_SIGNALS } from './phrases.js';
5
- import { BUNDLED_CATALOGUE, effectivePricing, modelFrom, multipliersFor } from './pricing.js';
5
+ import {
6
+ BUNDLED_CATALOGUE,
7
+ effectivePricing,
8
+ isOffered,
9
+ modelFrom,
10
+ multipliersFor,
11
+ } from './pricing.js';
6
12
  import type { PricingCatalogue } from './pricing.js';
7
13
  import { formatUsd } from './savings.js';
8
14
  import { analyzeExamples, findContradictions, findMovableSchema,
9
15
  findRestatedFormat } from './structure.js';
10
- import { ESTIMATE_ERROR_BAND_PCT, bandGoverns, estimateTokens } from './tokenizer.js';
16
+ import { bandGoverns, estimateTokens } from './tokenizer.js';
17
+ import { bandFor } from './band.js';
11
18
  import type { Advisory, ModelPricing, TokenCounter, UsageProfile } from './types.js';
12
19
 
13
20
  function countSignals(haystack: string, signals: readonly string[]): number {
@@ -16,17 +23,43 @@ function countSignals(haystack: string, signals: readonly string[]): number {
16
23
  return count;
17
24
  }
18
25
 
26
+ /** What `recommendTierDetailed` saw, not only what it concluded. */
27
+ export interface TierRecommendation {
28
+ tier: ModelPricing['tier'];
29
+ complexSignals: number;
30
+ simpleSignals: number;
31
+ /**
32
+ * True when the prompt asks for depth and for brevity at once, and the
33
+ * heuristic has no business preferring either.
34
+ *
35
+ * The score subtracts one side from the other, so three complex signals
36
+ * against three simple ones cancels to zero and returns `sonnet` — the same
37
+ * answer as a prompt with **no signals at all**. Those are opposite
38
+ * situations reported identically: one is "nothing here suggests a tier", the
39
+ * other is "this prompt contradicts itself about which tier it needs", and
40
+ * only the second is worth telling somebody about.
41
+ *
42
+ * The threshold is a lead of one signal or fewer, and it comes from the
43
+ * weights above rather than from taste: each signal moves the score by 2,
44
+ * prompt size moves it by up to 2, so a one-signal lead is inside what
45
+ * length alone contributes. A lead of two or more is a majority the size
46
+ * term cannot manufacture.
47
+ */
48
+ conflicted: boolean;
49
+ }
50
+
19
51
  /**
20
- * Estimates the capability tier the prompt needs.
52
+ * Estimates the capability tier the prompt needs, and says what it saw.
21
53
  *
22
54
  * This is a keyword-and-size heuristic, not a judgement about answer quality.
23
55
  * Treat it as a hypothesis to validate with your own evaluations before
24
56
  * moving down a tier in production.
25
57
  */
26
- export function recommendTier(prompt: string, tokens: number): ModelPricing['tier'] {
58
+ export function recommendTierDetailed(prompt: string, tokens: number): TierRecommendation {
27
59
  const haystack = prompt.toLowerCase();
28
- let score =
29
- countSignals(haystack, COMPLEX_SIGNALS) * 2 - countSignals(haystack, SIMPLE_SIGNALS) * 2;
60
+ const complexSignals = countSignals(haystack, COMPLEX_SIGNALS);
61
+ const simpleSignals = countSignals(haystack, SIMPLE_SIGNALS);
62
+ let score = complexSignals * 2 - simpleSignals * 2;
30
63
 
31
64
  if (tokens > 4000) score += 2;
32
65
  else if (tokens > 1500) score += 1;
@@ -34,9 +67,25 @@ export function recommendTier(prompt: string, tokens: number): ModelPricing['tie
34
67
 
35
68
  if (/```|~~~/.test(prompt)) score += 1;
36
69
 
37
- if (score >= 3) return 'opus';
38
- if (score >= 0) return 'sonnet';
39
- return 'haiku';
70
+ const tier = score >= 3 ? 'opus' : score >= 0 ? 'sonnet' : 'haiku';
71
+ return {
72
+ tier,
73
+ complexSignals,
74
+ simpleSignals,
75
+ conflicted:
76
+ complexSignals > 0 && simpleSignals > 0 && Math.abs(complexSignals - simpleSignals) <= 1,
77
+ };
78
+ }
79
+
80
+ /**
81
+ * The tier alone, unchanged.
82
+ *
83
+ * Kept because it is public API and the 1.x line does not change shapes.
84
+ * Callers that need to know whether the answer is trustworthy reach for
85
+ * `recommendTierDetailed`.
86
+ */
87
+ export function recommendTier(prompt: string, tokens: number): ModelPricing['tier'] {
88
+ return recommendTierDetailed(prompt, tokens).tier;
40
89
  }
41
90
 
42
91
  const TIER_ORDER: Record<ModelPricing['tier'], number> = {
@@ -81,9 +130,10 @@ function cheapestInTier(
81
130
  (m) =>
82
131
  m.tier === tier &&
83
132
  m.provider === provider &&
84
- // Not generally available: recommending a model the reader cannot call is
85
- // worse than recommending nothing.
86
- m.recommendable !== false,
133
+ // Not generally available, or refused outright by the provider:
134
+ // recommending a model the reader cannot call is worse than
135
+ // recommending nothing. Both halves live in `isOffered`.
136
+ isOffered(m),
87
137
  );
88
138
  if (candidates.length === 0) return undefined;
89
139
  return candidates.reduce((best, m) =>
@@ -142,7 +192,18 @@ export function buildAdvisories(
142
192
  * outright rather than degrading, so there is no partial result to notice.
143
193
  */
144
194
  const estimated = count === estimateTokens;
145
- const band = ESTIMATE_ERROR_BAND_PCT / 100;
195
+ /**
196
+ * The band of **this prompt**, not one number for all text.
197
+ *
198
+ * These three hedges — the context window, the cache minimum both ways — are
199
+ * the places an estimate is compared against a hard threshold and the answer
200
+ * stated as fact, so the width of the hedge decides whether the statement is
201
+ * safe. A single 10% was measured on a corpus of thirteen prose files and one
202
+ * each of code, numeric and punctuation, and the estimator is 25% out on a
203
+ * SQL migration and 33% out on a CSV ledger. Hedging a code prompt by ten
204
+ * points was hedging by a third of what it needed.
205
+ */
206
+ const band = bandFor(optimizedPrompt) / 100;
146
207
 
147
208
  /**
148
209
  * Whether that band describes **this** model's tokenizer.
@@ -254,15 +315,36 @@ export function buildAdvisories(
254
315
  */
255
316
  nearMinimum:
256
317
  count === estimateTokens &&
257
- cache.stablePrefixTokens * (1 - ESTIMATE_ERROR_BAND_PCT / 100) < minTokens,
318
+ cache.stablePrefixTokens * (1 - band) < minTokens,
258
319
  }),
259
320
  estimatedMonthlyUsd: saving,
260
321
  });
261
322
  } else {
323
+ /**
324
+ * The threshold is derived from this model's own multipliers, not
325
+ * quoted.
326
+ *
327
+ * Break-even is where a cached token costs exactly what an uncached one
328
+ * does: `h*read + (1-h)*write = 1`, so `h = (1 - write) / (read -
329
+ * write)`. At Anthropic's 1.25 and 0.1 that is 21.74%, and the sentence
330
+ * this replaces said 28% for every model in the catalogue.
331
+ *
332
+ * A write multiplier of 1 has no such threshold. Writing then costs
333
+ * exactly what not caching costs, so caching cannot lose money at any
334
+ * hit rate, and `(1 - 1) / (r - 1)` is 0 rather than a small number.
335
+ * Eight of the eighteen models are that shape, and the fixed sentence
336
+ * advised all eight to consider turning caching off.
337
+ */
338
+ const write = rates.cacheWrite5m;
262
339
  advisories.push({
263
340
  id: 'prompt-caching-not-worth-it',
264
341
  severity: 'info',
265
- ...t.advisories.promptCachingNotWorthIt(),
342
+ ...t.advisories.promptCachingNotWorthIt({
343
+ readPct: Math.round(rates.cacheRead * 100),
344
+ writePct: Math.round(write * 100),
345
+ breakEvenPct:
346
+ write <= 1 ? null : Math.round(((1 - write) / (rates.cacheRead - write)) * 1000) / 10,
347
+ }),
266
348
  estimatedMonthlyUsd: null,
267
349
  });
268
350
  }
@@ -287,7 +369,7 @@ export function buildAdvisories(
287
369
  */
288
370
  couldReachMinimum:
289
371
  count === estimateTokens &&
290
- cache.stablePrefixTokens * (1 + ESTIMATE_ERROR_BAND_PCT / 100) >= minTokens,
372
+ cache.stablePrefixTokens * (1 + band) >= minTokens,
291
373
  }),
292
374
  estimatedMonthlyUsd: null,
293
375
  });
@@ -374,7 +456,34 @@ export function buildAdvisories(
374
456
  }
375
457
 
376
458
  // --- Recommended model ---
377
- const suggestedTier = recommendTier(optimizedPrompt, tokensAfter);
459
+ const recommendation = recommendTierDetailed(optimizedPrompt, tokensAfter);
460
+ const suggestedTier = recommendation.tier;
461
+
462
+ /**
463
+ * When the prompt argues with itself about which tier it needs, say so and
464
+ * recommend nothing.
465
+ *
466
+ * The score subtracts one side from the other, so a prompt demanding depth
467
+ * and brevity in equal measure cancels to zero and lands on `sonnet` — the
468
+ * identical answer to a prompt with no signals at all. Reporting two opposite
469
+ * situations with one number is the failure; the fix is not a better score,
470
+ * it is declining to pretend there is one.
471
+ *
472
+ * This returns before the downgrade block rather than alongside it, because a
473
+ * saving computed from a tier the heuristic cannot stand behind is a figure
474
+ * with a dollar sign on it and nothing underneath.
475
+ */
476
+ if (recommendation.conflicted) {
477
+ advisories.push({
478
+ id: 'tier-signals-conflict',
479
+ severity: 'info',
480
+ ...t.advisories.tierSignalsConflict({
481
+ complexSignals: recommendation.complexSignals,
482
+ simpleSignals: recommendation.simpleSignals,
483
+ }),
484
+ estimatedMonthlyUsd: null,
485
+ });
486
+ }
378
487
  /**
379
488
  * A model whose capability nobody recorded is never told it is overpowered.
380
489
  *
@@ -392,15 +501,30 @@ export function buildAdvisories(
392
501
  * and a condition that forgets it both have to happen before a model of
393
502
  * unrecorded capability is told it is overpowered.
394
503
  */
395
- if (model.tier !== 'unknown' && TIER_ORDER[suggestedTier] < TIER_ORDER[model.tier]) {
504
+ if (!recommendation.conflicted && model.tier !== 'unknown' && TIER_ORDER[suggestedTier] < TIER_ORDER[model.tier]) {
396
505
  const candidate = cheapestInTier(suggestedTier, on, pricing, model.provider);
397
506
  if (candidate) {
398
507
  const candidatePricing = effectivePricing(candidate, on);
508
+ /**
509
+ * The candidate's own batch multiplier, not a constant.
510
+ *
511
+ * This line read `usage.batchEligible ? 0.5 : 1`, while the current
512
+ * model's cost twenty lines above already used `rates.batch ?? 1` for
513
+ * exactly the reason stated there: a discount that is not per model is an
514
+ * invented saving rather than an imprecise one.
515
+ *
516
+ * Three models in the catalogue carry `batch: null` — kimi-k2,
517
+ * deepseek-v3 and grok-4 — which means no batch API at all. Halving their
518
+ * cost offered money that cannot be bought at any price, made the
519
+ * downgrade look twice as good as it is, and could turn a saving that was
520
+ * negative into one this advisory reports.
521
+ */
522
+ const candidateBatch = usage.batchEligible ? (multipliersFor(candidate).batch ?? 1) : 1;
399
523
  const candidateMonthly =
400
524
  ((tokensAfter / 1_000_000) * candidatePricing.inputPerMTok +
401
525
  (usage.avgOutputTokens / 1_000_000) * candidatePricing.outputPerMTok) *
402
526
  usage.callsPerMonth *
403
- (usage.batchEligible ? 0.5 : 1);
527
+ candidateBatch;
404
528
  const saving = monthlyInputUsd + monthlyOutputUsd - candidateMonthly;
405
529
  if (saving > 0) {
406
530
  advisories.push({
@@ -522,6 +646,29 @@ export function buildAdvisories(
522
646
  });
523
647
  }
524
648
 
649
+ if (model.retired) {
650
+ /**
651
+ * The most severe thing this function can say about a model, and it is
652
+ * placed before the promotional note deliberately: a price that is about to
653
+ * change matters less than a model that cannot be called at all.
654
+ *
655
+ * Not suppressed when the reader asked for this model by name. They asked
656
+ * because something in their world still says this id, and the whole value
657
+ * of the answer is that it no longer works.
658
+ */
659
+ advisories.push({
660
+ id: 'model-retired',
661
+ severity: 'warning',
662
+ ...t.advisories.modelRetired({
663
+ modelName: model.displayName,
664
+ modelId: model.id,
665
+ on: model.retired.on,
666
+ because: model.retired.because,
667
+ }),
668
+ estimatedMonthlyUsd: null,
669
+ });
670
+ }
671
+
525
672
  if (promoApplied && model.promo) {
526
673
  advisories.push({
527
674
  id: 'promo-pricing',
package/src/band.ts ADDED
@@ -0,0 +1,213 @@
1
+ /**
2
+ * The error band Trazum is entitled to print about a given piece of text.
3
+ *
4
+ * ## Why this exists
5
+ *
6
+ * For a long time every report said `±10%`, everywhere, about everything. That
7
+ * number came from a corpus of twenty-one samples that held **thirteen files of
8
+ * Latin prose and exactly one each of code, numeric and punctuation** — and
9
+ * those single files were the set the estimator's constants had been fitted to.
10
+ * So the published band was not a measurement of the estimator. It was a
11
+ * measurement of its own training set.
12
+ *
13
+ * Seven ordinary samples in the thin classes broke it. Measured against
14
+ * Anthropic's counting endpoint, the same estimator that is 4.6% out on prose
15
+ * is **24.9% out on a SQL migration** and **32.5% out on a CSV ledger**. Telling
16
+ * somebody `±10%` about their ledger was telling them a number that is wrong
17
+ * about their prompt specifically.
18
+ *
19
+ * ## Why it does not name a text type
20
+ *
21
+ * The obvious design is a classifier — prose, code, few-shot, punctuation — and
22
+ * a band per class. The corpus says it cannot be built. Measured by character
23
+ * mix, code and punctuation **overlap completely**: `code-sql` is 7.7% symbols
24
+ * and `code-heavy` 15.6%, while `punctuation-markup` is 17.5% and
25
+ * `punctuation-heavy` 29.7%. Two of the three few-shot samples are
26
+ * indistinguishable from prose. A classifier over those would be a guess
27
+ * wearing a measurement's name, and it would hand out the wrong band precisely
28
+ * where the band matters most.
29
+ *
30
+ * So this does not classify. It sorts text into the **buckets the evidence
31
+ * actually separates**, and where two classes overlap it gives the worse of
32
+ * their bands. An overstated uncertainty costs a reader some confidence; an
33
+ * understated one tells them a wrong number and lets them act on it.
34
+ *
35
+ * ## What holds it
36
+ *
37
+ * `token-band.test.js` runs every corpus sample through `bandFor` and requires
38
+ * the sample's own measured error to be inside the band it was given. That is
39
+ * the whole promise, and it cannot be satisfied by tuning the thresholds: a
40
+ * sample sorted into a friendlier bucket gets a smaller band and fails.
41
+ *
42
+ * The figures below are derived from the fixture by the same test, which fails
43
+ * when they drift. They are written here rather than imported because this file
44
+ * ships and the fixture does not.
45
+ */
46
+
47
+ /**
48
+ * The buckets, worst measured error first.
49
+ *
50
+ * Every number is `Math.ceil` of the worst sample in that bucket, from
51
+ * `test/fixtures/token-ground-truth.json` — Anthropic's, because that is the
52
+ * tokenizer this estimator was built for and the only one whose fixture governs
53
+ * a published claim. A DeepSeek or Mistral prompt is further out still, and
54
+ * `providerBandCaveat` is what says so.
55
+ */
56
+ export const BANDS = {
57
+ /** Digit-dominant: ledgers, tables, exports. The worst the estimator does. */
58
+ numeric: 33,
59
+ /** Symbol-rich: code, markup, anything a merge table covers badly. */
60
+ symbolic: 26,
61
+ /**
62
+ * CJK, all three scripts.
63
+ *
64
+ * Was 10 and is 4, and the difference is the second Korean sample rather than
65
+ * anything in this file. Hangul had been charged a placeholder that nothing
66
+ * measured; one sample moved it to within ten points and two moved it to
67
+ * within three. The class's worst went from 10.6% to 3.2% and its standard
68
+ * deviation from 4.0 to 1.0.
69
+ */
70
+ cjk: 4,
71
+ /**
72
+ * Latin prose and few-shot blocks made of it. What most prompts are.
73
+ *
74
+ * Widened from 5 by `numeric-matrix`, a confusion matrix whose class labels
75
+ * make letters the dominant class: composition put it here, its error came
76
+ * out at 5.6%, and the band follows the measurement rather than the other way
77
+ * round. Eighteen samples, deviation 1.4 points.
78
+ */
79
+ prose: 6,
80
+ } as const;
81
+
82
+ export type BandBucket = keyof typeof BANDS;
83
+
84
+ /**
85
+ * Where the thresholds come from.
86
+ *
87
+ * Each is the widest gap the corpus offers between the bucket it admits and
88
+ * everything below it, so none of them is a round number somebody liked:
89
+ *
90
+ * - `cjk` at 0.5: the CJK samples are 90.1% to 94.5% CJK and every other sample
91
+ * is 0.0%. Nothing in the corpus lies between.
92
+ * - `numeric` at 0.30: the two numeric samples are 47.7% and 49.7% digits; the
93
+ * next highest anywhere is `few-shot-extraction` at 15.3%.
94
+ * - `symbolic` at 0.07: this one is thin and is stated as thin. `code-sql` is
95
+ * 7.7% symbols and `portuguese-prose` is 6.1%, so 1.6 points of margin decide
96
+ * it. Thin in the safe direction: a prose sample that crosses it is given the
97
+ * symbolic band, which is wider than it needs, and no sample is given a band
98
+ * narrower than its own error.
99
+ */
100
+ const CJK_SHARE = 0.5;
101
+ const DIGIT_SHARE = 0.3;
102
+ const SYMBOL_SHARE = 0.07;
103
+
104
+ /*
105
+ Written as escapes rather than as the characters themselves, and CodeQL is
106
+ why. The last range was typed as a pair of literal ideographs meaning
107
+ "compatibility ideographs, U+F900 to U+FAFF" — and the opening character was
108
+ U+8C48, the ordinary unified ideograph that shares the glyph, not the
109
+ compatibility one. So the range ran from U+8C48 to U+FAFF and swallowed the
110
+ Yi syllables, the private-use area and both surrogate halves, which meant a
111
+ page of astral emoji was sorted `cjk` and handed the narrowest band in the
112
+ file: ±4% about text nothing has ever measured.
113
+
114
+ A literal that looks right and is not is the failure mode this release exists
115
+ to fix, so the ranges are numbers now and the test below plants one character
116
+ from each block that used to be caught.
117
+ */
118
+ const CJK = /[\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uac00-\ud7af\uf900-\ufaff]/;
119
+ const LETTER = /[A-Za-zÀ-ɏͰ-ϿЀ-ӿ]/;
120
+ const DIGIT = /[0-9]/;
121
+
122
+ /** The bucket a piece of text falls in, by the composition the corpus separates on. */
123
+ export function bucketFor(text: string): BandBucket {
124
+ const chars = [...text].filter((ch) => !/\s/.test(ch));
125
+ if (chars.length === 0) return 'prose';
126
+
127
+ let cjk = 0;
128
+ let digits = 0;
129
+ let letters = 0;
130
+ for (const ch of chars) {
131
+ if (CJK.test(ch)) cjk += 1;
132
+ else if (DIGIT.test(ch)) digits += 1;
133
+ else if (LETTER.test(ch)) letters += 1;
134
+ }
135
+ const n = chars.length;
136
+ const symbols = n - cjk - digits - letters;
137
+
138
+ /*
139
+ Order is the design. CJK first because it is the one bucket with no overlap
140
+ at all, then digits, then symbols — each test only runs on text the previous
141
+ one declined, so a Korean prompt with quotation marks is CJK and not
142
+ symbolic. Reversing any two would let a wide bucket swallow a narrow one and
143
+ hand out a band nobody measured.
144
+ */
145
+ if (cjk / n >= CJK_SHARE) return 'cjk';
146
+ if (digits / n >= DIGIT_SHARE) return 'numeric';
147
+ if (symbols / n >= SYMBOL_SHARE) return 'symbolic';
148
+ return 'prose';
149
+ }
150
+
151
+ /**
152
+ * The band, as a percentage, that may be printed about this text.
153
+ *
154
+ * Never a single number for all text again. A caller that wants one figure for
155
+ * a mixed report should take the widest band across the texts it covers, not an
156
+ * average: averaging two uncertainties produces a third that describes neither.
157
+ */
158
+ export function bandFor(text: string): number {
159
+ return BANDS[bucketFor(text)];
160
+ }
161
+
162
+ /**
163
+ * Whether the published band applies to a model at all.
164
+ *
165
+ * It does not, and this is the sentence that has been missing. The estimator is
166
+ * calibrated on Claude's tokenizer, and the same 47 samples measured against
167
+ * DeepSeek's own counter came out **94.5% wrong** at worst and against
168
+ * Mistral's **103.1%**. Trazum prices seven providers; the band is one
169
+ * provider's.
170
+ *
171
+ * Returns null when the model is one the band was measured on, and the provider
172
+ * id otherwise, so a caller can say which foreign tokenizer it is estimating
173
+ * against rather than printing a figure that belongs to a different one.
174
+ */
175
+ export function foreignTokenizer(provider: string | null): string | null {
176
+ return provider === null || provider === 'anthropic' ? null : provider;
177
+ }
178
+
179
+ /**
180
+ * The worst error measured against a provider's own tokenizer, where anybody
181
+ * has run it.
182
+ *
183
+ * Two entries, and the emptiness of the rest is the point. `foreignTokenizer`
184
+ * could always say *this is not Claude*; what it could not say is how far off.
185
+ * A hedge with no figure reads as a formality, and a reader discounts it — so
186
+ * where the corpus has been run against the family's own counter, the report
187
+ * says the number, and where it has not, it says nobody has measured rather
188
+ * than borrowing one of these.
189
+ *
190
+ * Every value is the worst of the 47 samples in
191
+ * `test/fixtures/token-ground-truth.<provider>.json`, and
192
+ * `token-band.test.js` recomputes both from the fixtures and fails on drift.
193
+ * Written here rather than imported because this file ships and the fixtures
194
+ * do not.
195
+ */
196
+ export const MEASURED_FOREIGN_ERROR_PCT: Readonly<Record<string, number>> = {
197
+ /** Worst on `cjk-chinese`: the estimator's CJK charge is Claude's, not this one's. */
198
+ deepseek: 94.5,
199
+ /** Worst on `german-technical`, where even Latin prose is more than twice out. */
200
+ mistral: 103.1,
201
+ };
202
+
203
+ /**
204
+ * How far out the estimator has been measured against this provider, or null.
205
+ *
206
+ * Null means unmeasured, never zero: a family nobody has run is an open
207
+ * question, and answering it with a number would be the exact fault this whole
208
+ * file exists to undo.
209
+ */
210
+ export function measuredForeignError(provider: string | null): number | null {
211
+ if (provider === null) return null;
212
+ return MEASURED_FOREIGN_ERROR_PCT[provider] ?? null;
213
+ }
package/src/conform.ts CHANGED
@@ -54,6 +54,11 @@ export const CONTRACT_NAMES = [
54
54
  'bench',
55
55
  'prompt-draft',
56
56
  'position',
57
+ 'routing-measurement',
58
+ 'example-pruning',
59
+ 'store-inventory',
60
+ 'conformance',
61
+ 'watch-cycle',
57
62
  ] as const;
58
63
 
59
64
  export type ContractName = (typeof CONTRACT_NAMES)[number];
@@ -394,6 +399,62 @@ const DOCUMENT_RULES: Record<Exclude<ContractName, 'usage-log'>, FieldRule[]> =
394
399
  rule('cannotSay', 'an array of sentences — what is deliberately not answered', isArray),
395
400
  rule('unpricedRecords', 'a count of records the catalogue cannot price', isNumber),
396
401
  ],
402
+ 'routing-measurement': [
403
+ rule('slice', 'the workload object — the calls this measured', isObject),
404
+ rule('evaluation', 'the measurement object — verdict and agreement rates', isObject),
405
+ ],
406
+ 'store-inventory': [
407
+ rule('providers', 'an array — one entry per provider held', isArray),
408
+ rule('totalRecords', 'records the store resolves to', isNumber),
409
+ rule(
410
+ 'span',
411
+ 'an object, or null when the store holds nothing',
412
+ (v) => v === null || isObject(v),
413
+ ),
414
+ rule('possiblyDouble', 'records the store could not tell apart', isNumber),
415
+ rule('unknownVersion', 'records written by a newer schema', isNumber),
416
+ rule('totalUsd', 'what the held records priced to', isNumber),
417
+ rule('unreadable', 'an array — files the store could not parse', isArray),
418
+ ],
419
+ conformance: [
420
+ rule(
421
+ 'contract',
422
+ 'the contract the input looked like, or null when nothing did',
423
+ (v) => v === null || typeof v === 'string',
424
+ ),
425
+ rule(
426
+ 'because',
427
+ 'why nothing matched, or null when something did — never a bare refusal',
428
+ (v) => v === null || typeof v === 'string',
429
+ ),
430
+ rule(
431
+ 'records',
432
+ 'records examined for a log, or null for a single document',
433
+ (v) => v === null || isNumber(v),
434
+ ),
435
+ rule('problems', 'an array — one entry per fault found', isArray),
436
+ rule('unavailable', 'an array — findings this shape cannot support', isArray),
437
+ rule('conforms', 'a boolean — the verdict', (v) => typeof v === 'boolean'),
438
+ ],
439
+ 'watch-cycle': [
440
+ rule('firedAtMs', 'the instant the cycle ran', isNumber),
441
+ rule('crossings', 'an array — gates over their limit, news this cycle', isArray),
442
+ rule('suppressed', 'an array — still over, already reported', isArray),
443
+ rule('abstentions', 'an array — gates that could not be judged', isArray),
444
+ rule(
445
+ 'gap',
446
+ 'an object, or null when this cycle watched the whole stretch',
447
+ (v) => v === null || isObject(v),
448
+ ),
449
+ ],
450
+ 'example-pruning': [
451
+ rule('provider', 'the provider the calls went to', (v) => typeof v === 'string'),
452
+ rule('model', 'the model that answered', (v) => typeof v === 'string'),
453
+ rule('selfAgreement', 'the model’s agreement with itself, the yardstick every removal is judged against', isNumber),
454
+ rule('recoverableTokens', 'tokens held by examples whose removal changed nothing measurable', isNumber),
455
+ rule('callsMade', 'calls actually spent', isNumber),
456
+ rule('contributions', 'an array — one entry per example', isArray),
457
+ ],
397
458
  pulse: [
398
459
  rule('beats', 'an array — one per kind', isArray),
399
460
  rule('nowMs', 'the instant the ages were taken against', isNumber),
@@ -554,6 +615,44 @@ function contractOf(doc: Record<string, unknown>): Exclude<ContractName, 'usage-
554
615
  if (Array.isArray(doc.actions) && typeof doc.arrived === 'number') return 'verification';
555
616
  if (Array.isArray(doc.actions)) return 'plan';
556
617
  if (typeof doc.verdict === 'string' && 'restsOn' in doc) return 'cost-answer';
618
+ /*
619
+ Two the inference could never reach, found by the guard that demands every
620
+ contract be recognisable from a document of its own shape.
621
+
622
+ The cost answer's branch above tested `restsOn`, which its contract does
623
+ not require: the required set is verdict, call and budget, so a conforming
624
+ answer that omitted the optional field was recognised as nothing at all.
625
+ The prompt draft had no branch of any kind — its schema has been published
626
+ since 1.68 and `trazum conform draft.json` has never known what it was.
627
+ */
628
+ if (typeof doc.verdict === 'string' && 'call' in doc && 'budget' in doc) return 'cost-answer';
629
+ if (typeof doc.prompt === 'string' && Array.isArray(doc.sections) && Array.isArray(doc.missing)) {
630
+ return 'prompt-draft';
631
+ }
632
+ /*
633
+ The five the JSON sweep contracted, and the reason they are here.
634
+
635
+ All five arrived with rules, a schema, a row in the interchange table and
636
+ a parity test, and none of them arrived with a branch in this function —
637
+ so `trazum conform verdict.json` read a real routing measurement as a
638
+ usage log and answered with advice about `stop_reason`. A contract that
639
+ cannot be recognised is a contract only reachable by somebody who already
640
+ knows its name, which is the opposite of what this page is for. Found by
641
+ running the command against its own output; `contract-coverage.test.js`
642
+ now requires every name to be inferable from a document.
643
+
644
+ Conformance sits before the connected report deliberately: both carry an
645
+ `unavailable` array, and only the conformance verdict carries `conforms`.
646
+ */
647
+ if (Array.isArray(doc.problems) && Array.isArray(doc.unavailable) && typeof doc.conforms === 'boolean') {
648
+ return 'conformance';
649
+ }
650
+ if (isObject(doc.evaluation) && 'slice' in doc) return 'routing-measurement';
651
+ if (Array.isArray(doc.contributions) && typeof doc.recoverableTokens === 'number') {
652
+ return 'example-pruning';
653
+ }
654
+ if (Array.isArray(doc.providers) && typeof doc.totalRecords === 'number') return 'store-inventory';
655
+ if (Array.isArray(doc.crossings) && Array.isArray(doc.abstentions)) return 'watch-cycle';
557
656
  if (typeof doc.provider === 'string' && Array.isArray(doc.unavailable)) return 'connected';
558
657
  return null;
559
658
  }
@@ -650,7 +749,7 @@ export function conform(text: string, options: ConformOptions = {}): Conformance
650
749
  schemaVersion: 1,
651
750
  contract: null,
652
751
  because:
653
- 'no contract recognised it — a roll-up has contributors and notMerged, a profile has byLabelAndModel, a plan has actions, a verification adds arrived, a history has periods and runs, a connected report has provider and unavailable, a cost answer has verdict and restsOn',
752
+ 'no contract recognised it — a roll-up has contributors and notMerged, a profile has byLabelAndModel, a plan has actions, a verification adds arrived, a history has periods and runs, a connected report has provider and unavailable, a cost answer has verdict with call and budget, a prompt draft has prompt, sections and missing, a routing measurement has slice and evaluation, an example pruning has contributions and recoverableTokens, a store inventory has providers and totalRecords, a conformance verdict has problems and conforms, a watch cycle has crossings and abstentions',
654
753
  problems: [],
655
754
  unavailable: [],
656
755
  records: null,