pleach
Architecture

Fingerprint

The cache-key tuple — what's in it, what's deliberately excluded, why the split matters for caching, replay, and audit.

Fingerprint is one concept in the safety & determinism thematic island — siblings of safety, scrubbers, fabrication detection, and determinism.

A fingerprint is the equivalence class for an LLM call. Two calls with the same fingerprint are interchangeable: the cache returns one for the other; replay reuses the recorded result; audit groups them together.

computeFingerprint is a pure function. Same inputs in any order produce the same fingerprint, byte-for-byte. That's what makes deterministic replay possible and what lets the cache survive across processes.

import {
  computeFingerprint,
  buildMetadata,
  canonicalize,
  sha256,
  isCacheEligible,
  PLEACH_CORE_VERSION,
} from "@pleach/core/fingerprint";
import type { Fingerprint, FingerprintInput, FingerprintConfig } from "@pleach/core/fingerprint";
Subpath@pleach/core/fingerprintSourcesrc/fingerprint/

The key/metadata split

The single most load-bearing decision in the fingerprint contract is what goes into the cache key versus what stays as metadata.

What's IN the key

Anything that changes the model's output bytes or defines a soundness boundary.

FieldWhy it's in the key
modelDifferent models produce different outputs
promptHashsha256 of canonicalized messages
toolsHashsha256 of the tool catalog, sorted by name
systemPromptHashsha256 of the composed system prompt (omitted when absent)
temperatureBucketQuantized temperature — Math.round(temp * 10) / 10 at default precision
seedWhen set, deterministic provider seed
familyTokenizer / prompt-cache key / tool dialect lock
callClassDetermines the downgrade ladder
runtimeModeDeterminism contract (see below)
tenantIdSoundness — never leak one tenant's cache to another
pleachVersionSchema invalidation on substrate upgrades
safetyPoliciesHashsha256 of the "<id>@<version>" list, sorted by id; omitted when zero policies are active

What's deliberately OUT of the key

Identity-like fields. Putting these in the key would make caching nearly useless and break replay.

FieldWhy it's NOT in the key
chatIdTwo fresh chats with identical input should hit the same cache
sessionIdSame call in different sessions should reuse
messageIdRe-emitting an idempotent message shouldn't fork the cache
userId / orgIdCache by content, not identity (tenant isolation handles security)
environmentId / workspaceIdSoft scopes; not soundness boundaries
evalRunId / replayOfEventIdThese are reuse keys, not invalidation keys
recordedAtWall-clock time would invalidate the cache on every read

These live on FingerprintMetadata instead — joinable to the fingerprint at audit time, never part of the equivalence class.

If you put sessionId in the key, two identical calls in different sessions miss the cache. If you leave tenantId out, the cache leaks across tenants. The split is the load-bearing decision.

Computing a fingerprint

import { computeFingerprint } from "@pleach/core/fingerprint";

const fp = computeFingerprint({
  model:                 "claude-sonnet-4-5",
  family:                "anthropic",
  callClass:             "synthesize",
  runtimeMode:           "interactive",
  messages:              [{ role: "user", content: "Hello" }],
  systemPrompt:          composedSystemPrompt,
  tools:                 toolDescriptors,
  temperature:           0.7,
  seed:                  42,
  tenantId:              "org_abc",
  activeSafetyPolicies:  [{ id: "compliance.pii-redaction", version: "1.2.0" }],
});

// fp.promptHash / fp.toolsHash / fp.systemPromptHash — sha256 hex digests
// fp.safetyPoliciesHash — present iff activeSafetyPolicies is non-empty
// fp.temperatureBucket / fp.seed — present iff the call set them

The returned Fingerprint carries both the canonical hash (for joins, dedup, cache lookup) and the typed component fields (for debugging, dashboards, and audit queries).

Temperature bucketing

Floats aren't a reliable cache key — two paths that compute "0.7" through different arithmetic produce different bytes. The fingerprint quantizes:

temperatureBucket = Math.round(temperature * 10 ** precision) / 10 ** precision;

Default precision is 1 (0.70.7). Override via FingerprintConfig.temperaturePrecision when you need finer buckets — higher precision = lower cache hit rate.

Bucketing is a fallback for ineligible-but-fingerprintable calls. Callers that want strict-zero matching should set temperature: 0 and rely on isCacheEligible() (below).

Safety policies are sorted

activeSafetyPolicies canonicalizes by sorting the <id>@<version> tuples by id before hashing, then stores the sha256 as safetyPoliciesHash. Two operators who enable the same set in different orders produce the same key.

canonicalize and sha256

Two helpers exported for building related cache keys:

import { canonicalize, sha256 } from "@pleach/core/fingerprint";

const json = canonicalize({ b: 2, a: 1 });
// → '{"a":1,"b":2}' — keys sorted, whitespace stripped, no key-order ambiguity

