Skip to content

Commit d1fd1cb

Browse files
committed
Allow partial graph components
1 parent b956a43 commit d1fd1cb

3 files changed

Lines changed: 77 additions & 35 deletions

File tree

lib/galaxy/managers/history_graph.py

Lines changed: 38 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -113,30 +113,37 @@ def build(self) -> HistoryGraphResponse:
113113
# Step 4-5: Build edges with batched resolution
114114
edges = self._build_edges(tr_ids, dataset_ids, collection_ids, node_set)
115115

116-
# Step 6: Apply representability — only tool_requests with both input AND output edges
117-
representable_trs, omitted_count = self._filter_representable(tr_ids, edges)
116+
# Step 6: Classify tool_requests by edge completeness
117+
included_trs, partial_trs, omitted_count = self._classify_tool_request_completeness(tr_ids, edges)
118118
truncation.tool_requests_omitted = omitted_count
119+
truncation.tool_requests_partial = len(partial_trs)
119120

120-
# Filter edges to only representable tool_requests
121-
repr_tr_encoded = {self._encode("tool_request", tid) for tid in representable_trs}
121+
# Filter edges to only included tool_requests (drop edges for isolated TRs)
122+
incl_tr_encoded = {self._encode("tool_request", tid) for tid in included_trs}
122123
edges = [
123124
e
124125
for e in edges
125-
if (e.source in repr_tr_encoded or not e.source.startswith("r"))
126-
and (e.target in repr_tr_encoded or not e.target.startswith("r"))
126+
if (e.source in incl_tr_encoded or not e.source.startswith("r"))
127+
and (e.target in incl_tr_encoded or not e.target.startswith("r"))
127128
]
128129

129-
# Final node set: all selected items + representable tool_requests
130+
# Final node set: all selected items + included tool_requests
130131
final_dataset_ids = dataset_ids
131132
final_collection_ids = collection_ids
132-
final_tr_ids = representable_trs
133+
final_tr_ids = included_trs
133134

134135
# Step 7: Fetch metadata
135136
nodes: list[GraphNode] = []
136137
nodes.extend(self._fetch_dataset_metadata(final_dataset_ids))
137138
nodes.extend(self._fetch_collection_metadata(final_collection_ids))
138139
nodes.extend(self._fetch_tool_request_metadata(final_tr_ids))
139140

141+
# Mark partial tool_requests
142+
partial_tr_encoded = {self._encode("tool_request", tid) for tid in partial_trs}
143+
for node in nodes:
144+
if node.id in partial_tr_encoded:
145+
node.partial = True
146+
140147
# Step 8: Resolve tool names
141148
self._resolve_tool_names(nodes)
142149

@@ -793,30 +800,46 @@ def _batch_resolve(
793800

794801
# ── Step 6: Representability ──
795802

796-
def _filter_representable(self, tr_ids: set[int], edges: list[GraphEdge]) -> tuple[set[int], int]:
797-
"""Only include tool_requests with both input AND output edges."""
803+
def _classify_tool_request_completeness(
804+
self, tr_ids: set[int], edges: list[GraphEdge]
805+
) -> tuple[set[int], set[int], int]:
806+
"""Classify tool_requests by edge completeness within the current scope.
807+
808+
A tool_request is:
809+
- **complete**: has at least one resolved input edge AND one resolved output edge
810+
- **partial**: has edges on one side only (input OR output, not both).
811+
This typically means the other side is outside the current scope window,
812+
was suppressed during collapsing, or the tool genuinely has no inputs
813+
(e.g. a generator). The API does not distinguish these cases.
814+
- **isolated**: has no resolved edges at all. These are dropped from the graph.
815+
816+
Returns:
817+
included: tool_requests with at least one edge (complete + partial, kept in graph)
818+
partial: subset of included that are one-sided in the current scope
819+
omitted_count: isolated tool_requests with no edges (dropped)
820+
"""
798821
tr_has_input: set[int] = set()
799822
tr_has_output: set[int] = set()
800823

801824
for e in edges:
802825
if e.target.startswith("r"):
803-
# This is an input edge (something → tool_request)
804826
try:
805827
tr_db_id = self.security.decode_id(e.target[1:])
806828
tr_has_input.add(tr_db_id)
807829
except Exception:
808830
pass
809831
if e.source.startswith("r"):
810-
# This is an output edge (tool_request → something)
811832
try:
812833
tr_db_id = self.security.decode_id(e.source[1:])
813834
tr_has_output.add(tr_db_id)
814835
except Exception:
815836
pass
816837

817-
representable = tr_ids & tr_has_input & tr_has_output
818-
omitted = len(tr_ids) - len(representable)
819-
return representable, omitted
838+
has_any_edge = (tr_has_input | tr_has_output) & tr_ids
839+
has_both = tr_has_input & tr_has_output & tr_ids
840+
partial = has_any_edge - has_both
841+
omitted_count = len(tr_ids) - len(has_any_edge)
842+
return has_any_edge, partial, omitted_count
820843

821844
# ── Node metadata ──
822845

lib/galaxy/schema/history_graph.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
Optional,
44
)
55

