Skip to content

kvstore: stamp checkpoints with a behavioral tokenizer fingerprint - #983

Open
nazerim wants to merge 2 commits into
antirez:mainfrom
nazerim:feat/kvstore-tokenizer-fingerprint
Open

kvstore: stamp checkpoints with a behavioral tokenizer fingerprint#983
nazerim wants to merge 2 commits into
antirez:mainfrom
nazerim:feat/kvstore-tokenizer-fingerprint

Conversation

@nazerim

@nazerim nazerim commented Sep 5, 2026

Copy link
Copy Markdown

Persisted KV checkpoints store a token history that future requests
re-tokenize from text with whatever tokenizer the running engine has.
When tokenizer code or data changes (the JoyAI pre-tokenizer has seen
several rule fixes recently), old checkpoint token histories become
unreproducible: every resumed session diverges from its restored state
at the first changed merge boundary, keeps reloading the same stale
file through the text-keyed tiers, and re-imports the stale
tokenization - a permanent per-turn miss/reload/re-prefill loop.

Production symptom (this week, long multimodal agent session): 29
consecutive turns each live-missed at the same token, each reloaded
the same stale checkpoint by text key, each re-prefilled ~38k tokens.
The session was permanently pinned; only a manual cold rebuild healed
it. A plain text-equality check at load does not work as a detector:
it false-positives on legitimate preserved-thinking continuations,
whose stored sampled tokens intentionally differ from a whole-text
re-tokenization of their bytes (that difference is the bridge's entire
premise). The correct discriminator is engine identity.

This commit adds a behavioral tokenizer fingerprint: an append-only
probe set (apostrophe-punctuation seams, digit runs, indentation,
newline joins, UTF-8 letters, special-token edges) run through the
real tokenizer, hashed together with the token table. It covers
tokenizer data AND code changes with no human-maintained version
number, and it is cheap (computed once, lazily, under a mutex).

Mechanics:

  • Every checkpoint trailer now begins with a TOKFP section (8-byte
    header + u64 fp) and the auxiliary ext bit DS4_KVSTORE_EXT_TOKFP.
  • The loader pre-checks the stamp with one 16-byte seek at the trailer
    offset BEFORE touching the payload: mismatches cost microseconds,
    never a 600 ms load, and the session is left untouched.
  • Rejection falls into the normal prefill path, which rewrites a fresh
    stamped file under the same text key: the poison class becomes
    self-healing instead of permanent.
  • Unstamped legacy files keep today's trusted behavior exactly.

Verification (live agent session on Apple M5 Max):

  • stamped restore: full 451k-token thinking-visible checkpoint accepted,
    38-token re-prefill, session continues at full reuse.
  • synthetic skew (temporary probe-string change = simulated upstream
    tokenizer edit): old stamped files rejected instantly with a clear
    warning; fallback chain restored from the next valid candidate;
    fresh stores re-stamped correctly after restoring the probe.
  • unit suite green on bare main (trailer size/write round-trip test
    updated for the new leading section).

Independent of #968/#971 (touches ds4.c/ds4.h/ds4_kvstore.*/server
trailer hooks only); no behavior change for files written today.

Persisted KV checkpoints encode their conversation as a token history
whose bytes, on future requests, are re-tokenized by whatever tokenizer
the running engine has. When tokenizer code or data changes upstream
(the JoyAI pre-tokenizer has seen several rule fixes), existing
checkpoint token histories become unreproducible: every later request
diverges from the restored session at the first changed merge
boundary, reloads the same stale file by text key, and re-imports the
stale tokenization. The visible symptom is a permanent per-turn
live-miss + disk-reload + partial-re-prefill loop for every session
resumed with old checkpoints (observed in production: 29 consecutive
turns, ~38k tokens re-prefilled each turn, session permanently pinned
to a stale frontier).

A text-equality check at load cannot detect this class without also
rejecting legitimate preserved-reasoning continuations, whose stored
sampled tokens intentionally differ from a whole-text re-tokenization
of their bytes - the bridge's premise. The correct discriminator is
the engine identity that produced the tokens.

This commit adds a behavioral fingerprint: an append-only set of probe
strings (apostrophe-punctuation seams, digit runs, indentation,
newline joins, UTF-8 letters, special-token edges) is run through the
real tokenizer once, and the resulting ids hashed together with the
token table. It covers tokenizer data AND code changes with no
human-maintained version constant. Checkpoint trailers now begin with
a TOKFP section carrying the fingerprint; the loader pre-checks it by
a 16-byte seek to the trailer, before touching the payload (session
untouched), and mismatches fall back to the normal cold path, which
rewrites a fresh stamped file under the same key. Unstamped legacy
files keep today's trusted behavior.

Verified in a live agent session: stamped files restore at full depth
(451k tokens, 38-token re-prefill); a synthetic probe change (simulated
upstream tokenizer edit) makes every old stamped file reject in
microseconds with a clear warning and the session self-heals on the
next store.
@JordiPosthumus

Copy link
Copy Markdown
Contributor

Hey Naz — I like the aim here. Preventing a session from repeatedly restoring an incompatible checkpoint is useful hardening. I spotted a restart-stability issue in the fingerprint on f043133b that is worth fixing before this lands.

