You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Deliver a public library API for token counting across providers: tiktoken cl100k_base and o200k_base for OpenAI-family models, an offline approximation for Anthropic models, and a conservative heuristic fallback for everything else. Today skim's only tokenizer is cl100k_base, hard-wired as a pub(crate) function inside the rskim binary crate — nothing outside the CLI can count tokens, and everything inside it counts in one encoding regardless of the target model. Every Layer-3 component built after this — per-block never-inflate checks, budget truncation, savings accounting, stale-content compaction — needs to count tokens in the encoding the target model actually uses; counting in the wrong encoding produces savings numbers that are estimates dressed as measurements. This ticket supplies the counting substrate as a Wave-1 foundation with no dependencies, composing with the already tokenizer-agnostic rskim_core::truncate_to_token_budget. Network token-counting APIs (e.g., Anthropic's count-tokens endpoint) are permitted only as an explicit opt-in construction, never reachable from any default or hot path — and that opt-in network counter is delivered by this ticket (behind a non-default cargo feature; see constraint 15), not deferred to the proxy ticket.
Why now — evidence
PRISM-derived (all claims verified in the 2026-06-09 competitive study):
PRISM L3 follow-up: extract shared log-line heuristics into a reusable library #327 (35 comments, their most-discussed open issue): real sessions see only 0–15% savings because file-read outputs and aged messages — the dominant agentic token pool — go uncompressed; the maintainer concedes the gap. This pool is the justification for skim's entire Layer-3 slate, and every downstream compression/budget decision in that slate consumes token counts. Tokenization is the first brick.
PRISM #645 (independent benchmark, publishing ~2026-06-17): measured ~10.2% average savings at +0.9s/turn against PRISM's vendor-claimed fleet median of 4.8%. The lesson: savings claims that aren't grounded in real per-provider token counts don't survive independent measurement. skim's observability ticket (L3: Proxy observability — analytics schema v4, per-provider/model savings accounting #305) records only real measured raw−compressed counts — which requires this API.
What PRISM got right here: deterministic local tiktoken counting (cl100k for GPT, o200k for newer models) exposed as a library-reusable interface with lazy initialization. The study's reliability filter found PRISM's deterministic components have no bug cluster against them — every PRISM reliability failure traces to ML, scoring heuristics, or proxy state. Tokenization belongs to the safe lane. Also instructive: PRISM #552 (their unknown-model-snapshot fallback fix) shows they had to retrofit graceful handling of unknown model identifiers after shipping — a lesson this ticket adopts up front as an acceptance criterion (unknown IDs resolve to documented defaults and never error).
What PRISM got wrong here: (1) Anthropic counting that leans on a network count-tokens API places a network call near the hot path; skim's binding stance is offline approximation by default, network strictly opt-in. (2) A SentencePiece fallback and similar heavyweight dependencies feed PRISM's install-reliability bug cluster (PRISM #525: macOS Intel unsupported due to ONNX; #355: native extension missing from wheel; #408/#593: unusable on a current Python release). skim ships a single static binary across 7 targets; this ticket replaces SentencePiece with a dependency-free conservative heuristic.
skim-internal evidence (audited at commit e3dec42, 2026-06-09):
crates/rskim/src/tokens.rs: cl100k_base only (lines 11, 18); count_tokens is pub(crate) (line 22) in the binary crate, not rskim-core — not reusable as a library API. Lazy OnceLock<CoreBPE> init (lines 14–19) calls .expect(), i.e., panics on init failure. Counting goes through encode_with_special_tokens (line 24), so special-token text such as <|endoftext|> is counted as ordinary text — behavior the new API must preserve (constraint 13). A workspace-wide search found no other tokenizer (no o200k, no Anthropic tokenizer, no SentencePiece); tiktoken-rs 0.7 is the only tokenization dependency.
rskim_core::truncate_to_token_budget (lib.rs:376) already takes an injected counting closure F: Fn(&str) -> usize — core is tokenizer-agnostic by design. The composition point exists; only the supply side is missing.
Scope
In
A public library API exposing token counting for: OpenAI cl100k_base, OpenAI o200k_base (both available in the existing tiktoken-rs 0.7 workspace dependency — no new tokenizer crates), Anthropic via deterministic offline approximation, and a conservative heuristic fallback for unknown providers. All four counters must be available under default cargo features.
Closure adapters satisfying Fn(&str) -> usize so any provider's counter plugs into rskim_core::truncate_to_token_budget and the never-inflate guardrail's token comparison.
An explicitly-constructed, clearly-separated opt-in network counter for Anthropic's count-tokens endpoint, with bounded timeout and bounded retries, unreachable from any default constructor or existing code path. This deliverable is unconditionally in scope for this ticket (not contingent on L3: HTTP proxy foundation — provider routing, SSE/chunked streaming passthrough, stateless fail-open core #303); its interface contract and dependency policy are pinned in constraint 15.
Migrating crates/rskim/src/tokens.rs internals to consume the new API, with zero behavior change to existing commands (cl100k remains the CLI default).
If the crate-placement open question resolves to a new publishable crate: the release-pipeline updates that creation entails — crates.io publish order in the release workflow (rskim-core → new crate → rskim), version sync in the release-prep process, and workspace metadata — are in scope for this ticket.
Importance scoring / budget-driven message dropping — PRISM deleted ~10K LOC of it (2026-05-01 REALIGNMENT: "wrong mental model"); skim compresses in place, never drops turns. ANTI-FEATURE slate-wide.
Model pricing / model-metadata registry — deferred (nice-to-have in the study's capability matrix).
Quantitative accuracy benchmarking campaign — explicitly deferred by owner decision; PRISM #645's independent benchmark (~2026-06-17) is a calibration input for later. (A minimal directional quality bar for the Anthropic approximation is still in scope — constraint 14 — because without it the counter's defining property is untestable.)
Amends:v3: Layer 3 — LLM Message Compression Proxy (skim proxy) #101's Integration Points list assumes count_tokens() is callable from rskim-core; it is not (it is pub(crate) in the binary crate). This ticket makes that assumption true by shipping the public library API.
No network in default paths. Constructing any default counter and counting text opens zero sockets. The network counter is a distinct, explicitly-named construction behind a non-default cargo feature (constraint 15); no default constructor, CLI path, guardrail check, or truncation path can reach it. Verifiable under a network-denied test environment.
Deterministic. Identical input yields an identical count across calls, threads, processes, and platforms. No wall-clock, RNG, locale, or environment dependence in any counting path. Property test: N repeated counts of the same corpus, all equal; golden vectors checked on all CI platforms.
Bounded. Counting is a single pass, O(input length). The opt-in network path has a fixed request timeout and a fixed retry cap. No unbounded loops or retries anywhere.
Result types, no panics. Tokenizer initialization and all fallible operations return Result; no unwrap/expect outside tests. This fixes the existing get_tokenizer().expect() panic path when the code migrates.
Single binary, no config files. Vocabulary data is embedded at compile time; no runtime downloads; no .skimrc-style configuration. Provider selection happens only through API arguments.
Stateless and pure. Counters are pure &str → count functions after construction; lazy OnceLock-style one-time init is permitted (existing precedent in tokens.rs), mutable global state is not.
Encoding consistency for never-inflate. Any before/after comparison (guardrail token slow path) must use the same encoding on both sides; the API design must make mixed-encoding comparison structurally awkward (e.g., one counter instance owns one encoding and counts both sides).
Conservative fallback direction — pinned semantics. When the provider is unknown, the heuristic must not undercount. Binding test semantics: per document — for every document in the AC6 conservatism corpus, the heuristic's count must be ≥ max(cl100k count, o200k count) for that same document; corpus-aggregate compliance is not sufficient. Rationale: an overcount wastes a sliver of budget; an undercount overflows the real context window after budget truncation. The bound is binding; the formula is the planner's.
Latency budget. Binding slate-wide constraint (self-contained restatement): total added proxy latency must stay within single-digit milliseconds per turn, so counting must never dominate that budget. Concretely: counting a 100 KB input after initialization must complete in <5 ms locally (target) and <25 ms as the hard CI assertion (margin for shared runners); regressions are gated by a criterion benchmark with a tracked baseline rather than by wall-clock assertions alone.
Zero warnings; rustdoc on every public item (repo policy; design decisions documented in code).
Special-token semantics pinned. All tiktoken-backed counters (cl100k, o200k) count via encode_with_special_tokens, matching the current tokens.rs behavior (line 24): special-token strings such as <|endoftext|> are counted as ordinary text, never dropped and never an error. This choice propagates to the Anthropic approximation's basis and to both sides of any guardrail before/after comparison.
Anthropic offline approximation — minimal quality bar. Same conservatism direction as constraint 9, bound to cl100k: for every document in the AC3/AC5 golden corpus, the Anthropic offline count must be ≥ the cl100k count for the same text. Basis: Anthropic's public token-counting guidance states that tiktoken-style counts undercount Claude tokens by roughly 15–20% on typical text (more on code and non-English input), so an Anthropic estimate below cl100k is known-wrong in the unsafe direction. The approximation must apply a documented uplift over its cl100k/byte basis, and rustdoc must state the basis and its honesty limits (it is an approximation, not a measurement). Accuracy beyond this directional bar is deferred with the benchmarking campaign (see open question 3).
Network counter interface contract and dependency policy. Pinned for the planner:
Placement: lives in this ticket's library behind a non-default cargo feature; downstream crates that ever need it enable the feature explicitly (no slate ticket does today). The default build of the library and of the skim binary carries no HTTP client or TLS stack — verified by a dependency-tree check in CI.
Auth: API key read from the ANTHROPIC_API_KEY environment variable at construction (env var only — no config files per constraint 5; never logged). Missing key ⇒ construction returns Err.
Payload mapping: Anthropic's count-tokens endpoint (POST /v1/messages/count_tokens) counts a model+messages request body, not raw text. The adapter wraps the input &str as a single user message, so the returned count includes the endpoint's message-envelope overhead relative to bare text. Rustdoc must state this and that network counts are therefore not directly comparable to offline raw-text counts.
Model parameter: a required explicit argument at construction — no default model ID baked in.
Execution model: a synchronous blocking call honoring the fixed timeout and bounded retry cap of constraint 3. This library takes no tokio dependency; async adaptation is deferred until a consumer exists (no slate ticket needs it today — see Scope/Out).
Reusable skim assets
crates/rskim/src/tokens.rs — current implementation to migrate: cl100k_base via tiktoken-rs (lines 11, 18), pub(crate) count_tokens (line 22), encode_with_special_tokens call (line 24), lazy OnceLock<CoreBPE> (lines 14–19), TokenStats (line 30).
crates/rskim/tests/cli_tokens.rs — existing --show-stats integration tests are pattern-based (contains assertions), not exact golden output; the behavior-freeze acceptance criterion below requires capturing an exact golden run from current main before migration starts.
crates/rskim-core/src/lib.rs:376 — truncate_to_token_budget<F: Fn(&str) -> usize>: AST-aware, priority-based truncation with an injected counting closure; documented invariant that a budget of 0 (or smaller than the omission marker) returns an empty string rather than violating the budget.
crates/rskim/src/output/guardrail.rs — never-inflate guardrail: two-tier check (byte fast path, then token comparison); a direct consumer of counting and the model for constraint 8.
crates/rskim/src/cascade.rs — token-budget cascade (progressively more aggressive modes until output fits a budget); a downstream consumer whose budgets become provider-accurate via this API.
Workspace dependency tiktoken-rs = "0.7" (root Cargo.toml) — already provides both cl100k_base and o200k_base; no new tokenizer dependency required.
Additive-crate precedent: rskim-search depends on rskim-core without changing rskim-core's public API — the pattern to weigh in the crate-placement open question.
Acceptance criteria
An external crate (compile-tested example or integration test in a separate test crate) can call the public API under default cargo features to count tokens for each of: cl100k, o200k, Anthropic offline approximation, and the fallback heuristic.
For each provider, a closure adapter from this API compiles against and executes through rskim_core::truncate_to_token_budget (integration test: truncate the same source under each provider's counter; output respects the budget per that counter).
cl100k counts from the new API are identical to current tokens.rs counts over a golden corpus (no regression in existing stats). The corpus must include special-token strings (e.g., <|endoftext|>) so a divergence from the encode_with_special_tokens behavior of constraint 13 is actually caught.
o200k counts match golden vectors checked into the repo, generated once with the upstream Python tiktoken reference implementation (pinned version recorded in the golden-file header) over a fixed corpus drawn from tests/fixtures/ plus special-token and multilingual samples, with a documented regeneration script so the vectors are reproducible.
The Anthropic offline counter is deterministic, performs zero network I/O (verified under a network-denied test), meets the constraint-14 bar (per-document count ≥ cl100k over the golden corpus, via a documented uplift), and its approximation basis and accuracy limits are documented in rustdoc.
The fallback heuristic is deterministic, never returns an error, and — per document over a fixed mixed corpus that must include prose, code, JSON, logs, CJK text, and emoji/symbol-heavy samples — produces a count ≥ max(cl100k, o200k) for that document (the pinned semantics of constraint 9).
The model-ID → encoding mapping resolves, at minimum: gpt-3.5-turbo, gpt-4, gpt-4-turbo → cl100k; gpt-4o, gpt-4o-mini, o1, o3, gpt-4.1 → o200k; current claude-* model IDs (including claude-sonnet-4-5, claude-opus-4-5, claude-haiku-4-5) → Anthropic offline approximation. A test asserts the public API consumes this table as its single source (no second mapping anywhere in the workspace) and that the public lookup function is exposed.
Unknown-ID resolution follows the two-tier rule and produces a count without error or panic: an unknown model ID in the OpenAI family resolves to o200k_base; an unknown Anthropic-family ID resolves to the offline approximation; an unknown provider resolves to the conservative heuristic (the PRISM #552 lesson, adopted up front).
The network counter can only be obtained through an explicitly-named opt-in constructor behind the non-default cargo feature; it enforces a fixed timeout and bounded retry cap; on any failure (missing ANTHROPIC_API_KEY, network error, non-success response) it returns Err without side effects. Tests prove no default constructor or existing CLI path can reach it, and a CI check proves the default-features build pulls no HTTP/TLS dependency (constraint 15).
Tokenizer initialization failure surfaces as Result::Err, not a panic; no unwrap/expect in non-test code (clippy/CI gate).
Determinism and thread-safety: counting the same input concurrently from multiple threads yields identical results; counter types are statically asserted Send + Sync.
Performance: a criterion benchmark with a tracked baseline covers counting a 100 KB input after initialization (target <5 ms locally), and a hard test asserts <25 ms in CI (the generous-margin bound of constraint 11).
Negative: no output, exit code, or analytics value of any existing skim command changes — the full existing test suite passes unmodified, and --show-stats output is unchanged against an exact golden run. Current --show-stats tests are pattern-based, so the exact golden output must be captured from current main and checked in before the migration begins.
Negative: no default code path opens a network socket (network-denied CI job covering the library's default surface).
Initialization-failure handling is verified at unit level: every Result-returning constructor's Err path is exercised by a test, and code review confirms each CLI call site of the counting API handles Err by reporting the failure (stderr) without altering or suppressing the command's primary transformed output. No new CLI injection seam is required for this criterion — cl100k_base() over compile-time-embedded vocabulary has no realistic runtime failure trigger, so an end-to-end CLI failure injection is explicitly not demanded.
Zero compiler/clippy warnings; every public item documented.
Open questions for the implementation planner
Crate placement: extend rskim-core vs a new additive crate (e.g., rskim-tokens). rskim-core is a published, pure, no-I/O transform crate; embedded BPE vocabularies add real binary weight to it. rskim-search proves the additive pattern. Note truncate_to_token_budget only needs a closure, so core need not depend on the tokenizer crate in either design. (If a new crate: the publish-pipeline updates are in scope per the In list.)
API shape: enum-selected free function (count_tokens(text, provider)) vs constructed counter objects yielding Fn(&str) -> usize closures — and how Result-at-construction reconciles with an infallible counting closure at the truncate_to_token_budget boundary.
Anthropic approximation method and calibration basis: scaled cl100k? byte-ratio model? What uplift factor honestly satisfies constraint 14 without the deferred benchmarking campaign? Should a small golden set captured once via the opt-in network counter (checked in with provenance: capture date, model ID, endpoint) back the directional bar with a documented tolerance? No captured reference data exists in the repo today — capturing it requires a network step the planner must schedule explicitly. Should PRISM #645's published data (~2026-06-17) be a planned later calibration input?
Binary size: AC1 requires all four counters under default features, so feature-gating may only add (the network feature) — it cannot remove cl100k/o200k vocabularies from the default build. Is the added o200k vocabulary weight acceptable across the 7 release targets, and if not, what in-build mitigation (e.g., compressed embedded vocab with one-time decompression at init) is worth its complexity?
Model-ID → encoding mapping breadth and update policy: the reuse contract is fixed (the table lives in this ticket's library behind a public lookup function — see In); the open parts are table breadth beyond the AC7 minimum, the matching strategy (exact IDs vs family-prefix rules vs both), and the policy for keeping the table current across skim releases as new model IDs appear.
Network counter surface details (placement and dependency policy are decided — constraint 15): the cargo feature name, the constructor name, whether an additional runtime env-var guard is warranted on top of explicit construction, and the HTTP client crate choice (must keep a minimal dependency tree and stay entirely out of the default build).
TokenStats / format_number: stay binary-private (display concerns) or move with the API?
Supplementary reading
Local-only, git-ignored artifacts (not available to external readers; the ticket above is self-contained without them):
.devflow/docs/competitive/adoption-decisions.md — authoritative scope filter and evidence index for the Layer-3 slate.
Wave: 1 · Depends on: none · Part of: #309
Summary
Deliver a public library API for token counting across providers: tiktoken
cl100k_baseando200k_basefor OpenAI-family models, an offline approximation for Anthropic models, and a conservative heuristic fallback for everything else. Today skim's only tokenizer iscl100k_base, hard-wired as apub(crate)function inside therskimbinary crate — nothing outside the CLI can count tokens, and everything inside it counts in one encoding regardless of the target model. Every Layer-3 component built after this — per-block never-inflate checks, budget truncation, savings accounting, stale-content compaction — needs to count tokens in the encoding the target model actually uses; counting in the wrong encoding produces savings numbers that are estimates dressed as measurements. This ticket supplies the counting substrate as a Wave-1 foundation with no dependencies, composing with the already tokenizer-agnosticrskim_core::truncate_to_token_budget. Network token-counting APIs (e.g., Anthropic's count-tokens endpoint) are permitted only as an explicit opt-in construction, never reachable from any default or hot path — and that opt-in network counter is delivered by this ticket (behind a non-default cargo feature; see constraint 15), not deferred to the proxy ticket.Why now — evidence
PRISM-derived (all claims verified in the 2026-06-09 competitive study):
What PRISM got right here: deterministic local tiktoken counting (cl100k for GPT, o200k for newer models) exposed as a library-reusable interface with lazy initialization. The study's reliability filter found PRISM's deterministic components have no bug cluster against them — every PRISM reliability failure traces to ML, scoring heuristics, or proxy state. Tokenization belongs to the safe lane. Also instructive: PRISM #552 (their unknown-model-snapshot fallback fix) shows they had to retrofit graceful handling of unknown model identifiers after shipping — a lesson this ticket adopts up front as an acceptance criterion (unknown IDs resolve to documented defaults and never error).
What PRISM got wrong here: (1) Anthropic counting that leans on a network count-tokens API places a network call near the hot path; skim's binding stance is offline approximation by default, network strictly opt-in. (2) A SentencePiece fallback and similar heavyweight dependencies feed PRISM's install-reliability bug cluster (PRISM #525: macOS Intel unsupported due to ONNX; #355: native extension missing from wheel; #408/#593: unusable on a current Python release). skim ships a single static binary across 7 targets; this ticket replaces SentencePiece with a dependency-free conservative heuristic.
skim-internal evidence (audited at commit e3dec42, 2026-06-09):
crates/rskim/src/tokens.rs:cl100k_baseonly (lines 11, 18);count_tokensispub(crate)(line 22) in the binary crate, not rskim-core — not reusable as a library API. LazyOnceLock<CoreBPE>init (lines 14–19) calls.expect(), i.e., panics on init failure. Counting goes throughencode_with_special_tokens(line 24), so special-token text such as<|endoftext|>is counted as ordinary text — behavior the new API must preserve (constraint 13). A workspace-wide search found no other tokenizer (no o200k, no Anthropic tokenizer, no SentencePiece);tiktoken-rs0.7 is the only tokenization dependency.rskim_core::truncate_to_token_budget(lib.rs:376) already takes an injected counting closureF: Fn(&str) -> usize— core is tokenizer-agnostic by design. The composition point exists; only the supply side is missing.Scope
In
cl100k_base, OpenAIo200k_base(both available in the existingtiktoken-rs0.7 workspace dependency — no new tokenizer crates), Anthropic via deterministic offline approximation, and a conservative heuristic fallback for unknown providers. All four counters must be available under default cargo features.o200k_base— an unrecognized OpenAI ID is most likely a model newer than the table; Anthropic family default: the offline approximation); an unknown provider resolves to the conservative heuristic. The table is the single source of truth consumed by the public API and is exposed via a public lookup function so L3: LLM message model — parse/serialize Anthropic & OpenAI request bodies + content-block classification #302 and L3: HTTP proxy foundation — provider routing, SSE/chunked streaming passthrough, stateless fail-open core #303 can reuse it without duplication. (This adopts the lesson of PRISM #552 before shipping rather than retrofitting it.)Fn(&str) -> usizeso any provider's counter plugs intorskim_core::truncate_to_token_budgetand the never-inflate guardrail's token comparison.crates/rskim/src/tokens.rsinternals to consume the new API, with zero behavior change to existing commands (cl100k remains the CLI default).Out (decided exclusions, not oversights)
Relationship to existing specs
count_tokens()is callable from rskim-core; it is not (it ispub(crate)in the binary crate). This ticket makes that assumption true by shipping the public library API.Design constraints & invariants
Each stated as a testable constraint:
Result; nounwrap/expectoutside tests. This fixes the existingget_tokenizer().expect()panic path when the code migrates..skimrc-style configuration. Provider selection happens only through API arguments.&str → countfunctions after construction; lazyOnceLock-style one-time init is permitted (existing precedent in tokens.rs), mutable global state is not.Send + Sync(consumed by rayon parallel CLI paths today, and later — inside the tokio-based proxy process — by L3: Per-content-type compression of message content blocks (reuse existing engines) #304's per-block never-inflate checks and L3: Proxy observability — analytics schema v4, per-provider/model savings accounting #305's analytics background thread). Closure adapters satisfyFn(&str) -> usizeto match thetruncate_to_token_budget<F>bound.--show-stats, analytics, guardrail); all existing tests pass unmodified; recorded analytics values stay comparable with history. Per-provider recording arrives only with L3: Proxy observability — analytics schema v4, per-provider/model savings accounting #305.encode_with_special_tokens, matching the currenttokens.rsbehavior (line 24): special-token strings such as<|endoftext|>are counted as ordinary text, never dropped and never an error. This choice propagates to the Anthropic approximation's basis and to both sides of any guardrail before/after comparison.ANTHROPIC_API_KEYenvironment variable at construction (env var only — no config files per constraint 5; never logged). Missing key ⇒ construction returnsErr.POST /v1/messages/count_tokens) counts a model+messages request body, not raw text. The adapter wraps the input&stras a single user message, so the returned count includes the endpoint's message-envelope overhead relative to bare text. Rustdoc must state this and that network counts are therefore not directly comparable to offline raw-text counts.Reusable skim assets
crates/rskim/src/tokens.rs— current implementation to migrate:cl100k_baseviatiktoken-rs(lines 11, 18),pub(crate) count_tokens(line 22),encode_with_special_tokenscall (line 24), lazyOnceLock<CoreBPE>(lines 14–19),TokenStats(line 30).crates/rskim/tests/cli_tokens.rs— existing--show-statsintegration tests are pattern-based (containsassertions), not exact golden output; the behavior-freeze acceptance criterion below requires capturing an exact golden run from current main before migration starts.crates/rskim-core/src/lib.rs:376—truncate_to_token_budget<F: Fn(&str) -> usize>: AST-aware, priority-based truncation with an injected counting closure; documented invariant that a budget of 0 (or smaller than the omission marker) returns an empty string rather than violating the budget.crates/rskim/src/output/guardrail.rs— never-inflate guardrail: two-tier check (byte fast path, then token comparison); a direct consumer of counting and the model for constraint 8.crates/rskim/src/cascade.rs— token-budget cascade (progressively more aggressive modes until output fits a budget); a downstream consumer whose budgets become provider-accurate via this API.tiktoken-rs = "0.7"(rootCargo.toml) — already provides bothcl100k_baseando200k_base; no new tokenizer dependency required.rskim-searchdepends onrskim-corewithout changing rskim-core's public API — the pattern to weigh in the crate-placement open question.Acceptance criteria
rskim_core::truncate_to_token_budget(integration test: truncate the same source under each provider's counter; output respects the budget per that counter).tokens.rscounts over a golden corpus (no regression in existing stats). The corpus must include special-token strings (e.g.,<|endoftext|>) so a divergence from theencode_with_special_tokensbehavior of constraint 13 is actually caught.tiktokenreference implementation (pinned version recorded in the golden-file header) over a fixed corpus drawn fromtests/fixtures/plus special-token and multilingual samples, with a documented regeneration script so the vectors are reproducible.gpt-3.5-turbo,gpt-4,gpt-4-turbo→ cl100k;gpt-4o,gpt-4o-mini,o1,o3,gpt-4.1→ o200k; currentclaude-*model IDs (includingclaude-sonnet-4-5,claude-opus-4-5,claude-haiku-4-5) → Anthropic offline approximation. A test asserts the public API consumes this table as its single source (no second mapping anywhere in the workspace) and that the public lookup function is exposed.o200k_base; an unknown Anthropic-family ID resolves to the offline approximation; an unknown provider resolves to the conservative heuristic (the PRISM #552 lesson, adopted up front).ANTHROPIC_API_KEY, network error, non-success response) it returnsErrwithout side effects. Tests prove no default constructor or existing CLI path can reach it, and a CI check proves the default-features build pulls no HTTP/TLS dependency (constraint 15).Result::Err, not a panic; nounwrap/expectin non-test code (clippy/CI gate).Send + Sync.--show-statsoutput is unchanged against an exact golden run. Current--show-statstests are pattern-based, so the exact golden output must be captured from current main and checked in before the migration begins.Result-returning constructor'sErrpath is exercised by a test, and code review confirms each CLI call site of the counting API handlesErrby reporting the failure (stderr) without altering or suppressing the command's primary transformed output. No new CLI injection seam is required for this criterion —cl100k_base()over compile-time-embedded vocabulary has no realistic runtime failure trigger, so an end-to-end CLI failure injection is explicitly not demanded.Open questions for the implementation planner
rskim-corevs a new additive crate (e.g.,rskim-tokens). rskim-core is a published, pure, no-I/O transform crate; embedded BPE vocabularies add real binary weight to it.rskim-searchproves the additive pattern. Notetruncate_to_token_budgetonly needs a closure, so core need not depend on the tokenizer crate in either design. (If a new crate: the publish-pipeline updates are in scope per the In list.)count_tokens(text, provider)) vs constructed counter objects yieldingFn(&str) -> usizeclosures — and how Result-at-construction reconciles with an infallible counting closure at thetruncate_to_token_budgetboundary.TokenStats/format_number: stay binary-private (display concerns) or move with the API?Supplementary reading
Local-only, git-ignored artifacts (not available to external readers; the ticket above is self-contained without them):
.devflow/docs/competitive/adoption-decisions.md— authoritative scope filter and evidence index for the Layer-3 slate..devflow/docs/competitive/prism-synthesis.md— §F8 (multi-provider tokenization feature analysis)..devflow/docs/competitive/prism-capability-matrix.md— group (d) Multi-Provider & Tokenization..devflow/docs/competitive/raw/skim-surface.md— skim internal capability audit (tokenizer section), commit e3dec42.