Identify is a clinician movement-assessment flow. A participant marks how spacious each movement feels. When several movements still look like "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 implement 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 a pick window and stops accepting answers until someone chooses.
Only pick is legal in that window. Pick records hatch presentations for every loser, ends the run as a hypothesis, and keeps hatched items out of remainders. 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.
Compare hatch pick
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