6-
from pydantic import BaseModel
6+
from pydantic import BaseModel, Field
77

88

99
class GraphNode(BaseModel):
@@ -18,6 +18,13 @@ class GraphNode(BaseModel):
1818
visible: Optional[bool] = None
1919
tool_id: Optional[str] = None
2020
tool_name: Optional[str] = None
21+
partial: Optional[bool] = Field(
22+
default=None,
23+
description="True if this tool_request has edges on only one side (input or output) "
24+
"within the current graph scope. This can mean the other side is outside the scope "
25+
"window, was suppressed during collapsing, or the tool genuinely has no inputs. "
26+
"None for fully connected tool_requests and non-tool_request nodes.",
27+
)
2128

2229

2330
class GraphEdge(BaseModel):
@@ -30,6 +37,7 @@ class TruncationInfo(BaseModel):
3037
item_count_capped: bool = False
3138
tool_request_count_capped: bool = False
3239
tool_requests_omitted: int = 0
40+
tool_requests_partial: int = 0
3341
scope_type: Literal["recent", "window", "seed_centered"] = "recent"
3442
oldest_hid_included: Optional[int] = None
3543
newest_hid_included: Optional[int] = None

test/unit/app/managers/test_HistoryGraphBuilder.py

Lines changed: 30 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -566,8 +566,8 @@ def test_hidden_non_element_hda_included(self):
566566
node_ids = {n.id for n in graph.nodes}
567567
assert hidden_enc in node_ids
568568

569-
def test_truncation_boundary_omits_partial_tool_requests(self):
570-
"""Tool_requests crossing the scope boundary are omitted by representability."""
569+
def test_truncation_boundary_marks_partial_tool_requests(self):
570+
"""Tool_requests crossing the scope boundary are kept but marked partial."""
571571
history, _ = self._create_history()
572572
# Create chain: input -> tr -> output
573573
input_hda = self._create_hda(history, name="input")
@@ -577,13 +577,14 @@ def test_truncation_boundary_omits_partial_tool_requests(self):
577577
self._link_job_input_hda(job, input_hda)
578578
self._link_job_output_hda(job, output_hda)
579579

580-
# Use limit=1 — only one item in scope, tool_request can't have both input+output
580+
# Use limit=1 — only one item in scope, tool_request has only one side
581581
graph = self._build_graph(history, limit=1)
582582

583-
# The tool_request should be omitted (not representable with only 1 item)
583+
# The tool_request should be present but marked partial
584584
tr_nodes = [n for n in graph.nodes if n.type == "tool_request"]
585-
assert len(tr_nodes) == 0
586-
assert graph.truncated.tool_requests_omitted >= 1
585+
assert len(tr_nodes) == 1
586+
assert tr_nodes[0].partial is True
587+
assert graph.truncated.tool_requests_partial >= 1
587588
assert graph.truncated.item_count_capped is True
588589

