UNPKG

kestrel.markets

Version:

A typed, token-efficient language + runtime for agentic trading: agents author bounded plans, the runtime fires them at the tick. CLI + typed library + MCP server.

381 lines (349 loc) 20.5 kB
/** * # render/tokens — the per-model token-count ORACLE (kestrel-4gl.11) * * A Rendering has two parameters: a **format** and the **tokenizer its token costs are * measured under** (CONTEXT.md / {@link ./index.ts}) — "which glyphs are cheap is an * empirical, per-model fact." This module IS that tokenizer: it measures how many BPE * tokens a rendered string costs a given model. It is NOT `src/tooling/highlight.ts` * `tokenize` — that is a source HIGHLIGHTER (what colour is this lexeme); this answers a * different question (how many tokens does this string cost). * * Three backends, and every result is LABELLED with the {@link CountMethod} that produced it, * so a consumer always knows whether it holds a precise BPE count, a provider-authoritative * native count, or a documented estimate — never a silently stubbed, fake-precise number: * - `tiktoken-cl100k` — precise BPE via the OPTIONAL, LAZY `js-tiktoken` backend (a frozen * merge table; a pure function of the input bytes). * - `anthropic-count-tokens` — the CLOSED Claude/Fable tokenizer (`fable-native`), measured via * the Anthropic `POST /v1/messages/count_tokens` endpoint with a base+fragment DIFFERENCING * protocol, served OFFLINE from a committed `sha256(bytes) → input_tokens` cache fixture. A * cache miss FAILS CLOSED (see {@link NativeTokenCacheMissError}) — it NEVER hits the network * at test/CI time, so it stays as deterministic as the open backends. Only an explicit * maintainer refresh (networked, needs `ANTHROPIC_API_KEY`) writes the fixture. * - `chars-approx` — the dependency-free `ceil(len / CHARS_PER_TOKEN)` fallback, always * available. It is deliberately allowed to diverge from the true BPE count; the label is * exactly what tells a consumer not to trust it as precise. * * FOUR STANDING CONSTRAINTS (this module is a CI-testable contract for each): * - DETERMINISTIC: same text + same tokenizer id ⇒ byte-identical {@link TokenCount}. No * wall clock, no RNG, NO NETWORK on the read path — `encode()`, `ceil(len/N)`, and the native * cache lookup are all pure functions of committed bytes. * - NEVER SILENTLY STUBBED: if `js-tiktoken` is present the oracle MUST use it (no silent * downgrade); if absent it reports `chars-approx` and logs ONCE that the precise backend * is unavailable (visible, not silent). The native backend NEVER falls back to an estimate on * a miss — it throws {@link NativeTokenCacheMissError} rather than emit a fake-precise number. * - OFF THE RUNTIME/RECORD PATH: this is a MEASUREMENT tool for renderer/benchmark cost. A * closed model's token count is an external estimate, scored into `docs/results`, never * stamped onto the deterministic Blotter/Grade or the bus. It is imported by benchmark / * renderer code, never by the engine, session, bus, or any canonical receipt. * - ISOLATED FROM LIBRARY EXPORTS: a leaf module. `src/render/index.ts` homes the lightweight * render tokenizer contract (a pure `count(text): number` budget type) but NOT this oracle; * `src/index.ts` / `src/protocol` / `src/lang` never reach THIS module. `js-tiktoken`, * `node:fs`, and `node:crypto` are reached ONLY through lazy dynamic imports here, so the * Bun/Node/Workers library surface (dist/index.js) stays dependency-free and Workers-safe. */ /** * Tokenizers the oracle can measure a Rendering's cost under. Extensible: add an id here and * a backend to {@link REGISTRY}. Two open tokenizers today: `cl100k_base` (GPT-3.5/4, older * GPT family) and `o200k_base` (GPT-4o / o-series / Codex family — the tokenizer the `codex` * agent's model reads under). Open-weight tokenizers are one registry entry each. A CLOSED * model registers one of two labelled backends: a fallback-only `chars-approx` backend when * nothing better exists (Gemini's SentencePiece), OR — when the provider ships an authoritative * count endpoint — a NATIVE backend serving precise counts from a committed offline cache. * `fable-native` is the second case: Claude/Fable's proprietary BPE, measured via the Anthropic * count_tokens endpoint (model {@link NATIVE_MODEL}). Never a fake-precise count. */ export type TokenizerId = "cl100k_base" | "o200k_base" | "fable-native"; /** * The honest label of HOW a count was produced — this is the anti-fake-precise guarantee. A * consumer reads `method` to know whether it holds a precise BPE count (`tiktoken-cl100k`), a * provider-authoritative count read from the committed native cache (`anthropic-count-tokens`), or * a documented approximation (`chars-approx`). Never invent an unlabelled path. */ export type CountMethod = | "tiktoken-cl100k" | "tiktoken-o200k" | "anthropic-count-tokens" | "chars-approx"; /** The model whose tokenizer `fable-native` measures — the `model` on every count_tokens request * AND the label stamped (with {@link NATIVE_METHOD} + `measured_at`) into every emitted artifact * (ADR-0043 declared-tokenizer). */ export const NATIVE_MODEL = "claude-fable-5"; /** The {@link CountMethod} label for the native backend: the count came from the Anthropic * count_tokens endpoint, not a locally reproducible BPE table. */ export const NATIVE_METHOD = "anthropic-count-tokens" as const; /** The Anthropic token-count endpoint the native cache is measured against (stamped in artifacts). */ export const NATIVE_ENDPOINT = "/v1/messages/count_tokens"; /** * The FIXED, COMMITTED base message the native DIFFERENCING protocol subtracts. A count_tokens * response includes per-message/wrapper overhead; measuring `count(base + fragment) − count(base)` * cancels that overhead and isolates the fragment's true native cost. Editing this string * invalidates EVERY cached differenced count — the cache stores `base_sha256` so a drift is caught * at load ({@link NativeTokenCacheBaseDriftError}), never silently mis-differenced. Generic * wording — carries no application/strategy (ARCHITECTURE §7). */ export const NATIVE_DIFF_BASE = "kestrel native-token differencing base: a fixed committed anchor message. " + "Do not edit — editing invalidates every cached differenced count."; /** The maintainer command that (networked, with ANTHROPIC_API_KEY) measures + rewrites the cache. * Named verbatim in the fail-closed miss error so the repair path is discoverable from the error. */ export const NATIVE_REFRESH_COMMAND = "bun scripts/percept-lab/refresh-native-cache.ts --refresh-native-cache"; /** * A measured token cost of a Rendering under one tokenizer. Lives OFF the record path: a * closed model's count is an external estimate, never a canonical fact on the Blotter/Grade. */ export interface TokenCount { readonly count: number; /** Precise BPE vs documented approximation — the count's provenance, always present. */ readonly method: CountMethod; readonly tokenizer: TokenizerId; } /** * A resolved counter: pure + sync + deterministic once its backend is loaded. The async * dynamic-import cost is paid once by {@link loadTokenizer}; then `count()` is a pure function, * which is exactly what a tournament that counts many Renderings under one tokenizer needs. * * NAMED distinctly from the render barrel's `Tokenizer` ({@link ./index.ts}) on purpose (kestrel-2fab): * this ORACLE returns a LABELLED {@link TokenCount} (precise BPE vs documented estimate), a richer * contract than the render path's lightweight budget tokenizer (`count(text): number`). One name per * contract so neither can silently drift into the other. */ export interface TokenCounter { readonly id: TokenizerId; readonly method: CountMethod; count(text: string): TokenCount; } /** * Documented approximation ratio: ~4 chars/token for English (OpenAI's own rule of thumb). * The `chars-approx` fallback is `ceil(text.length / CHARS_PER_TOKEN)`. */ export const CHARS_PER_TOKEN = 4; /** * How a {@link TokenizerId} maps to a backend — a discriminated union, so a closed model can * register a `native` backend (count_tokens + committed cache) alongside the open `tiktoken` * backends WITHOUT any fake-precise or null-encoding shim. Extending the oracle = adding a row. */ type Backend = | { readonly kind: "tiktoken"; readonly encoding: string; readonly method: "tiktoken-cl100k" | "tiktoken-o200k" } | { readonly kind: "native"; readonly model: string; readonly method: typeof NATIVE_METHOD }; /** The registry. One entry per known tokenizer; extending the oracle = adding a row. */ const REGISTRY: Readonly<Record<TokenizerId, Backend>> = { cl100k_base: { kind: "tiktoken", encoding: "cl100k_base", method: "tiktoken-cl100k" }, o200k_base: { kind: "tiktoken", encoding: "o200k_base", method: "tiktoken-o200k" }, "fable-native": { kind: "native", model: NATIVE_MODEL, method: NATIVE_METHOD }, }; /** Minimal structural view of the parts of `js-tiktoken` the oracle uses. Kept local (not an * import) so this module typechecks and ships WITHOUT the optional dependency installed. The * interop union covers both the ESM named export and a CJS default-wrapped shape. */ type Encoder = { encode(text: string): readonly number[] }; type TiktokenModule = { getEncoding?: (name: string) => Encoder; default?: { getEncoding?: (name: string) => Encoder }; }; /** * The always-available, dependency-free fallback. `method` is always `chars-approx`, so it can * never masquerade as a precise BPE count; `count = ceil(len / CHARS_PER_TOKEN)` (0 for ""). */ export function approxTokenizer(id: TokenizerId): TokenCounter { return { id, method: "chars-approx", count(text: string): TokenCount { return { count: Math.ceil(text.length / CHARS_PER_TOKEN), method: "chars-approx", tokenizer: id, }; }, }; } /** Wrap a resolved js-tiktoken encoder as a precise, per-id-labelled TokenCounter (the `method` names * the EXACT BPE table — `tiktoken-cl100k` or `tiktoken-o200k` — never a generic "tiktoken"). */ function tiktokenTokenizer(id: TokenizerId, enc: Encoder, method: "tiktoken-cl100k" | "tiktoken-o200k"): TokenCounter { return { id, method, count(text: string): TokenCount { return { count: enc.encode(text).length, method, tokenizer: id, }; }, }; } /** Ids we have already logged a fallback for — so the "backend unavailable" line prints ONCE. */ const warned = new Set<TokenizerId>(); /** Memoised loads: the dynamic import + encoding build is paid once per id, then reused. */ const cache = new Map<TokenizerId, Promise<TokenCounter>>(); async function resolveTokenizer(id: TokenizerId): Promise<TokenCounter> { const backend = REGISTRY[id]; // A closed model with a provider-authoritative count endpoint: served offline from the committed // native cache. NEVER falls back to an estimate — a miss THROWS (fail-closed) rather than fake it. if (backend.kind === "native") return (await loadNativeTokenizer()).tokenizer; try { // Lazy + indirected specifier: the `: string` type stops tsc from resolving `js-tiktoken` // at build time, so the package typechecks/builds/packs with the optional dep uninstalled. const spec: string = "js-tiktoken"; const mod = (await import(spec)) as TiktokenModule; const getEncoding = mod.getEncoding ?? mod.default?.getEncoding; if (typeof getEncoding !== "function") throw new Error("js-tiktoken: getEncoding not found"); return tiktokenTokenizer(id, getEncoding(backend.encoding), backend.method); } catch { // Backend absent/unloadable: fall back to the LABELLED approximation — never a silent // downgrade, never a fake-precise count. Log once so the degrade is visible, not silent. if (!warned.has(id)) { warned.add(id); console.warn( `token oracle: precise backend 'js-tiktoken' unavailable for '${id}'; ` + `falling back to labelled 'chars-approx' (ceil(len/${CHARS_PER_TOKEN})).`, ); } return approxTokenizer(id); } } /** * Lazily resolve the counter for `id`: try the js-tiktoken backend via a dynamic import; on ANY * load failure fall back to {@link approxTokenizer}. Never throws. Memoised, so the async cost * is paid once and the returned {@link TokenCounter.count} is thereafter pure + sync. */ export function loadTokenizer(id: TokenizerId): Promise<TokenCounter> { const hit = cache.get(id); if (hit) return hit; const loading = resolveTokenizer(id); cache.set(id, loading); return loading; } /** Convenience: load the tokenizer for `id` and count `text` in one call. */ export async function countTokens(text: string, id: TokenizerId): Promise<TokenCount> { const tokenizer = await loadTokenizer(id); return tokenizer.count(text); } // ───────────────────────────────────────────────────────────────────────────── // The `fable-native` backend — Anthropic count_tokens, DIFFERENCED, served OFFLINE from a // committed cache. This is the ADR-0043 declared-tokenizer coordinate for a closed model. // ───────────────────────────────────────────────────────────────────────────── /** One measured count: the authoritative `input_tokens` the count_tokens endpoint returned for a * specific byte string, stamped with the `model` measured under and the `measured_at` timestamp * (ADR-0043 label). Keyed in {@link NativeTokenCache.entries} by `sha256(utf8(bytes))`. */ export interface NativeTokenCacheEntry { readonly model: string; readonly input_tokens: number; /** ISO-8601 instant the measurement was taken (maintainer refresh time — off the read path). */ readonly measured_at: string; } /** * The committed offline cache the native backend reads. `entries` maps `sha256(utf8(measured * string))` → its measured count; the measured strings are the DIFFERENCING pairs — the base * ({@link NATIVE_DIFF_BASE}) and every `base + fragment`. `base_sha256` pins the base so an edit to * {@link NATIVE_DIFF_BASE} is caught at load rather than silently mis-differenced. An EMPTY * `entries` object is schema-valid (the Day-0 unmeasured state): every count then fails closed with * a repair-guiding {@link NativeTokenCacheMissError}, never a fabricated number. */ export interface NativeTokenCache { readonly tokenizer: "fable-native"; readonly method: typeof NATIVE_METHOD; readonly model: string; readonly endpoint: string; readonly base_sha256: string; readonly note: string; readonly entries: Readonly<Record<string, NativeTokenCacheEntry>>; } /** * Fail-closed miss: the native backend was asked to count a byte string whose measurement is not in * the committed cache, in offline (read) mode. Sibling in spirit to a KernelHonestyError — the * oracle refuses to fabricate a precise-looking count. Carries the missing `sha` and the refresh * command so the repair path is discoverable straight from the thrown error. */ export class NativeTokenCacheMissError extends Error { readonly sha: string; constructor(sha: string, which: string) { super( `native token cache MISS: ${which} (sha256=${sha}) is not in the committed cache. ` + `The '${"fable-native"}' backend is offline-deterministic and will NOT hit the network at ` + `read/CI time. To measure and commit it, run (maintainer, needs ANTHROPIC_API_KEY): ` + `${NATIVE_REFRESH_COMMAND}`, ); this.name = "NativeTokenCacheMissError"; this.sha = sha; } } /** The committed cache's `base_sha256` disagrees with `sha256(NATIVE_DIFF_BASE)` — the base message * was edited without a re-measure, so every differenced count would be wrong. Fail closed at load. */ export class NativeTokenCacheBaseDriftError extends Error { constructor(cachedBase: string, computedBase: string) { super( `native token cache base drift: cache.base_sha256=${cachedBase} but sha256(NATIVE_DIFF_BASE)=` + `${computedBase}. NATIVE_DIFF_BASE was edited without a re-measure — every differenced count ` + `would be wrong. Re-run: ${NATIVE_REFRESH_COMMAND}`, ); this.name = "NativeTokenCacheBaseDriftError"; } } /** Is the committed cache in the Day-0 unmeasured (empty-but-schema-valid) state? Used by artifact * renderers to print a single "cache empty — run refresh" reason instead of a per-sha miss. */ export function isNativeCacheEmpty(cacheData: NativeTokenCache): boolean { return Object.keys(cacheData.entries).length === 0; } /** * Construct the native {@link TokenCounter} from a cache + a sha256 function (injected so this stays a * pure function — the caller supplies `node:crypto` off the runtime path, and tests can supply the * same hash to exercise the DIFFERENCING arithmetic without touching the network). * * `count(text)` = `entries[sha(base+text)].input_tokens − entries[base].input_tokens`. The base * subtraction cancels the count_tokens message/wrapper overhead, isolating the fragment's true * native cost. A missing entry (base or base+text) throws {@link NativeTokenCacheMissError}. */ export function nativeTokenizer(cacheData: NativeTokenCache, sha256Hex: (s: string) => string): TokenCounter { const baseKey = sha256Hex(NATIVE_DIFF_BASE); if (cacheData.base_sha256 !== baseKey) throw new NativeTokenCacheBaseDriftError(cacheData.base_sha256, baseKey); return { id: "fable-native", method: NATIVE_METHOD, count(text: string): TokenCount { const base = cacheData.entries[baseKey]; if (base === undefined) throw new NativeTokenCacheMissError(baseKey, "differencing base"); const fullKey = sha256Hex(NATIVE_DIFF_BASE + text); const full = cacheData.entries[fullKey]; if (full === undefined) throw new NativeTokenCacheMissError(fullKey, `base+fragment (${text.length} chars)`); return { count: full.input_tokens - base.input_tokens, method: NATIVE_METHOD, tokenizer: "fable-native" }; }, }; } /** The committed cache fixture path (relative to repo root). The default the loader reads. */ export const NATIVE_TOKEN_CACHE_REL_PATH = "docs/percept-lab/native-token-cache.json"; /** Lazily read `node:crypto` (off the library surface) and return a sync sha256-hex function. */ async function nativeSha256(): Promise<(s: string) => string> { const { createHash } = await import("node:crypto"); return (s: string) => createHash("sha256").update(s, "utf8").digest("hex"); } /** * Read the committed native-token cache fixture (default {@link NATIVE_TOKEN_CACHE_REL_PATH}, two * levels up from this module). Lazy `node:fs` import keeps the library surface Workers-safe. Throws * if the file is missing (the fixture must be committed) — but an EMPTY `entries` object is valid. */ export async function loadNativeTokenCache(path?: string): Promise<NativeTokenCache> { const { readFileSync } = await import("node:fs"); const target = path ?? new URL(`../../${NATIVE_TOKEN_CACHE_REL_PATH}`, import.meta.url).pathname; const raw = readFileSync(target, "utf8"); const parsed = JSON.parse(raw) as NativeTokenCache; if (parsed.tokenizer !== "fable-native" || parsed.method !== NATIVE_METHOD || typeof parsed.entries !== "object") { throw new Error(`native token cache at ${target} is not schema-valid (tokenizer/method/entries)`); } return parsed; } /** Memoised native load — the fixture read + crypto import is paid once. */ let nativeLoad: Promise<{ tokenizer: TokenCounter; cacheData: NativeTokenCache }> | null = null; /** * Resolve the native tokenizer AND the cache it reads (the cache is returned too so artifact * renderers can stamp `model`/`measured_at` and detect the empty state). Offline + deterministic. */ export function loadNativeTokenizer(path?: string): Promise<{ tokenizer: TokenCounter; cacheData: NativeTokenCache }> { if (nativeLoad !== null && path === undefined) return nativeLoad; const loading = (async () => { const [sha, cacheData] = await Promise.all([nativeSha256(), loadNativeTokenCache(path)]); return { tokenizer: nativeTokenizer(cacheData, sha), cacheData }; })(); if (path === undefined) nativeLoad = loading; return loading; }