cc-os/plugins/os-doc-hygiene/scripts/token_estimator.py

281 lines
10 KiB
Python
Raw Permalink Normal View History

#!/usr/bin/env python3
"""
token_estimator.py deterministic, local token estimation for doc-hygiene.
Produces the `raw_tokens` count that feeds each report entry's
`token_estimate` field (see the frozen report schema:
`openspec/specs/report-schema/spec.md`). This is the deterministic seam
required by invariant #6 — NO model, NO network/API call, ever.
Accuracy goal
-------------
This is BALLPARK relative ranking of documentation bloat, not billing-grade
tokenization. A swappable backend lets a heavier (more accurate) tokenizer be
used when available, while a zero-dependency heuristic guarantees the seam
always works.
Backends (pluggable via the `TokenEstimator` ABC)
-------------------------------------------------
- `HeuristicEstimator` ALWAYS available. Pure-Python ~4-chars/token rule
(`ceil(len(text) / 4)`). Zero third-party dependencies.
- `TiktokenEstimator` OPTIONAL accuracy upgrade. Uses tiktoken's
`o200k_base` encoding. Only selected when tiktoken is importable AND its
vocab is vendored in the local cache (no cold-cache network download).
`default_estimator()` performs the selection and NEVER raises it always
returns a working estimator, falling back to the heuristic when tiktoken or
its vendored vocab is unavailable. The active backend is introspectable via
`.name` so the `check` skill can record which tokenizer produced the counts.
Offline / determinism guarantee (invariant #6)
----------------------------------------------
tiktoken has no "offline only" flag: calling `get_encoding("o200k_base")`
with a cold cache silently fetches the vocab over the network. We therefore
DETECT BEFORE FETCH we set `TIKTOKEN_CACHE_DIR` to a path inside the plugin
and check that the expected cache file already exists *before* importing the
encoding. If it is absent we fall back to the heuristic rather than triggering
a download.
CACHE-FILENAME GOTCHA: tiktoken names its cache file by
`sha1(blobpath_url).hexdigest()` NOT by the sha256 content hash and NOT by a
human-readable name. For `o200k_base` the URL is
`https://openaipublic.blob.core.windows.net/encodings/o200k_base.tiktoken`,
which hashes to the filename:
fb374d419588a4632f3f557e76b4b70aebbca790
A vendored vocab MUST live at `<cache_dir>/fb374d419588a4632f3f557e76b4b70aebbca790`
or the cache never hits and tiktoken falls back to a network fetch.
Pre-warming the vendored vocab (one-time, online, optional)
-----------------------------------------------------------
TIKTOKEN_CACHE_DIR=scripts/tiktoken_cache \
python -c "import tiktoken; tiktoken.get_encoding('o200k_base')"
The ~2 MB vocab blob is intentionally NOT committed to git. When it is absent
the estimator runs on the heuristic backend; this keeps the repo lean and the
runtime offline-safe.
"""
from __future__ import annotations
import hashlib
import json
import math
import os
import sys
from abc import ABC, abstractmethod
from pathlib import Path
from typing import Optional
# ---------------------------------------------------------------------------
# Constants — tiktoken vendored-cache resolution
# ---------------------------------------------------------------------------
# The encoding we standardise on (current OpenAI tokenizer; close enough for a
# relative ballpark of Claude-context bloat — accuracy goal is ranking, not
# billing).
_O200K_BASE_NAME = "o200k_base"
_O200K_BASE_BLOBPATH = (
"https://openaipublic.blob.core.windows.net/encodings/o200k_base.tiktoken"
)
# tiktoken's on-disk cache file is named sha1(blobpath_url).hexdigest().
# Pre-computed here so the offline pre-check needs no tiktoken import.
_O200K_BASE_CACHE_FILENAME = hashlib.sha1(
_O200K_BASE_BLOBPATH.encode()
).hexdigest() # == "fb374d419588a4632f3f557e76b4b70aebbca790"
# Default vendored-cache directory inside the plugin (sibling of this file).
_DEFAULT_CACHE_DIR = Path(__file__).resolve().parent / "tiktoken_cache"
# ---------------------------------------------------------------------------
# Estimator abstraction
# ---------------------------------------------------------------------------
class TokenEstimator(ABC):
"""Pluggable, deterministic token-count backend.
Subclasses implement `estimate(text)`; the file convenience and report
wrapper are shared. Every backend exposes `.name` so callers can record
which tokenizer produced a count.
"""
#: Short, stable backend identifier (e.g. "heuristic", "tiktoken:o200k_base").
name: str = "abstract"
@abstractmethod
def estimate(self, text: str) -> int:
"""Return the token count for *text* (>= 0). Deterministic, no I/O."""
raise NotImplementedError
def estimate_file(self, path: Path) -> int:
"""Return the token count of *path*'s UTF-8 contents.
Decode errors are tolerated (``errors="replace"``) to match the
scanner's read convention, so arbitrary/binary-ish docs never raise.
A missing/unreadable file yields 0.
"""
try:
text = Path(path).read_text(encoding="utf-8", errors="replace")
except OSError:
return 0
return self.estimate(text)
def estimate_for_report(self, text: str) -> dict:
"""Return a schema-shaped ``token_estimate`` object for *text*.
v1 populates only `raw_tokens` (the required field). The
injection-frequency weighting fields are the v2 bonus and are emitted
as `null` here so the shape is explicit for the `check` skill.
See `openspec/specs/report-schema/spec.md` (Per-Entry Token Estimate).
"""
return {
"raw_tokens": self.estimate(text),
"injection_frequency": None,
"weighted_tokens": None,
}
class HeuristicEstimator(TokenEstimator):
"""Zero-dependency ~4-chars/token estimator: ``ceil(len(text) / 4)``.
Boundaries: ``""`` -> 0, 4 chars -> 1, 5 chars -> 2. Always available;
this is the guaranteed fallback that keeps the deterministic seam working
with no third-party packages installed.
"""
name = "heuristic"
#: Average characters per token for the heuristic. ~4 is the long-standing
#: rule-of-thumb for English prose / markdown.
CHARS_PER_TOKEN = 4
def estimate(self, text: str) -> int:
if not text:
return 0
return math.ceil(len(text) / self.CHARS_PER_TOKEN)
class TiktokenEstimator(TokenEstimator):
"""Accuracy-upgrade backend wrapping a tiktoken encoding.
Constructed with an already-loaded encoding object so this class performs
no import or cache resolution itself (that lives in `default_estimator`,
which guarantees the encoding came from the vendored cache, not a network
fetch). `disallowed_special=()` ensures arbitrary markdown containing
special-token-looking substrings (e.g. ``<|endoftext|>``) never raises.
"""
def __init__(self, encoding, encoding_name: str = _O200K_BASE_NAME) -> None:
self._encoding = encoding
self.name = f"tiktoken:{encoding_name}"
def estimate(self, text: str) -> int:
if not text:
return 0
return len(self._encoding.encode(text, disallowed_special=()))
# ---------------------------------------------------------------------------
# Backend selection (never raises)
# ---------------------------------------------------------------------------
def _vendored_vocab_present(cache_dir: Path) -> bool:
"""Return True iff the o200k_base vocab is already in *cache_dir*.
Pure filesystem check by the sha1-of-blobpath filename see the
CACHE-FILENAME GOTCHA in the module docstring. This is the detect-before-
fetch gate that keeps the seam offline (invariant #6).
"""
return (cache_dir / _O200K_BASE_CACHE_FILENAME).is_file()
def default_estimator(cache_dir: Optional[Path] = None) -> TokenEstimator:
"""Return the best available working estimator. NEVER raises.
Selection order:
1. `TiktokenEstimator` only if tiktoken is importable AND the
o200k_base vocab is vendored in *cache_dir* (so loading it triggers
no network fetch).
2. `HeuristicEstimator` the always-available fallback otherwise.
Parameters
----------
cache_dir:
Directory to resolve the vendored tiktoken vocab from. Defaults to
the plugin's `scripts/tiktoken_cache/`. Injectable so tests can point
at an empty dir to force (and assert) the heuristic fallback without
any network access.
"""
resolved_cache = Path(cache_dir) if cache_dir is not None else _DEFAULT_CACHE_DIR
# Detect-before-fetch: bail to heuristic unless the vocab is already local.
if not _vendored_vocab_present(resolved_cache):
return HeuristicEstimator()
try:
# Point tiktoken at the vendored cache BEFORE importing/using it.
os.environ["TIKTOKEN_CACHE_DIR"] = str(resolved_cache)
import tiktoken # noqa: WPS433 (optional dependency, imported lazily)
encoding = tiktoken.get_encoding(_O200K_BASE_NAME)
return TiktokenEstimator(encoding, _O200K_BASE_NAME)
except Exception:
# Import error, corrupt cache, or any other failure — fall back.
# The seam must always yield a working estimator (invariant #6).
return HeuristicEstimator()
# ---------------------------------------------------------------------------
# CLI entry point
# ---------------------------------------------------------------------------
def main(argv: Optional[list] = None) -> int:
import argparse
parser = argparse.ArgumentParser(
description=(
"doc-hygiene token estimator — emits a JSON token estimate for a "
"file (deterministic, no model, no network)."
)
)
parser.add_argument("path", help="Path to the file to estimate.")
parser.add_argument(
"--cache-dir",
default=None,
help="Vendored tiktoken cache dir (default: scripts/tiktoken_cache/).",
)
args = parser.parse_args(argv)
target = Path(args.path)
if not target.is_file():
print(
json.dumps({"error": f"not a file: {args.path}"}, indent=2),
file=sys.stderr,
)
return 1
estimator = default_estimator(
cache_dir=Path(args.cache_dir) if args.cache_dir else None
)
raw_tokens = estimator.estimate_file(target)
output = {
"path": str(target),
"backend": estimator.name,
"token_estimate": {
"raw_tokens": raw_tokens,
"injection_frequency": None,
"weighted_tokens": None,
},
}
print(json.dumps(output, indent=2))
return 0
if __name__ == "__main__":
sys.exit(main())