589590
def test_deleted_items_with_include_deleted(self):
@@ -905,11 +906,11 @@ def test_pagination_newer_than_hid(self):
905906
assert n.hid > boundary_hid, f"Node hid {n.hid} should be > {boundary_hid}"
906907
assert len(graph.nodes) == 3 # hdas[3], hdas[4], hdas[5]
907908

908-
def test_pagination_boundary_tool_requests_omitted(self):
909-
"""Tool_request with input on one page and output on another is omitted.
909+
def test_pagination_boundary_marks_partial_tool_requests(self):
910+
"""Tool_request with input on one page and output on another is marked partial.
910911
911912
When pagination splits a tool chain, tool_requests that lose either
912-
their input or output edge are omitted by representability.
913+
their input or output edge are kept but marked partial.
913914
"""
914915
history, _ = self._create_history()
915916
input_hda = self._create_hda(history, name="input")
@@ -919,16 +920,19 @@ def test_pagination_boundary_tool_requests_omitted(self):
919920
self._link_job_input_hda(job, input_hda)
920921
self._link_job_output_hda(job, output_hda)
921922

922-
# Full graph: tool_request is representable
923+
# Full graph: tool_request is fully representable
923924
full = self._build_graph(history)
924-
assert any(n.type == "tool_request" for n in full.nodes)
925-
assert full.truncated.tool_requests_omitted == 0
925+
full_tr = [n for n in full.nodes if n.type == "tool_request"]
926+
assert len(full_tr) == 1
927+
assert full_tr[0].partial is None
928+
assert full.truncated.tool_requests_partial == 0
926929

927-
# Page with only the input (older items): tool_request loses its output
930+
# Page with only the input (older items): tool_request loses its output → partial
928931
graph = self._build_graph(history, older_than_hid=output_hda.hid)
929932
tr_nodes = [n for n in graph.nodes if n.type == "tool_request"]
930-
assert len(tr_nodes) == 0
931-
assert graph.truncated.tool_requests_omitted >= 1
933+
assert len(tr_nodes) == 1
934+
assert tr_nodes[0].partial is True
935+
assert graph.truncated.tool_requests_partial >= 1
932936

933937
def test_stability_new_items_shift_recent_window(self):
934938
"""Adding new items shifts the recent-overview window.
@@ -1141,17 +1145,24 @@ def test_deep_linear_chain(self):
11411145
item_nodes = [nd for nd in graph.nodes if nd.type != "tool_request"]
11421146
assert len(item_nodes) <= limit
11431147

1144-
# For linear chain: each representable TR has exactly 2 edges
1148+
# For linear chain: each full TR has 2 edges, partial TRs have 1
11451149
tr_nodes = [nd for nd in graph.nodes if nd.type == "tool_request"]
1146-
assert len(graph.edges) == 2 * len(tr_nodes)
1150+
full_trs = [nd for nd in tr_nodes if not nd.partial]
1151+
partial_trs = [nd for nd in tr_nodes if nd.partial]
1152+
assert len(graph.edges) == 2 * len(full_trs) + len(partial_trs)
11471153

1148-
# Representability: every TR node has both input and output edges
1149-
for tr_node in tr_nodes:
1154+
# Full TRs have both input and output edges
1155+
for tr_node in full_trs:
11501156
incoming = [e for e in graph.edges if e.target == tr_node.id]
11511157
outgoing = [e for e in graph.edges if e.source == tr_node.id]
11521158
assert len(incoming) >= 1
11531159
assert len(outgoing) >= 1
11541160

1161+
# Partial TRs have at least one edge
1162+
for tr_node in partial_trs:
1163+
edges = [e for e in graph.edges if e.target == tr_node.id or e.source == tr_node.id]
1164+
assert len(edges) >= 1
1165+
11551166
def test_collection_heavy_map_over(self):
11561167
"""M collections × K elements each, with map-over tool execution.
11571168

0 commit comments

Comments
 (0)