Determinism
Why every primitive in `@pleach/core` is deterministic by contract — the replay story end-to-end, the invariants that hold it together, and the four ways it breaks.
Determinism is one concept in the safety & determinism thematic island — the others are safety, scrubbers, fabrication detection, and fingerprint. Each carries its own mechanism; no natural cluster triplet.
A recorded turn replays byte-identical against the same package
version + the same input. That's the property
@pleach/eval@0.1.0 and @pleach/replay@0.1.0 are built
around — both ship real bodies for the entry-point surfaces
(replayTurn, verifyChainIntegrity, compareScored,
diffReports); remaining slices throw typed NotImplemented
sentinels per Packages. It's the reason for
several otherwise-confusing decisions across the substrate —
sync-only stream observers, quantized temperature buckets,
canonicalized JSON, deterministic reducers, ULID record ids.
This page tells the story end-to-end. Each primitive's contribution to the chain shows up in its own reference page; here they're connected.
The chain
Same input → same composed prompt → same fingerprint → same cached or recorded result → same observer verdicts → same channel state → same audit row.
Each arrow in the chain is a contract. Break any one and replay determinism is gone for the whole turn.
The five contracts
1. Composed prompts are deterministic
composeBudgetedPrompt quantizes the active budget, sorts
contributions by declared order + priority, and emits byte-identical
bytes for identical inputs. The fingerprint module's canonicalize
helper sorts object keys at every nesting level and throws on
cycles / BigInt / function / symbol values so the canonical form
is a total function over fingerprintable payloads.
What can break it:
- A static prompt contribution that reads session state — moves it from fingerprint-eligible to runtime-aware.
- A custom section primitive that uses
Date.now()orMath.random()— must be pure.
See Prompt builder and Prompts.
2. Fingerprints are pure and canonical
computeFingerprint is a pure function over a typed input. The
key/metadata split rules out identity-like fields; the
canonicalization sorts object keys before hashing.
What can break it:
- Putting identity-like fields (
sessionId,messageId,userId) into the input — breaks cross-session cache reuse. - Reading
process.envmid-compute instead of at substrate init — the env can drift between runs.
See Fingerprint.
3. Stream observers are sync
onChunk returns Verdict synchronously. No Promise<Verdict>,
no async. The contract is structural — the type signature
forbids the async variant.
What can break it:
- A custom observer that fires off a
fetchand returnscontinuewithout awaiting — the work happens, but at a non-deterministic point relative to the next chunk. - Observers that mutate captured state across chunks based on wall-clock conditions.
The legal pattern is: observe synchronously, emit a named-channel envelope, do async work in a post-turn node. The runtime guarantees post-turn nodes see the named envelope; the timing is controlled by the lattice, not the observer.
See Plugin contract.
4. Channel reducers are commutative + associative
Concurrent writes to the same channel must produce the same result
regardless of arrival order. The built-in reducers (appendReducer,
messagesReducer, unionReducer) satisfy this; custom reducers
must too.
What can break it:
- A reducer that uses
Date.now()to tie-break — wall clock isn't deterministic across runs. - A reducer that reads from a global mutable state — the global state at replay time isn't what it was at record time.
The substrate doesn't validate the property. Violating it silently breaks replay; tests are what catch it.
See Channels.
5. Identifiers carry creation order
recordId on every audit row is a ULID — 26-char
Crockford-Base32 that lex-sorts to creation order. The same
ULID generator at record time produces a different id at replay
time (the timestamp embedded in the ULID is wall-clock); the
contract is that the ordering matches, not the values.
For replays that need the original ids, the runtime threads
replayOfEventId through FingerprintMetadata — the new ULIDs
are different, but each row carries the link to the original.
See Audit ledger.
The fingerprint test
The single-best test for determinism: run the same turn twice and compare fingerprints.
import { computeFingerprint } from "@pleach/core/fingerprint";
import type { FingerprintInput } from "@pleach/core/fingerprint";
const input: FingerprintInput = {
family: "anthropic",
model: "claude-sonnet-4",
callClass: "synthesize",
runtimeMode: "interactive",
messages: [{ role: "user", content: turnText }],
};
// computeFingerprint is pure: the same input produces the same
// Fingerprint object, byte-for-byte.
expect(computeFingerprint(input)).toEqual(computeFingerprint(input));End-to-end, the recorded audit row carries the composite hash as a
string on fingerprintComposite — compare that field across two
runs of the same turn:
await runtime.executeMessage(sessionA, content);
await runtime.executeMessage(sessionB, content);
const [rowA] = await ledger.getSession(sessionA);
const [rowB] = await ledger.getSession(sessionB);
expect(rowB.fingerprintComposite).toEqual(rowA.fingerprintComposite);If the fingerprints diverge between runs of the "same" turn, something in the chain has slipped. Walk back through the five contracts above; the divergence is almost always one of:
| Symptom | Likely contract violated |
|---|---|
| Prompt bytes differ | Static contribution reading runtime state |
| Cache misses on identical inputs | Identity-like field in fingerprint input |
| Stream produces different chunks | Provider non-determinism (forgot to pass seed) |
| Channel state diverges | Custom reducer not commutative |
| Audit row order shifts | Wall-clock-tied id generation |
Determinism modes
runtimeMode on the runtime distinguishes five operating
contracts. The fingerprint includes it, so a turn recorded in
one mode never collides with the cache of another.
| Mode | Behavior |
|---|---|
interactive | User-facing streaming chat; cache reads + writes enabled |
headless-eval | Batch eval; seed-pinned at runtime construction |
headless-replay | Cache MUST hit; a miss throws ReplayDivergenceError |
headless-job | Scheduled cron / async callback |
coding-agent | Multi-synthesize per turn |
Cross-mode cache reads follow CacheReadPolicy — one-way only,
interactive → headless-eval → headless-replay. Writes are
always single-mode.
What determinism doesn't give you
A few things people sometimes assume but the contract doesn't promise:
| Not promised | Why |
|---|---|
| Cross-provider equivalence | Two providers serving the same model id can return different bytes |
| Cross-version equivalence | Substrate upgrades intentionally invalidate the cache via pleachVersion |
| Cross-runtime equivalence | A custom provider that drifts from the standard ones isn't bound by this contract |
| Latency equivalence | Determinism is about output bytes, not timing |
The contract is "same inputs → same outputs" within a fixed substrate version, fixed provider, fixed model id. That's the useful guarantee.
Why this matters
Three real workflows depend on the chain:
- Eval reruns. A regression test that replays last week's production failure against this week's prompt only works if the bytes line up. Without determinism, every "regression" looks like noise.
- Cache correctness. A cache that returns wrong-looking results is a quiet incident — the user sees the result, the audit row records it, but the trace doesn't reflect what would have happened on a fresh call. Determinism makes the cache sound.
- Compliance audit. "Show me what the model would have said" is a real regulator question. The audit ledger + replay are the answer; both require the chain to hold.
The substrate's many "weird" decisions — sync-only observers, quantized temperature buckets, canonicalized JSON, deterministic reducers, ULID identifiers — are the cost of buying these three properties.