pleach
Architecture

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() or Math.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.env mid-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 fetch and returns continue without 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:

SymptomLikely contract violated
Prompt bytes differStatic contribution reading runtime state
Cache misses on identical inputsIdentity-like field in fingerprint input
Stream produces different chunksProvider non-determinism (forgot to pass seed)
Channel state divergesCustom reducer not commutative
Audit row order shiftsWall-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.

ModeBehavior
interactiveUser-facing streaming chat; cache reads + writes enabled
headless-evalBatch eval; seed-pinned at runtime construction
headless-replayCache MUST hit; a miss throws ReplayDivergenceError
headless-jobScheduled cron / async callback
coding-agentMulti-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 promisedWhy
Cross-provider equivalenceTwo providers serving the same model id can return different bytes
Cross-version equivalenceSubstrate upgrades intentionally invalidate the cache via pleachVersion
Cross-runtime equivalenceA custom provider that drifts from the standard ones isn't bound by this contract
Latency equivalenceDeterminism 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:

  1. 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.
  2. 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.
  3. 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.

Where to go next

On this page