Repository navigation
validate_workflow: enforce candidates/scores and previous_result/for_each list-identity consistency #213
Description
Split from #208 (approved by Don, scoped by triage's edge-case list to keep it bounded).
tool/endpoint: validate_workflow
background: #119's hand-off comment claimed dw/select_validation.py enforces "candidates/scores gather-consistency," but what actually shipped is only a shape check (both gather: or both plain lists — select_errors line ~87). The claimed list-identity consistency was never implemented.
scope for this fix (per Don, to keep this from sprawling into every edge case triage flagged):
-
select'scandidates/scoresgather-consistency: when aselectstep'scandidatesandscoresare bothgather:references, compare the resolved expansion set (postargumentsoverride, not just static length) each reference drew from. Refuse if they don't match — the twofor_eachgroups must have expanded from the same list. Report atsteps[N].task.arguments.scores(or.candidates), naming the mismatch (e.g. "candidates has 2 entries, scores has 1"). -
previous_result:into afor_eachstep: same rule from the other direction — aprevious_result:reference into afor_eachstep must come from afor_eachstep over the same list. (The Deterministic N→1 selection between a fan-out and an expensive stage (selectreducer +judgescorer) #119 error text already states this for the non-for_eachcase; this closes the gap for thefor_each-into-for_eachcase the repro below exercises.)
Out of scope for this issue (real, but separate — don't fold in): a gather from a non-for_each step, a literal list vs variable: list distinction, member-scoped previous_result:. If those turn out to need their own rules, file separately once this lands.
repro:
{"id": "qa119_mismatch",
"variables": {
"candidates": [{"name":"a","prompt":"a red apple"},{"name":"b","prompt":"a green pear"}],
"other": [{"name":"x","prompt":"a blue bowl"}]},
"seed": 7,
"steps": [
{"name":"still","for_each":"variable:candidates","release_pipeline":true,
"pipeline":{"configuration":{"component_type":"ZImagePipeline","offload":"sequential"},
"from_pretrained_arguments":{"model_name":"Tongyi-MAI/Z-Image-Turbo","torch_dtype":"torch.bfloat16","low_cpu_mem_usage":true},
"arguments":{"prompt":"item:prompt","num_inference_steps":9,"guidance_scale":0,"width":512,"height":512}},
"result":{"content_type":"image/jpeg","subfolder":"intermediate"}},
{"name":"judge","for_each":"variable:other",
"task":{"command":"judge","arguments":{"image":"previous_result:still","rubric":"How red is this?","scale":[0,10],"device":"cuda"}}},
{"name":"pick","task":{"command":"select","arguments":{"candidates":"gather:still","scores":"gather:judge","rule":"argmax"}},
"result":{"content_type":"image/jpeg","subfolder":"final"}}]}expected: valid: false before queueing, flagging both the judge step's previous_result:still (drawn from a different list than judge's own for_each:variable:other) and the pick step's scores/candidates size mismatch.
actual: valid: true with a plan; only diagnostic is an unrelated unused-field warning. The mismatch surfaces only at run time (after the fan-out), or not at all if select silently zips the shorter list.
Filed by triage per Don's approval to release this scoped version of #208.
Activity
dkackman commentedon Sep 18, 2026
triage: work — self-contained and already scoped/approved by Don as the bounded release of #208 (closed), so no escalation (model opus via provider anthropic). Not a duplicate: #208 is the parent, #119 shipped only the shape check (
dw/select_validation.py, both-gather:-or-both-lists). Fix lands indw/select_validation.pyfor rule 1 and besideprevious_result_reference_errors(dw/previous_results.py) /dw/for_each.py's expansion (which already records source indices and the list each group came from) for rule 2 — the "resolved expansion set" the issue asks for is whatexpand_for_eachproduces after the caller'sargumentsare folded invalidation_errors, so compare the expanded member name sets (shot@x) rather than static lengths. Respect the issue's out-of-scope list; file new issues for anything outside it. Kept separate from #212 (lighter, different file) so this session has its full budget.dkackman commentedon Sep 18, 2026
triage: work — re-confirmed on 2026-09-18 re-triage; nothing on develop or any branch touches it yet (
git log develop/git branch -ashow no #213 work). The earlier triage comment's pointers stand (dw/select_validation.pyfor rule 1;dw/previous_results.py+dw/for_each.pyexpansion for rule 2, comparing expanded member-name sets post-arguments). Not a duplicate of #212 — different files; the #212 branch does not overlap this one. (model opus via provider anthropic)dkackman commentedon Sep 18, 2026
Fixed on develop (commit f6eba42, merged bf6cc58), deployed to lem 2026-09-18 12:47 UTC.
Rule 1 (candidates/scores gather-consistency) — fixed.
select_errorsindw/select_validation.pynever compared resolved gather-expansion sizes: by the time it runs (afterfor_eachexpansion inWorkflow.validation_errors()), agather:<group>reference has already been rewritten by_gather()(dw/for_each.py) into an explicit list ofprevious_result:<group>@<key>strings — so the pre-existing_is_gather()literal-string check never fired on a real, integration-level call. Added_is_expanded_gather()plus a size-comparison branch that operates on that post-expansion list shape, reporting atsteps[N].task.arguments.scores(or.candidates) with a message naming both sizes, e.g.:Verified against a purpose-built repro isolating rule 1 (judge step referencing a fixed asset instead of
previous_result:, so only the gather-size mismatch trips) — empty[]before the fix, single correctly-worded error after. Two new unit tests added intests/test_select_validation.py. Full suite: 5041 passed, 16 skipped, no regressions.Rule 2 (
previous_result:into afor_eachstep must come from afor_eachover the same list) — already covered, no change needed. This is_rewrite_referenceindw/for_each.py, which already raises aForEachErrorwhen aprevious_result:reference names a for_each group whoseentriesdiffer from the referencing member's own entries.git log -Straces this to commit3b7e856, predating both #119 and this issue's filing. I ran the issue's own repro JSON (qa119_mismatch) directly against currentdevelopand gotvalid: false, with:This contradicts the issue's stated "actual: valid: true" — that must reflect an earlier revision. No code change made for rule 2; closing it out as already resolved.
Note on the issue's original repro: because
for_eachexpansion is fail-fast inWorkflow.validation_errors()(an expansion failure short-circuits and returns only that one error), a single repro that trips rule 2 cannot also surface rule 1 in the same validation call — they were verified with two separate repros for that reason.Proposed regression-suite-smoke case:
validate_workflowon a workflow whoseselectstep gatherscandidatesfrom a for_each group of size 2 andscoresfrom a for_each group of size 1 (both groups otherwise valid, no rule-2 violation).valid: false, one error atsteps[N].task.arguments.scores, message containing "different sizes" and both entry counts ("2 entries", "1").Model/provider: sonnet / anthropic.
dkackman commentedon Sep 18, 2026
Verified over MCP (
validate_workflow, livedwserver on lem) — model opus via provider anthropic.Ran 5 calls:
qa119_mismatch) →valid: false, one error atsteps[1].task.arguments.image: "'still' names the for_each step 'still' from inside for_each step 'judge', which runs over a different list. Use 'gather:still' …". Rule 2 fires. Consistent with the implementer's fail-fast note — rule 1 can't co-surface in the same call, so it was checked separately.asset:qa-cast/hal-portrait.jpginstead ofprevious_result:;candidatesgroup 2,scoresgroup 1) →valid: false, one error atsteps[2].task.arguments.scores: "select: 'candidates' and 'scores' gather from for_each groups of different sizes: candidates has 2 entries, scores has 1."valid: true, planlist_entries: {candidates: 2, other: 2}, only the unrelated unused-promptwarnings. No false positive.argumentsoverride path (static lists 2/2,arguments: {"other": [one entry]}) →valid: false, same rule-1 error with "candidates has 2 entries, scores has 1". The issue's "resolved expansion set, postargumentsoverride, not just static length" requirement holds.valid: false, error at.scores: "candidates has 1 entries, scores has 2". Reported at.scoresin both directions rather than.candidates— fine per the issue's ".scores(or.candidates)" wording; noting it only so nobody expects the path to flip.Both rules match
expected:. Adding the implementer's proposed smoke case as S-F055 (case 2 above, plus the override variant). Closing as verified.fix: #213 - select_errors catches gather: candidates/scores size mism…
Develop (#241)