Skip to content

Replay and Diff

This is the heart of Verist. If you only use one feature, use this.

CapabilityDescription
ReplayRe-run a past decision and get byte-identical output
RecomputeRun the same step with a new model or prompt
DiffSee exactly what changed before you ship

The flow

text
run step → capture artifacts → store snapshot → later: recompute and diff

1. Run a step

ts
import { defineStep, run } from "verist";
import { z } from "zod";

const verifyDocument = defineStep({
  name: "verify-document",
  input: z.object({ docId: z.string(), text: z.string() }),
  output: z.object({
    verdict: z.enum(["accept", "reject"]),
    confidence: z.number(),
  }),
  run: async (input, ctx) => {
    const verdict = await ctx.adapters.llm.verify(input.text);
    return {
      output: { verdict, confidence: 0.84 },
      events: [{ type: "document_verified", payload: { docId: input.docId } }],
    };
  },
});

const result = await run(
  verifyDocument,
  { docId: "doc-1", text: "Hello" },
  {
    adapters: { llm: yourLlmAdapter },
    workflowId: "verify-document",
    workflowVersion: "1.0.0",
    runId: "run-1",
  },
);

2. Capture artifacts and store snapshot

ts
import { createSnapshotFromResult } from "verist";

if (!result.ok) throw new Error(result.error.message);

const snapshot = await createSnapshotFromResult(result.value);

await db.snapshots.insert(snapshot);

Artifact capture in runners

Artifacts are emitted via onArtifact during execution. Store them, then attach to the snapshot:

ts
import type { Artifact } from "verist";

const extraArtifacts: Artifact[] = [];

const result = await run(verifyDocument, input, {
  adapters,
  onArtifact: (artifact) => {
    if (artifact.kind !== "step-output" && artifact.kind !== "step-commands") {
      extraArtifacts.push(artifact);
    }
  },
});

if (result.ok) {
  const snapshot = await createSnapshotFromResult(result.value, {
    artifacts: extraArtifacts,
  });
  await snapshotStore.save(snapshot);
}

Storage options

SetupUse case
In-memoryLocal dev, quick iteration
Blob store (S3/GCS)Store artifact content, keep references in DB
DatabaseSnapshot metadata in DB, large payloads in content store

3. Recompute and diff later

ts
import { recompute, formatDiff } from "verist";

const recomputeResult = await recompute(snapshot, verifyDocument, {
  adapters: { llm: newModelAdapter }, 
});

if (recomputeResult.ok) {
  const { status, outputDiff, commandsDiff, schemaViolations } =
    recomputeResult.value;

  if (status === "schema_violation")
    console.log("Violations:", schemaViolations);
  if (outputDiff && !outputDiff.equal) console.log(formatDiff(outputDiff));
  if (commandsDiff && !commandsDiff.equal)
    console.log(formatDiff(commandsDiff));
}

What gets diffed

What changedHow it shows up
Output valuesoutputDiff from recompute()
Control flowcommandsDiff (auto-captured when present)
Schema violationsschemaViolations (requires validate)
Inputs across snapshotsinputDiff from compareSnapshots()

The status field classifies each result: "clean", "value_changed", or "schema_violation" (highest severity wins). Command changes are orthogonal and tracked separately.

INFO

Events are audit logs and are not diffed. If original output is missing or hash-only, comparable is false and outputDiff is undefined.

Replay vs recompute

SituationUse
Audit / incident reviewReplay
Debugging a past decisionReplay
Model or prompt upgradeRecompute
New adapter or feature flagRecompute
Backfill on historic dataBatch + recompute

What to capture

Capture anything that can change across runs:

  • LLM input and output
  • External API responses
  • File contents
  • Feature flags or config that affects behavior

Rule: If it can change, it should be an artifact.

Stable diffs with keyBy

LLMs often return array elements in unstable order. Without identity keys, recompute reports every element as changed whenever the order shifts.

Use keyBy on defineStep to match array elements by identity instead of index:

ts
const extractEntities = defineStep({
  name: "extract-entities",
  input: z.object({ text: z.string() }),
  output: z.object({ entities: z.array(entitySchema) }),
  keyBy: { entities: "id" }, 
  run: async (input, ctx) => {
    /* ... */
  },
});

Verist normalizes keyed arrays into maps before diffing, so only actual content changes appear in the diff. Keys must be unique and present on every element.

For composite keys, use a function:

ts
keyBy: {
  entities: (item) => {
    const e = item as { class: string; text: string };
    return `${e.class}::${e.text}`;
  },
},

Common mistakes

MistakeConsequence
Not capturing artifactsReplay won't be exact
Not storing snapshotsRecompute becomes impossible
Mixing side effects into stepsNon-deterministic outputs

LLM context: llms.txt · llms-full.txt
Released under the Apache 2.0 License.