Skip to main content

November 2, 2026

Compare a rewrite after every event

Porting routing from hand-written logic to a declarative flow? Drive both from one seeded client and compare what each waits for after every event.

Sander Korf4 min read
testingtypescriptviteststate-machines

A voice-guided session app walks a client through a structured session step by step with spoken prompts. In one step the client watches short videos of hand movements, tries each movement, and rates it (yes, a little, no, not sure). The app narrows down, compares, and either lands on the movement that helped most or ends honestly with none.

That step used to route itself with hand-written logic behind the screen. Call that the replay: a pure function from the list of client events so far to what the screen waits for next. The team is moving all session routing into one declarative flow, a graph of nodes and routing rules that a flow engine walks so non-engineers can edit steps in admin. The new flow version of this step must behave like the old replay before the product reads it.

Example tests and end-state comparisons miss the failures that matter here. Two implementations can reach the same winner through different questions, comfort breaks, or breathing pauses. A randomized run can miss rare branches entirely. Two implementations that each draw their own random numbers drift apart on the first roll, and then the comparison is noise.

Leave Introduce the off-by-one break checked, choose seed 7, press Run to end in Compare at end mode, and watch PASS even though the new flow offers a comfort break one rating too late. Switch to Compare every event, press Step until the status line shows a seed and event trail, and the panes highlight the first mismatch. Run 50 seeds lights up the coverage strip when rare endings were actually visited.

Compare

Lockstep replay vs end-state diff

Toy movement-rating step. The new flow can pass end checks while diverging mid-path.

Old screen logic

Waiting for

{"gate":"intro","kind":"ready"}

Events: began

New flow

Waiting for

{"gate":"intro","kind":"ready"}

Events: began

Coverage strip (50-seed sweep)

  • Comfort break
  • Pick among winners
  • End: validate
  • End: none

Step with the OLD side driving answers. Toggle compare mode to see end-only green runs.

Compare mode

Lockstep differential replay

The Vitest check I kept while building the port is a lockstep differential replay:

  1. One driver: the old replay decides what to ask next. A seeded random client answers, and that single event is fed to both implementations (translated into the new engine event shape).
  2. After every event, project both sides into one shared waiting type (ready at gate X, watch movement M, rate M, pick among [...], move on with winner W, done: validate | stop | none) and compare them. On mismatch, fail with the seed and the event trail, for example Seed 137, after began > ready > tried > rated: the flow waits for {...}, the replay for {...}.
  3. A small seeded PRNG (linear congruential generator) makes any failing seed replayable.
  4. Sweep the configuration matrix: four worlds (video question asked or not, follow-up validate step on or off) times 250 seeds, one thousand runs in a single test.
  5. Assert coverage: collect every node the new flow visited across all runs and assert that a list of rare places was reached, and that all three kinds of ending occurred. If someone changes the random client and the rare branches stop being reached, the test fails instead of going quietly green.
  6. Spoken beats the screen advances on its own are settled automatically before comparing, so the assertion is about what the client must do next.
type Waiting =
  | { kind: 'ready'; gate: string }
  | { kind: 'rate'; code: string }
  | { kind: 'pick'; among: readonly string[] }
  | { kind: 'done'; end: 'validate' | 'stop' | 'none' }
 
function playOnce(seed: number, world: World) {
  const rand = seeded(seed)
  const events: OldEvent[] = [{ kind: 'began' }]
  let run = startRun(flow)
  for (let step = 0; step < 400; step++) {
    for (const e of toNewEvents(events.at(-1)!, run)) run = engineNext(flow, run, e)
    run = settleSayBeats(flow, run)
    const old = oldReplay(events, world)
    const a = JSON.stringify(newWaiting(flow, run))
    const b = JSON.stringify(oldWaiting(old))
    if (a !== b) throw new Error(`Seed ${seed}, after ${events.map((e) => e.kind).join(' > ')}: new ${a}, old ${b}`)
    if (old.expect.kind === 'done') return Object.keys(run.visits)
    events.push(randomClientAnswer(old.expect, rand))
  }
  throw new Error(`Seed ${seed} never ended`)
}

When you port behaviour from code to data, drive both sides from one event stream, compare what each is waiting for after every single event, make failures replayable by seed, and assert coverage of rare branches so a green run means the rare paths actually ran.

The new flow does not embed movement codes. The screen reports pools of movements to it as events. A structural validator over the flow graph also reports no issues, and a clean structural check still says nothing about behavioural parity. The port happened in slices with this parity test in place before the product read the new flow.

When this does not apply

Example tests are enough when there are only a few states, when intermediate steps are allowed to differ on purpose, or when you cannot seed the nondeterminism. The lockstep pattern pays off when clients feel every question and rare branches must be proven.

Happy coding!

Sander