In ds4_engine_tokenizer_fingerprint():

h = tokenizer_fp_hash_bytes(h, e->vocab.token,
                            (size_t)e->vocab.n_vocab * sizeof(ds4_str));

ds4_str contains const char *ptr and uint64_t len, so this hashes the pointer addresses and lengths, not the token-string bytes. The same unchanged model can map at a different address in another process/restart. That changes the fingerprint even though tokenization is identical, causing valid stamped checkpoints from the previous process to be rejected. The behavioral probes cannot cancel out that address-dependent input.

I think the fix is to hash a canonical serialization of the tokenizer data: token IDs/order, lengths and the actual bytes behind each pointer, plus the other data needed to identify its tokenization behavior. Avoid hashing raw structs/process representations.

Two particularly useful regressions would be:

  • Identical tokenizer contents at different mapping addresses / in separate processes produce the same fingerprint, and a saved checkpoint survives an unchanged-server restart.
  • Changing token bytes without changing their lengths changes the data fingerprint, independently of whether those tokens happen to appear in the probe set.

This is a static-code finding, not a claim that I've run your full model-backed test sequence. Thanks for working on this — the underlying problem is definitely worth addressing, and this looks like a fixable snag. 🙂

@nazerim

nazerim commented Sep 6, 2026

Copy link
Copy Markdown
Author

You're right, and thank you — this is the kind of bug my live validation was uniquely suited to hide. Same binary + same args + same load sequence on macOS reproduced the allocator layout across my restart test, so the pointer-hash passed by luck; on any different mapping (or a different host) every stamped file would silently reject after each restart. Static review caught what the runtime fluked.

Fix (will force-push once validated): hash tokenizer contentn_vocab, then each token's length + bytes in id order, then the merge-rank table (used/cap + every used slot's key bytes + rank in slot order, which is a deterministic function of the GGUF merge list, not of process state). The behavioral probes stay on top, covering code changes. No raw struct hashing anywhere.

Your two regressions become the validation plan, run live:

  1. Restart-stability: store checkpoints, restart the server twice across unchanged binaries, confirm zero fingerprint mismatch lines and full-depth restores (this is exactly the case that silently fails with the pointer hash — and my earlier "verified" claim was lucky, not sound).
  2. Content sensitivity: synthetic token-bytes mutation (same lengths, no probe overlap) → new fingerprint → old stamps rejected. (I can pair that with the existing synthetic probe-change test for the code dimension.)

I'll report both results on this PR before re-requesting review. The lesson is noted for the probe-set doc comment as well: pointer-derived inputs must never reach the hash.

ds4_str holds a pointer and a length; hashing the raw array therefore
hashed process mapping state, not tokenizer data. Same-binary restarts
can reproduce the allocator layout by luck, but any different mapping
would silently change the fingerprint and reject valid stamped
checkpoints after an ordinary restart - the opposite of the feature's
purpose.

Hash content instead: n_vocab, then every token's length + bytes in id
order, then the merge-rank table (used/cap plus each used slot's key
bytes and rank; open-addressed slot order is a deterministic function
of the GGUF merge list). The behavioral probes still ride on top, so
code changes are covered even when data is not.

Validated live (Apple M5 Max): fingerprint e467e62ceeadf47e identical
across separate process launches of the unchanged model, and a stamped
checkpoint restored at full depth (32768-token grid, 69 ms load)
across a stop/start cycle with zero mismatch lines. A single-byte
token mutation on an APFS clone of the model (TABLE->TASLE, same
length, outside every probe string) moves the fingerprint to
5f83c7a25a3b184e, proving data sensitivity independent of probe
coverage.
@nazerim

nazerim commented Sep 6, 2026

Copy link
Copy Markdown
Author

Fixed and validated — head now e692449 (two commits: original + the content-hashing fix you outlined).

What changed in ds4_engine_tokenizer_fingerprint():

  • n_vocab, then every token's length + bytes in id order (no raw ds4_str array hashing anywhere).
  • The merge-rank table as content: used/cap plus each used slot's key bytes + rank. Open-addressed slot order is a deterministic function of the GGUF merge list, so it is stable across processes without hashing pointers.
  • Behavioral probes ride on top, unchanged, covering code changes.

Your two regressions, run live on Apple M5 Max against the production model file:

  1. Restart stability. Two separate process launches of the unchanged model produce the identical fingerprint (e467e62ceeadf47e), and a stamped checkpoint stored by the first process restored at full depth from the second (kv cache hit text tokens=32768 ... load=69.4 ms, re-prefill = only the 5253 tokens above the grid, zero mismatch lines). With the pointer hash this cycle silently rejected or accepted depending on allocator luck; it is now deterministic either way.

  2. Content sensitivity. APFS-cloned the model, flipped one byte of a token string (TABLE -> TASLE, same length, no probe string contains TABLE): fingerprint moves to 5f83c7a25a3b184e. Data changes are captured independently of probe coverage, as requested.

One confession your static finding flushed out: my original "validated across restart" claim in the PR description was real but lucky — same binary + same args reproduced the allocator layout on macOS, so the pointer-hash passed its own test. The fix and both regressions above are now the honest basis for that claim. Thanks for catching this before it landed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants