Add implicit collection mapping support to PickValueModule

Detect collection inputs via compute_collection_info and iterate
per-element slices so pick_value runs once per collection element.
For single-value modes output is a flat list; for all_non_null
output is list:list. Adds framework tests for both modes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
John Chilton
2026-03-22 19:15:04 -04:00
co-authored by Claude Opus 4.6
parent fc69697dd8
commit 1263c8d946
5 changed files with 314 additions and 21 deletions
+110 -21
View File
@@ -2067,20 +2067,9 @@ class PickValueModule(WorkflowModule):
return True
return False
def execute(
self, trans, progress: "WorkflowProgress", invocation_step, use_cached_job: bool = False
) -> Optional[bool]:
def _pick_from_replacements(self, trans, invocation_step, mode, replacements):
"""Apply pick logic to a list of replacement values. Returns the picked output."""
step = invocation_step.workflow_step
mode = step.tool_inputs.get("mode", "first_non_null") if step.tool_inputs else "first_non_null"
# Gather replacements from each named input terminal, in order
replacements = []
for input_dict in self.get_all_inputs():
replacement = progress.replacement_for_input(trans, step, input_dict)
if replacement is not NO_REPLACEMENT:
replacements.append(replacement)
# Separate non-null from null/skipped, preserving order
non_null = [r for r in replacements if not self._is_null_or_skipped(r)]
if mode == "first_non_null":
@@ -2091,13 +2080,12 @@ class PickValueModule(WorkflowModule):
workflow_step_id=step.id,
)
)
output = non_null[0]
return non_null[0]
elif mode == "first_or_skip":
if not non_null:
output = self._create_skipped_output(trans, invocation_step)
else:
output = non_null[0]
return self._create_skipped_output(trans, invocation_step)
return non_null[0]
elif mode == "the_only_non_null":
if len(non_null) != 1:
@@ -2107,20 +2095,77 @@ class PickValueModule(WorkflowModule):
workflow_step_id=step.id,
)
)
output = non_null[0]
return non_null[0]
elif mode == "all_non_null":
# CWL spec: all_non_null returns list of non-null values,
# which may be empty if all inputs are null.
output = self._create_collection_from_list(trans, invocation_step, non_null)
return self._create_collection_from_list(trans, invocation_step, non_null)
else:
raise ValueError(f"Unknown pick_value mode: {mode}")
def execute(
self, trans, progress: "WorkflowProgress", invocation_step, use_cached_job: bool = False
) -> Optional[bool]:
step = invocation_step.workflow_step
mode = step.tool_inputs.get("mode", "first_non_null") if step.tool_inputs else "first_non_null"
all_inputs = self.get_all_inputs()
collection_info = self.compute_collection_info(progress, step, all_inputs)
if collection_info:
output = self._execute_mapped(trans, invocation_step, mode, all_inputs, collection_info)
else:
# Gather replacements from each named input terminal, in order
replacements = []
for input_dict in all_inputs:
replacement = progress.replacement_for_input(trans, step, input_dict)
if replacement is not NO_REPLACEMENT:
replacements.append(replacement)
output = self._pick_from_replacements(trans, invocation_step, mode, replacements)
progress.set_step_outputs(invocation_step, {"output": output})
self._apply_post_job_actions(trans, step, output, progress.effective_replacement_dict())
return None
def _execute_mapped(self, trans, invocation_step, mode, all_inputs, collection_info):
"""Execute pick_value mapped over collection inputs."""
invocation = invocation_step.workflow_invocation
history = invocation.history
# Build a map of input_name -> input_dict for quick lookup
input_names = {d["name"] for d in all_inputs}
per_element_outputs = []
for iteration_elements, _when_value in collection_info.slice_collections():
# For each slice, extract per-element replacements
replacements = []
for input_dict in all_inputs:
name = input_dict["name"]
if iteration_elements and name in iteration_elements:
dce = iteration_elements[name]
replacement = dce.hda if hasattr(dce, "hda") and dce.hda else dce.child_collection
else:
# Input not part of the mapped collection — not connected
replacement = NO_REPLACEMENT
if replacement is not NO_REPLACEMENT:
replacements.append(replacement)
element_output = self._pick_from_replacements(trans, invocation_step, mode, replacements)
# Track the identifier from the first mapped input for naming
first_mapped = (
next(
(iteration_elements[n] for n in sorted(iteration_elements) if n in input_names),
None,
)
if iteration_elements
else None
)
identifier = first_mapped.element_identifier if first_mapped else str(len(per_element_outputs))
per_element_outputs.append((identifier, element_output))
# Build the output collection from per-element outputs
return self._create_mapped_output_collection(trans, history, mode, per_element_outputs)
def _create_skipped_output(self, trans, invocation_step):
"""Create a skipped HDA for first_or_skip when all inputs are null."""
invocation = invocation_step.workflow_invocation
@@ -2160,6 +2205,50 @@ class PickValueModule(WorkflowModule):
)
return hdca
def _create_mapped_output_collection(self, trans, history, mode, per_element_outputs):
"""Create an implicit output collection from per-element pick results.
For single-value modes (first_non_null, etc.), creates a flat list of HDAs.
For all_non_null mode, creates a list:list where each element is a sub-collection.
"""
collection_manager = trans.app.dataset_collection_manager
if mode == "all_non_null":
# Each element is an HDCA — build list:list
elements = []
for identifier, hdca in per_element_outputs:
elements.append(
dict(
name=identifier,
src="hdca",
id=hdca.id,
)
)
return collection_manager.create(
trans,
history,
name="Pick Value - mapped all non-null",
collection_type="list:list",
element_identifiers=elements,
)
else:
# Each element is an HDA — build flat list
elements = []
for identifier, hda in per_element_outputs:
elements.append(
dict(
name=identifier,
src="hda",
id=hda.id,
)
)
return collection_manager.create(
trans,
history,
name="Pick Value - mapped",
collection_type="list",
element_identifiers=elements,
)
def _apply_post_job_actions(self, trans, step, output, replacement_dict):
"""Apply post job actions directly to module output via ActionBox.
@@ -0,0 +1,55 @@
- doc: |
Map over pick_value(all_non_null) with both branches active.
Each input element produces a 2-element inner list.
Output is list:list with 2 outer elements.
job:
input_collection:
class: Collection
collection_type: list
elements:
- identifier: el1
class: File
contents: "line1"
filetype: bed
- identifier: el2
class: File
contents: "line2"
filetype: bed
when_a:
type: raw
value: true
when_b:
type: raw
value: true
outputs:
picked:
class: Collection
collection_type: list:list
element_count: 2
- doc: |
Map over pick_value(all_non_null) with one null branch.
Each element produces a 1-element inner list.
job:
input_collection:
class: Collection
collection_type: list
elements:
- identifier: el1
class: File
contents: "line1"
filetype: bed
- identifier: el2
class: File
contents: "line2"
filetype: bed
when_a:
type: raw
value: true
when_b:
type: raw
value: false
outputs:
picked:
class: Collection
collection_type: list:list
element_count: 2
@@ -0,0 +1,38 @@
class: GalaxyWorkflow
inputs:
input_collection:
type: collection
collection_type: list
when_a:
type: boolean
when_b:
type: boolean
outputs:
picked:
outputSource: pick/output
steps:
branch_a:
tool_id: cat
in:
input1:
source: input_collection
when:
source: when_a
when: $(inputs.when)
branch_b:
tool_id: cat
in:
input1:
source: input_collection
when:
source: when_b
when: $(inputs.when)
pick:
type: pick_value
in:
input_0:
source: branch_a/out_file1
input_1:
source: branch_b/out_file1
state:
mode: all_non_null
@@ -0,0 +1,73 @@
- doc: |
Map over pick_value(first_non_null) with both branches active.
Each input element picks from branch_a, output is a list with same
element count as the input collection.
job:
input_collection:
class: Collection
collection_type: list
elements:
- identifier: el1
class: File
contents: "line1"
filetype: bed
- identifier: el2
class: File
contents: "line2"
filetype: bed
when_a:
type: raw
value: true
when_b:
type: raw
value: true
outputs:
picked:
class: Collection
collection_type: list
element_count: 2
elements:
el1:
asserts:
- that: has_text
text: "line1"
el2:
asserts:
- that: has_text
text: "line2"
- doc: |
Map over pick_value(first_non_null) with only branch_b active.
Each element should pick from branch_b (branch_a is skipped).
job:
input_collection:
class: Collection
collection_type: list
elements:
- identifier: el1
class: File
contents: "line1"
filetype: bed
- identifier: el2
class: File
contents: "line2"
filetype: bed
when_a:
type: raw
value: false
when_b:
type: raw
value: true
outputs:
picked:
class: Collection
collection_type: list
element_count: 2
elements:
el1:
asserts:
- that: has_text
text: "line1"
el2:
asserts:
- that: has_text
text: "line2"
@@ -0,0 +1,38 @@
class: GalaxyWorkflow
inputs:
input_collection:
type: collection
collection_type: list
when_a:
type: boolean
when_b:
type: boolean
outputs:
picked:
outputSource: pick/output
steps:
branch_a:
tool_id: cat
in:
input1:
source: input_collection
when:
source: when_a
when: $(inputs.when)
branch_b:
tool_id: cat
in:
input1:
source: input_collection
when:
source: when_b
when: $(inputs.when)
pick:
type: pick_value
in:
input_0:
source: branch_a/out_file1
input_1:
source: branch_b/out_file1
state:
mode: first_non_null