Identify is a clinician movement-assessment flow backed by a Convex reducer. A participant sits with a clinician and marks how spacious each movement feels against a spreadsheet of IF/THEN paths. When several movements still read as "yes", the engine does not guess. It runs Compare: a second pass over the same presented set so the person can mark spaciousness again with the overlaps in view.
Compare is easy to wire badly. You show the yes chips again and hope the participant remembers which one felt best. They tap the same two. You loop. Nobody learns. Starting Low routing cannot fire yet because nobody has picked a winner.
The stall is a state, not a retry
When at least two items stay yes and the set did not shrink, you are stuck. Re-entering Compare with the same chips teaches nothing. The reducer sets awaitingPick to true and stops accepting answers until someone chooses. That flag means only a pick event is legal; compare answers are rejected until the clinician names a winner.
Pick records hatch presentations for every loser (ruled-out movements logged for metrics), ends the run as a hypothesis, and keeps hatched items out of remainders. Hatch here is not a UI animation. It is the engine marking losers as closed so they cannot re-enter the queue later. A held yes with leftover "little" groups is not a hypothesis until Compare finishes. Little counts can still feed metrics. They do not sneak into the remainder queue while Compare is unresolved.
If the yes set did shrink but still has two or more entries, you loop Compare on the smaller set. That loop is doing work: the set changed. If it drops to one, normal step rules take over. The hatch path exists for the flat yes set that will not budge.
The third identical question
I tried looping Compare. Clinicians already hated that in the sheet. The participant has already said both feel spacious. A third identical question is not data.
Auto-picking the first yes would have made the engine look decisive and been wrong. The human has to name the winner. The engine freezes the run until they do, then hatches the rest so remainders do not resurrect a loser later.
In the demo below, leave Shoulder roll and Hip hinge on yes (the default), press Finish Compare without toggling chips, and read the status line about awaitingPick. Click one yes chip to pick a hypothesis; the other yes chip should show hatched. To see Compare loop instead, before Finish Compare turn off one default yes and mark Ankle circle yes so the set changed but still has two yes answers, then finish and read the Compare status line. Press Reset to replay from the opening yes pair.
Simulation
Compare hatch pick
Fake Identify chips. When Compare does not shrink the yes set, pick one hypothesis and hatch the rest.
Compare pass: mark yes chips. Finish with two or more yes answers still matching the opening set.
// finishList on compare:
if (yes.length >= 2 && sameSet(presented, yes)) {
return { ...run, awaitingPick: true };
}
if (yes.length >= 2) {
return enterStep(run, identifyStepIds.compare, catalogue);
}
function pick(run, presentableRef, now) {
if (!run.awaitingPick) throw new Error('Pick is only valid on an unchanged Compare pass');
// record hatch presentations for losers, end as hypothesis
}Tests that name the fork
Vitest covers the hatch path with a fixed catalogue: two yes chips in, identical yes chips out, pick event, terminal hypothesis, losers absent from remainders. The UI can mirror those flags later. The reducer already knows the difference between "compare again" and "pick now."
Clinicians already knew this from the sheet. A stalled compare had a handwritten "pick one" note in the margin. The engine just did not have a state for that note. Once pick is a first-class event, the admin UI can render a chooser later without inventing a second rule. The reducer already decided when the chooser is legal. The screen only has to respect the flag.
If you are building a guided assessment, treat an unchanged comparison as a terminal choice, not another lap. Looping the same chips feels like progress. It is just a nicer infinite loop.
Happy coding! Sander