Files
Nicolò Boschi bce43b8e14 perf(tokenizer): move token counting from quicktok to toktok-rs (#4022)
Swaps `quicktok-v1` for `toktok-rs` (vectorize-io/toktok), a Rust BPE tokenizer
whose ids are byte-identical to tiktoken's, then collapses the module's
interface onto the two operations the engine actually performs.

The swap itself is behaviour-neutral: compared side by side over 258 texts
(~250 real files from `hindsight_api/` plus the special-token and mixed-script
edge cases), quicktok and toktok produce identical counts AND identical ids on
all three shared encodings. No token budget, chunk boundary or truncation point
moves.

Interface. `_SafeEncoding` existed to force `disallowed_special=()` onto
`encode()`. Every caller of the object it returned was doing either a plain
`count` or `decode(encode(x)[:n])` open-coded — which is `truncate_to_tokens`.
So the module's whole public surface is now `count_tokens`,
`truncate_to_tokens` / `truncate_many_to_tokens`, and `BUNDLED_ENCODINGS`; the
tokenizer itself is private. That keeps #1883 fixed by construction rather than
by convention: every route to the raising `encode()` went through the accessor
that is now `_load_encoding`.

Character-boundary truncation (toktok 0.1.3). Truncation was
`decode(encode(text)[:n])`, cutting on a *token* boundary. Byte-level BPE
splits one character across several tokens (under o200k_base "🧠" is three), so
a cut could land mid-character and decode to U+FFFD:

    truncate_to_tokens("hello 🧠", 2).text   ->  'hello �'   (before)
                                            ->  'hello '    (now)

The native call also never builds ids, never decodes, and returns the original
string object untouched when nothing needs cutting. `batch_truncate` replaces
the Python loop in the two callers that truncate a whole list: every reranker
document (both LiteLLM cross-encoders) and every embedding input.

Two user-visible consequences:

* `llama3` and `qwen3` are gone — quicktok bundled five vocabularies, toktok
  bundles three. `HINDSIGHT_API_TOKENIZER_ENCODING=llama3` now fails at the
  first token count with the existing "Unknown tokenizer encoding" ValueError.
  Docs and both env templates updated, and `BUNDLED_ENCODINGS` (which had been
  lying about those two) now has a test that loads every name it advertises.
* A negative budget used to slice a list with a negative index, silently
  dropping tokens off the end; the native call would raise. It clamps to 0.

Wheels: cp311-abi3 covers 3.11-3.14, so 3.14 no longer compiles from source the
way quicktok did. No musllinux wheels, which is irrelevant to the shipped
images (all Python stages are glibc python:3.11-slim). numpy drops to an
optional extra, so the tokenizer pulls in no dependency of its own.

Measured on this repo's text: 2-7x faster than tiktoken on cl100k_base, 10-16x
on o200k_base; counting an 81k-token document peaks at 1 KiB vs ~3 MB.
2026-09-02 12:52:08 +02:00

345 lines
13 KiB
Bash
Executable File

#!/bin/bash
#
# One-shot dev environment setup for Hindsight.
#
# Installs every toolchain the repo needs (uv/Python, Node/npm, Rust/cargo),
# installs all workspace dependencies, and builds the core artifacts so the
# project is ready to develop with — including offline, after this finishes.
#
# Each step checks whether it's already done and skips it, so the script is
# safe to re-run. Docker image builds are intentionally out of scope.
#
# Usage:
# ./scripts/dev/setup.sh # install toolchains + deps + core builds
# ./scripts/dev/setup.sh --skip-build # install toolchains + deps only
# ./scripts/dev/setup.sh --skip-models # don't pre-download local ML models
# ./scripts/dev/setup.sh --with-docs # also build the docs site
# ./scripts/dev/setup.sh --force # rebuild artifacts even if present
#
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$ROOT_DIR"
# --- options ----------------------------------------------------------------
SKIP_BUILD=false
SKIP_MODELS=false
WITH_DOCS=false
FORCE=false
for arg in "$@"; do
case "$arg" in
--skip-build) SKIP_BUILD=true ;;
--skip-models) SKIP_MODELS=true ;;
--with-docs) WITH_DOCS=true ;;
--force) FORCE=true ;;
-h|--help)
sed -n '3,17p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
exit 0
;;
*) echo "Unknown option: $arg (try --help)"; exit 2 ;;
esac
done
# --- pretty logging ---------------------------------------------------------
step() { printf '\n\033[1;34m▶ %s\033[0m\n' "$1"; }
ok() { printf ' \033[32m✓\033[0m %s\n' "$1"; }
info() { printf ' \033[2m• %s\033[0m\n' "$1"; }
warn() { printf ' \033[33m⚠ %s\033[0m\n' "$1"; }
have() { command -v "$1" >/dev/null 2>&1; }
OS="$(uname -s)"
SUMMARY=()
# Minimum Node major version the workspaces expect (CI builds on 20/22).
NODE_MIN_MAJOR=20
# ---------------------------------------------------------------------------
# Toolchains
# ---------------------------------------------------------------------------
ensure_uv() {
step "uv (Python toolchain & package manager)"
# uv installs to ~/.local/bin by default — make sure it's reachable now.
export PATH="$HOME/.local/bin:$HOME/.cargo/bin:$PATH"
if have uv; then
ok "already installed ($(uv --version))"
SUMMARY+=("uv: present")
return
fi
info "installing via astral.sh installer..."
curl -LsSf https://astral.sh/uv/install.sh | sh
# The installer writes an env file with the PATH update; source it so the
# rest of this run can use uv immediately.
[ -f "$HOME/.local/bin/env" ] && . "$HOME/.local/bin/env"
export PATH="$HOME/.local/bin:$PATH"
have uv || { warn "uv not on PATH after install — open a new shell and re-run"; exit 1; }
ok "installed ($(uv --version))"
SUMMARY+=("uv: installed")
}
node_major() { node -p 'process.versions.node.split(".")[0]' 2>/dev/null || echo 0; }
ensure_node() {
step "Node.js + npm"
if have node && [ "$(node_major)" -ge "$NODE_MIN_MAJOR" ]; then
ok "already installed (node $(node --version), npm $(npm --version))"
SUMMARY+=("node: present ($(node --version))")
return
fi
if have node; then
warn "node $(node --version) is older than v${NODE_MIN_MAJOR} — upgrading"
fi
# Pick the most appropriate installer for the host. Order matters: prefer a
# system package manager, fall back to nvm (no root, works in containers).
if [ "$OS" = "Darwin" ] && have brew; then
info "installing via Homebrew..."
brew install node
elif have apt-get; then
info "installing via apt (NodeSource ${NODE_MIN_MAJOR}.x)..."
local SUDO=""
[ "$(id -u)" -ne 0 ] && have sudo && SUDO="sudo"
curl -fsSL "https://deb.nodesource.com/setup_${NODE_MIN_MAJOR}.x" | $SUDO bash -
$SUDO apt-get install -y nodejs
else
info "installing via nvm..."
export NVM_DIR="$HOME/.nvm"
if [ ! -s "$NVM_DIR/nvm.sh" ]; then
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash
fi
# shellcheck disable=SC1091
. "$NVM_DIR/nvm.sh"
nvm install "$NODE_MIN_MAJOR"
nvm alias default "$NODE_MIN_MAJOR"
fi
have node || { warn "node not on PATH after install — open a new shell and re-run"; exit 1; }
ok "installed (node $(node --version), npm $(npm --version))"
SUMMARY+=("node: installed ($(node --version))")
}
ensure_rust() {
step "Rust + cargo (for hindsight-cli)"
[ -f "$HOME/.cargo/env" ] && . "$HOME/.cargo/env"
export PATH="$HOME/.cargo/bin:$PATH"
if have cargo; then
ok "already installed ($(cargo --version))"
SUMMARY+=("cargo: present")
return
fi
info "installing via rustup..."
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --no-modify-path
. "$HOME/.cargo/env"
have cargo || { warn "cargo not on PATH after install — open a new shell and re-run"; exit 1; }
ok "installed ($(cargo --version))"
SUMMARY+=("cargo: installed")
}
# ---------------------------------------------------------------------------
# Repo bootstrap
# ---------------------------------------------------------------------------
ensure_env_file() {
step ".env file"
if [ -f "$ROOT_DIR/.env" ]; then
ok ".env already exists"
return
fi
cp "$ROOT_DIR/.env.example" "$ROOT_DIR/.env"
ok "created .env from .env.example"
warn "set HINDSIGHT_API_LLM_API_KEY (and provider/model) in .env before running the API"
SUMMARY+=(".env: created — add your LLM API key")
}
setup_git_hooks() {
step "Git hooks"
if [ "$(git config --get core.hooksPath || true)" = "$ROOT_DIR/.githooks" ]; then
ok "hooks already configured"
return
fi
./scripts/setup-hooks.sh >/dev/null
ok "configured core.hooksPath -> .githooks"
}
# ---------------------------------------------------------------------------
# Dependencies (also primes offline caches)
# ---------------------------------------------------------------------------
install_python_deps() {
step "Python dependencies (uv sync — whole workspace)"
# Installs the pinned interpreter (.python-version) if needed plus every
# workspace member's deps into .venv. Populates uv's cache for offline use.
uv sync
ok "Python workspace synced (.venv ready)"
SUMMARY+=("python deps: synced")
}
install_node_deps() {
step "Node dependencies (npm ci — all workspaces)"
if [ -d "$ROOT_DIR/node_modules" ] && [ "$FORCE" = false ]; then
# npm ci is the clean-install path; only skip the reinstall when the
# tree already exists and the user didn't ask to force.
info "node_modules present — running npm install to reconcile lockfile"
npm install --no-audit --no-fund
else
npm ci --no-audit --no-fund
fi
ok "Node workspaces installed"
SUMMARY+=("node deps: installed")
}
# ---------------------------------------------------------------------------
# ML model cache (offline readiness) — mirrors docker/standalone/Dockerfile
# ---------------------------------------------------------------------------
prewarm_models() {
step "Pre-download ML models (offline readiness)"
# Respect provider/model overrides from .env; default to local + repo
# defaults (BAAI/bge-small-en-v1.5, cross-encoder/ms-marco-MiniLM-L-6-v2).
set -a; [ -f "$ROOT_DIR/.env" ] && . "$ROOT_DIR/.env"; set +a
# Only the "local" provider downloads weights; remote providers (TEI,
# OpenAI, Cohere, ...) fetch nothing here. The tokenizer needs no step at
# all — toktok compiles its vocabularies into the wheel.
local emb_model="" rer_model=""
if [ "${HINDSIGHT_API_EMBEDDINGS_PROVIDER:-local}" = "local" ]; then
emb_model="${HINDSIGHT_API_EMBEDDINGS_LOCAL_MODEL:-BAAI/bge-small-en-v1.5}"
info "embeddings (local): $emb_model"
else
info "embeddings provider is '${HINDSIGHT_API_EMBEDDINGS_PROVIDER}' — no local weights to fetch"
fi
if [ "${HINDSIGHT_API_RERANKER_PROVIDER:-local}" = "local" ]; then
rer_model="${HINDSIGHT_API_RERANKER_LOCAL_MODEL:-cross-encoder/ms-marco-MiniLM-L-6-v2}"
info "reranker (local): $rer_model"
else
info "reranker provider is '${HINDSIGHT_API_RERANKER_PROVIDER}' — no local weights to fetch"
fi
# With the tokenizer needing no cache, an all-remote setup has nothing left to
# fetch here — say so rather than starting a python process to warm nothing.
if [ -z "$emb_model" ] && [ -z "$rer_model" ]; then
ok "nothing to pre-download (no local providers configured)"
SUMMARY+=("models: none needed (remote providers)")
return
fi
# Retry with exponential backoff for transient network failures, matching
# the Docker build. Models land in the shared HF hub cache, so a re-run is
# a fast cache validation rather than a re-download.
local attempt delay=10
for attempt in 1 2 3; do
if PREWARM_EMB="$emb_model" PREWARM_RER="$rer_model" \
HF_HUB_DOWNLOAD_TIMEOUT=600 \
uv run --directory "$ROOT_DIR/hindsight-api-slim" python - <<'PY'
import os
os.environ.setdefault("HF_HUB_DOWNLOAD_TIMEOUT", "600")
emb = os.environ.get("PREWARM_EMB") or ""
rer = os.environ.get("PREWARM_RER") or ""
if emb:
from sentence_transformers import SentenceTransformer
print(f" caching embedding model {emb} ...", flush=True)
SentenceTransformer(emb)
if rer:
from sentence_transformers import CrossEncoder
print(f" caching cross-encoder model {rer} ...", flush=True)
CrossEncoder(rer)
print(" models cached", flush=True)
PY
then
ok "model cache warmed"
SUMMARY+=("models: cached for offline use")
return
fi
if [ "$attempt" -lt 3 ]; then
warn "download attempt $attempt failed — retrying in ${delay}s"
sleep "$delay"
delay=$((delay * 2))
fi
done
warn "model pre-download failed after 3 attempts (network?) — models will download on first API use"
SUMMARY+=("models: NOT cached (will download at runtime)")
}
# ---------------------------------------------------------------------------
# Builds
# ---------------------------------------------------------------------------
build_ts_client() {
step "Build TypeScript SDK (@vectorize-io/hindsight-client)"
# The control plane imports the built SDK, so this must come first.
if [ "$FORCE" = false ] && [ -d "$ROOT_DIR/hindsight-clients/typescript/dist" ]; then
ok "dist/ present — skipping (use --force to rebuild)"
return
fi
npm run build -w @vectorize-io/hindsight-client
ok "SDK built"
SUMMARY+=("ts-client: built")
}
build_cli() {
step "Build Hindsight CLI (cargo release)"
# Building also vendors every crate into ~/.cargo, priming the offline cache.
if [ "$FORCE" = false ] && [ -x "$ROOT_DIR/hindsight-cli/target/release/hindsight" ]; then
ok "release binary present — skipping (use --force to rebuild)"
return
fi
( cd "$ROOT_DIR/hindsight-cli" && cargo build --release )
ok "CLI built (hindsight-cli/target/release/hindsight)"
SUMMARY+=("cli: built")
}
build_docs() {
step "Build documentation site (Docusaurus)"
INCLUDE_CURRENT_VERSION=true npm run build -w hindsight-docs
ok "Docs built"
SUMMARY+=("docs: built")
}
# ---------------------------------------------------------------------------
# Run
# ---------------------------------------------------------------------------
printf '\033[1mHindsight dev setup\033[0m (root: %s)\n' "$ROOT_DIR"
[ "$SKIP_BUILD" = true ] && info "build steps disabled (--skip-build)"
[ "$SKIP_MODELS" = true ] && info "model pre-download disabled (--skip-models)"
[ "$FORCE" = true ] && info "force rebuild enabled (--force)"
ensure_uv
ensure_node
ensure_rust
ensure_env_file
setup_git_hooks
install_python_deps
install_node_deps
[ "$SKIP_MODELS" = false ] && prewarm_models
if [ "$SKIP_BUILD" = false ]; then
# NB: the control plane's Next.js production/standalone build is the Docker
# deploy artifact and is intentionally NOT built here. Local dev runs it via
# `npm run dev` (Turbopack, compiles on demand, no network needed offline).
build_ts_client
build_cli
[ "$WITH_DOCS" = true ] && build_docs
fi
step "Setup complete"
for line in "${SUMMARY[@]}"; do ok "$line"; done
cat <<'EOF'
Next steps:
• Add your LLM API key to .env (HINDSIGHT_API_LLM_API_KEY)
• Start everything: ./scripts/dev/start.sh
• API only: ./scripts/dev/start-api.sh
• Run API tests: cd hindsight-api-slim && uv run pytest tests/
The local embedding/reranker models are cached, so the API can run fully
offline (default local providers). Re-run with --skip-models to skip that
step. The tokenizer needs no cache: its vocabularies ship in the wheel.
EOF