const hash = sha256(json);
// → 64-char hex digest, platform-uniform (Node, browser, edge)

canonicalize is the canonical-JSON encoder. Use it when you're hashing structured data for cross-process comparison — it's the same routine the fingerprint uses internally, so your derived keys join cleanly.

isCacheEligible

The eligibility predicate. A call is eligible iff it's deterministic — every other condition is a consumer-side concern layered on top.

import { isCacheEligible } from "@pleach/core/fingerprint";

const eligible = isCacheEligible({ temperature, seed });

A call is eligible when either:

  • temperature === 0 (greedy / argmax sampling — no stochasticity), or
  • seed is set (!== undefined and !== null) — caller pinned the RNG.

Calls with temperature > 0 and no seed sample from a distribution; a cache hit would replay a stale sample in place of a fresh one. The predicate is pure, has no side effects, and is equivalent across all runtimeModes — eligibility is a property of the call, not the runtime.

Ineligible calls can still have a fingerprint computed (for audit / debug / cost projection). The cache MUST NOT serve a hit or write an entry under their key. Enforcement lives at the cache boundary, not inside computeFingerprint().

buildMetadata

The identity-like sibling of the fingerprint. Carries the fields the cache key deliberately excludes so the audit ledger can still join them.

import { buildMetadata } from "@pleach/core/fingerprint";

const meta = buildMetadata({
  // buildMetadata takes the SAME FingerprintInput as computeFingerprint and
  // extracts only the identity-like fields; the equivalence-class fields are
  // required inputs.
  family:           "anthropic",
  model:            "claude-sonnet-4-5",
  callClass:        "synthesize",
  runtimeMode:      "interactive",
  messages:         [{ role: "user", content: "Hello" }],
  // Identity-like fields — the ones the cache key deliberately excludes.
  chatId:           "chat_abc",
  sessionId,
  messageId,
  userId:           "user_123",
  orgId:            "org_abc",
  environmentId:    "prod",
  evalRunId,
  replayOfEventId,
  recordedAt:       new Date().toISOString(),
});

Pass both fingerprint and metadata when writing a ledger row; the fingerprint defines the equivalence class, the metadata says which row this particular write is.

RuntimeMode values

The fingerprint includes runtimeMode, so a turn recorded in one mode never collides with the cache of another. The closed union:

ModeDeterminism contract
interactiveUser-facing streaming chat; latency-sensitive
headless-evalBatch eval; seed-pinned at runtime construction
headless-replayReplay from recorded events — cache MUST hit; a miss throws ReplayDivergenceError
headless-jobScheduled cron / async callback
coding-agentMulti-synthesize per turn

Cross-mode read direction is one-way: interactive → headless-eval → headless-replay. Headless modes never read interactive entries — an interactive call was not produced under a determinism contract, so a headless consumer that borrowed from it would lose its replay guarantee. The direction is gated by CacheReadPolicy (strict-mode is the v1 default; cross-mode-readable is v1.x).

ReplayDivergenceError

import { ReplayDivergenceError } from "@pleach/core/fingerprint";

Thrown by replay-mode lookups when the cache misses. Carries the Fingerprint that failed to resolve. headless-replay is the one mode where a miss is fatal — a replay session cannot fall through to a live call without breaking the recorded-equivalence contract.

PLEACH_CORE_VERSION

The substrate version. Lives in the fingerprint key so a substrate upgrade invalidates the cache automatically. If the runtime composes prompts differently after a version bump (a common reason for output drift), the cache miss is automatic rather than silent corruption.

import { PLEACH_CORE_VERSION } from "@pleach/core/fingerprint";

console.log(PLEACH_CORE_VERSION); // e.g. "1.1.0"

Override only in tests where you want to assert a specific version behavior — production code should always read the constant.

Replay determinism

The fingerprint exists so the replay story works. The contract:

  1. Same fingerprint input → same fingerprint hash.
  2. Same fingerprint hash + same provider → same output bytes.
  3. Output bytes feed into channel reducers deterministically.
  4. Channel reducers + scheduling are deterministic given the superstep state.

Therefore: a recorded turn replays byte-identical against the same package version + the same input. @pleach/eval@0.1.0 and @pleach/replay@0.1.0 are built around this property and ship today (entry-point bodies real; later-slice methods throw typed sentinels — see Packages). The fingerprint is what keeps the chain honest.

Common ways the chain breaks

What breaks itHow to fix
A static prompt contribution reads session stateMove it to the runtime-aware hook
An async stream observer (forbidden but sometimes attempted)Make it sync; route fan-out through named-channel emit
Wall-clock read inside a reducerRead once at turn start; pass as a channel write
A custom channel that mutates checkpoint()/restore() stateImplement honestly — snapshot must round-trip

Each of these silently breaks replay. The fingerprint test catches them: two runs of the "same" turn produce different fingerprints when one of these slips in.

Where to go next

On this page