Skip to content

Commit 0654b7b

Browse files
committed
✨ graphs: evaluate inline comparisons without a task
1 parent 71e1c0a commit 0654b7b

9 files changed

Lines changed: 180 additions & 34 deletions

File tree

‎src/aiida/engine/processes/graphs/__init__.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from aiida.engine.processes.graphs.build_source import GraphHandle, graph, lower_from_source, task
1313
from aiida.engine.processes.graphs.ir import (
1414
BranchControl,
15+
Comparison,
1516
Dependency,
1617
Endpoint,
1718
GraphIR,
@@ -24,6 +25,7 @@
2425

2526
__all__ = (
2627
'BranchControl',
28+
'Comparison',
2729
'Dependency',
2830
'Endpoint',
2931
'GraphHandle',

‎src/aiida/engine/processes/graphs/build_source.py‎

Lines changed: 51 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
from aiida.engine.processes.graphs.ir import (
3131
CONDITION_PORT,
3232
BranchControl,
33+
Comparison,
3334
Dependency,
3435
Endpoint,
3536
GraphIR,
@@ -503,10 +504,14 @@ def _lower_branch(state: _LoweringState, statement: ast.If) -> None:
503504
_reject(state, statement, 'condition conflicts with a branch input; rename one of them')
504505
instance = f'branch_{len(state.tasks) + 1}'
505506
given: dict[str, CapturedValue] = {}
506-
_wire(state, instance, CONDITION_PORT, _lower_value(state, test), given)
507+
comparison = _lower_comparison(state, instance, test, given)
508+
if comparison is None:
509+
_wire(state, instance, CONDITION_PORT, _lower_value(state, test), given)
507510
for name in sorted({path.split('.')[0] for side in (first, second) for path in side.child.inputs}):
508511
_wire(state, instance, name, state.names[name], given)
509-
state.tasks.append(BranchControl(name=instance, inputs=given, body=first.body, otherwise=second.body))
512+
state.tasks.append(
513+
BranchControl(name=instance, inputs=given, body=first.body, otherwise=second.body, comparison=comparison)
514+
)
510515
selected = first.child.names[first.name]
511516
state.names[first.name] = _Reference(instance, selected.port, selected.shape)
512517

@@ -521,9 +526,11 @@ def _lower_loop(state: _LoweringState, statement: ast.While) -> None:
521526
ast.copy_location(update.targets[0], statement.test)
522527
body.append(update)
523528
test: ast.expr = bound if bound is not None else statement.test
524-
if statement.orelse or not isinstance(test, ast.Name) or test.id not in state.names:
525-
_reject(state, statement, 'while requires a bound condition name and no else')
526-
condition = test.id
529+
if statement.orelse or (
530+
not isinstance(test, ast.Compare) and (not isinstance(test, ast.Name) or test.id not in state.names)
531+
):
532+
_reject(state, statement, 'while requires a bound condition or comparison and no else')
533+
condition = test.id if isinstance(test, ast.Name) else CONDITION_PORT
527534
child = _collect_region_inputs(state, body)
528535
# Loop state is read from the previous iteration, including values rebound in the body.
529536
assigned: set[str] = {
@@ -536,7 +543,7 @@ def _lower_loop(state: _LoweringState, statement: ast.While) -> None:
536543
_reject(state, statement, f'loop state {name!r} must be initialized before the loop')
537544
child.inputs.setdefault(name, [])
538545
child.names[name] = _Reference(None, name, state.names[name].shape)
539-
if condition not in assigned:
546+
if not isinstance(test, ast.Compare) and condition not in assigned:
540547
_reject(state, statement, 'while body must update its condition; rebind the condition name in the body')
541548
for node in body:
542549
_lower_assignment(child, node, rebind=True)
@@ -545,7 +552,12 @@ def _lower_loop(state: _LoweringState, statement: ast.While) -> None:
545552
instance = f'loop_{len(state.tasks) + 1}'
546553
given: dict[str, CapturedValue] = {}
547554
_route_region(state, child, instance, given)
548-
state.tasks.append(LoopControl(name=instance, inputs=given, body=body_spec, condition_port=condition))
555+
comparison = _lower_comparison(state, instance, test, given) if isinstance(test, ast.Compare) else None
556+
if comparison is None and isinstance(test, ast.Compare):
557+
_reject(state, test, _unsupported_reason(test))
558+
state.tasks.append(
559+
LoopControl(name=instance, inputs=given, body=body_spec, condition_port=condition, comparison=comparison)
560+
)
549561
for name in assigned:
550562
state.names[name] = _Reference(instance, name, child.names[name].shape)
551563

@@ -611,6 +623,38 @@ def _bind_condition(state: _LoweringState, test: ast.expr) -> ast.Name | None:
611623
return ast.Name(id=name, ctx=ast.Load())
612624

613625

626+
_COMPARISON_OPERATORS: dict[type[ast.cmpop], str] = {
627+
ast.Eq: '==',
628+
ast.NotEq: '!=',
629+
ast.Lt: '<',
630+
ast.LtE: '<=',
631+
ast.Gt: '>',
632+
ast.GtE: '>=',
633+
}
634+
635+
636+
def _lower_comparison(
637+
state: _LoweringState, instance: str, test: ast.expr, given: dict[str, CapturedValue]
638+
) -> Comparison | None:
639+
"""Lower one comparison over names and literals, or decline anything richer."""
640+
if not isinstance(test, ast.Compare) or len(test.ops) != 1 or type(test.ops[0]) not in _COMPARISON_OPERATORS:
641+
return None
642+
operands: list[str | int | float | bool | None] = []
643+
for side in (test.left, *test.comparators):
644+
if isinstance(side, ast.Constant) and (side.value is None or isinstance(side.value, (int, float, str, bool))):
645+
operands.append(side.value)
646+
elif isinstance(side, ast.Name):
647+
lowered = _lower_value(state, side)
648+
if not isinstance(lowered, _Reference):
649+
return None
650+
_wire(state, instance, side.id, lowered, given)
651+
operands.append(side.id)
652+
else:
653+
return None
654+
left, right = operands
655+
return Comparison(operator=_COMPARISON_OPERATORS[type(test.ops[0])], left=left, right=right)
656+
657+
614658
def _snapshot_namespace_boundary(state: _LoweringState) -> dict[str, t.Any] | None:
615659
"""Snapshot region boundaries only when routing a declared namespace field."""
616660
if not any('.' in path for path in state.inputs):

‎src/aiida/engine/processes/graphs/ir.py‎

Lines changed: 57 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626

2727
__all__ = (
2828
'BranchControl',
29+
'Comparison',
2930
'Dependency',
3031
'Endpoint',
3132
'GraphIR',
@@ -35,7 +36,7 @@
3536
'TaskIR',
3637
)
3738

38-
GRAPH_IR_VERSION = '0.1'
39+
GRAPH_IR_VERSION = '0.2'
3940
TASK_IR_VERSION = '0.1'
4041
CONDITION_PORT = 'condition'
4142
TaskKind = t.Literal['process', 'graph', 'branch', 'loop']
@@ -269,13 +270,15 @@ def from_dict(cls, data: Mapping[str, t.Any]) -> GraphTask:
269270
body=body,
270271
otherwise=GraphIR.from_dict(data['otherwise']) if data.get('otherwise') is not None else None,
271272
condition_port=data.get('condition_port', CONDITION_PORT),
273+
comparison=Comparison.from_dict(data['comparison']) if data.get('comparison') is not None else None,
272274
**common,
273275
)
274276
if kind == 'loop':
275277
assert body is not None
276278
return LoopControl(
277279
body=body,
278280
condition_port=data.get('condition_port', CONDITION_PORT),
281+
comparison=Comparison.from_dict(data['comparison']) if data.get('comparison') is not None else None,
279282
max_iterations=data['max_iterations'],
280283
**common,
281284
)
@@ -309,12 +312,44 @@ class SubgraphTask(BodyTask):
309312
KIND: t.ClassVar[TaskKind] = 'graph'
310313

311314

315+
@dataclass(frozen=True, kw_only=True)
316+
class Comparison:
317+
"""A comparison over control inputs or literals, evaluated without a task."""
318+
319+
operator: str
320+
left: str | int | float | bool | None
321+
right: str | int | float | bool | None
322+
323+
def __post_init__(self) -> None:
324+
if self.operator not in COMPARISON_OPERATORS:
325+
msg = f'unsupported comparison operator {self.operator!r}; use one of {sorted(COMPARISON_OPERATORS)}.'
326+
raise GraphTypeCheckError(msg)
327+
328+
@property
329+
def ports(self) -> tuple[str, ...]:
330+
"""Return the control input ports this comparison reads."""
331+
return tuple(side for side in (self.left, self.right) if isinstance(side, str))
332+
333+
def to_dict(self) -> dict[str, t.Any]:
334+
"""Return the comparison without its evaluated values."""
335+
return {'operator': self.operator, 'left': self.left, 'right': self.right}
336+
337+
@classmethod
338+
def from_dict(cls, data: Mapping[str, t.Any]) -> Comparison:
339+
"""Rebuild a comparison from its serialized form."""
340+
return cls(operator=data['operator'], left=data.get('left'), right=data.get('right'))
341+
342+
343+
COMPARISON_OPERATORS = ('==', '!=', '<', '<=', '>', '>=')
344+
345+
312346
@dataclass(frozen=True, kw_only=True)
313347
class BranchControl(BodyTask):
314348
"""A condition dispatching exactly one of two nested templates."""
315349

316350
KIND: t.ClassVar[TaskKind] = 'branch'
317351
condition_port: str = CONDITION_PORT
352+
comparison: Comparison | None = None
318353
otherwise: GraphIR | None = None
319354

320355
@property
@@ -331,6 +366,7 @@ def to_dict(self) -> dict[str, t.Any]:
331366
return {
332367
**super().to_dict(),
333368
'condition_port': self.condition_port,
369+
'comparison': None if self.comparison is None else self.comparison.to_dict(),
334370
'otherwise': None if self.otherwise is None else self.otherwise.to_dict(),
335371
}
336372

@@ -341,10 +377,16 @@ class LoopControl(BodyTask):
341377

342378
KIND: t.ClassVar[TaskKind] = 'loop'
343379
condition_port: str = CONDITION_PORT
380+
comparison: Comparison | None = None
344381
max_iterations: int = 1000
345382

346383
def to_dict(self) -> dict[str, t.Any]:
347-
return {**super().to_dict(), 'condition_port': self.condition_port, 'max_iterations': self.max_iterations}
384+
return {
385+
**super().to_dict(),
386+
'condition_port': self.condition_port,
387+
'comparison': None if self.comparison is None else self.comparison.to_dict(),
388+
'max_iterations': self.max_iterations,
389+
}
348390

349391

350392
@dataclass(frozen=True, kw_only=True)
@@ -369,7 +411,11 @@ def to_dict(self) -> dict[str, t.Any]:
369411

370412
def _accepts_condition(task: GraphTask, port: str | None) -> bool:
371413
"""Return whether a control task wires its condition outside declared inputs."""
372-
return isinstance(task, (BranchControl, LoopControl)) and port == task.condition_port
414+
if not isinstance(task, (BranchControl, LoopControl)):
415+
return False
416+
if port == task.condition_port:
417+
return True
418+
return task.comparison is not None and port in task.comparison.ports
373419

374420

375421
def _stored_types(port: t.Any) -> tuple[type, ...]:
@@ -515,7 +561,9 @@ def serialize_inputs(self, inputs: Mapping[str, t.Any]) -> dict[str, t.Any]:
515561

516562
@staticmethod
517563
def _input_ports_at(task: GraphTask, path: str) -> list[t.Any]:
518-
if isinstance(task, (BranchControl, LoopControl)) and path == task.condition_port:
564+
if isinstance(task, (BranchControl, LoopControl)) and (
565+
path == task.condition_port or (task.comparison is not None and path in task.comparison.ports)
566+
):
519567
return []
520568
return [port for namespace in task.input_ports() if (port := port_at(namespace, path)) is not None]
521569

@@ -630,7 +678,11 @@ def _validate_region(self, task: GraphTask) -> None:
630678
msg = 'both branches must return the same outputs.'
631679
raise GraphTypeCheckError(msg)
632680
self._check_compatible(task.body.output_spec(), task.otherwise.output_spec(), 'branch outputs')
633-
if isinstance(task, LoopControl) and (task.condition_port not in task.body.outputs or task.max_iterations < 1):
681+
if (
682+
isinstance(task, LoopControl)
683+
and task.comparison is None
684+
and (task.condition_port not in task.body.outputs or task.max_iterations < 1)
685+
):
634686
msg = 'a loop must return its condition and allow at least one iteration.'
635687
raise GraphTypeCheckError(msg)
636688

‎src/aiida/engine/processes/graphs/run.py‎

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from aiida.engine.processes.graphs.inputs import at, place
1919
from aiida.engine.processes.graphs.ir import (
2020
BranchControl,
21+
Comparison,
2122
GraphIR,
2223
GraphTask,
2324
LoopControl,
@@ -37,6 +38,28 @@ def holds(condition: t.Any) -> bool:
3738
return bool(condition.value if isinstance(condition, BaseType) else condition)
3839

3940

41+
def evaluate(comparison: Comparison, values: Mapping[str, t.Any]) -> bool:
42+
"""Decide a comparison from control inputs or iteration outputs."""
43+
left: t.Any = values[comparison.left] if isinstance(comparison.left, str) else comparison.left
44+
right: t.Any = values[comparison.right] if isinstance(comparison.right, str) else comparison.right
45+
left = left.value if isinstance(left, BaseType) else left
46+
right = right.value if isinstance(right, BaseType) else right
47+
if comparison.operator == '==':
48+
return bool(left == right)
49+
if comparison.operator == '!=':
50+
return bool(left != right)
51+
if comparison.operator == '<':
52+
return bool(left < right)
53+
if comparison.operator == '<=':
54+
return bool(left <= right)
55+
if comparison.operator == '>':
56+
return bool(left > right)
57+
if comparison.operator == '>=':
58+
return bool(left >= right)
59+
msg = f'unsupported comparison operator {comparison.operator!r}.'
60+
raise RuntimeError(msg)
61+
62+
4063
def returned(node: Node) -> dict[str, t.Any]:
4164
"""Read the leaf outputs of one graph iteration."""
4265
return {entry.link_label: entry.node for entry in node.base.links.get_outgoing(link_type=LinkType.RETURN).all()}
@@ -209,7 +232,11 @@ def _begin(self, name: str, step: Step) -> None:
209232

210233
def _begin_branch(self, task: BranchControl, inputs: dict[str, t.Any], step: Step) -> None:
211234
"""Dispatch the taken side of a branch, skipping when it has no body."""
212-
taken = task.body if holds(inputs.pop(task.condition_port, None)) else task.otherwise
235+
if task.comparison is not None:
236+
decision = evaluate(task.comparison, inputs)
237+
else:
238+
decision = holds(inputs.pop(task.condition_port, None))
239+
taken = task.body if decision else task.otherwise
213240
if taken is None:
214241
self.skipped.add(task.name)
215242
return
@@ -219,7 +246,11 @@ def _begin_branch(self, task: BranchControl, inputs: dict[str, t.Any], step: Ste
219246

220247
def _begin_loop(self, task: LoopControl, inputs: dict[str, t.Any], step: Step) -> None:
221248
"""Start iterating a loop, or pass through never-entered initial state."""
222-
if not holds(inputs.get(task.condition_port, True)):
249+
if task.comparison is not None:
250+
if not evaluate(task.comparison, inputs):
251+
self._pass_initial_state(task, inputs)
252+
return
253+
elif not holds(inputs.get(task.condition_port, True)):
223254
self._pass_initial_state(task, inputs)
224255
return
225256
self.instances[task.name] = []
@@ -236,7 +267,12 @@ def _continue_loop(self, task: LoopControl, step: Step) -> None:
236267
if not runs or any(instance not in self.done for instance in runs):
237268
return
238269
last = load_node(self.done[runs[-1]])
239-
if not last.is_finished_ok or not holds(returned(last).get(task.condition_port)):
270+
if not last.is_finished_ok:
271+
return
272+
if task.comparison is not None:
273+
if not evaluate(task.comparison, {**self._inputs_for(task), **returned(last)}):
274+
return
275+
elif not holds(returned(last).get(task.condition_port)):
240276
return
241277
if len(runs) >= task.max_iterations:
242278
msg = f'loop `{task.name}` exceeded {task.max_iterations} iterations with its condition still true.'

‎tests/engine/processes/graphs/language/constructs/conditional/rejected.py‎

Lines changed: 0 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@
1313

1414
__all__ = (
1515
'boolean_condition',
16-
'compare_condition',
1716
'inline_conditional',
1817
'mismatched_branches',
1918
'walrus_condition',
@@ -47,23 +46,6 @@ def boolean_condition(value: int, flag: bool) -> int:
4746
# return flag and value > 0
4847

4948

50-
@graph
51-
def compare_condition(value: int) -> int:
52-
"""Compare inline; conditions are calls or names."""
53-
if value > 0:
54-
chosen = add(x=value, y=1)
55-
else:
56-
chosen = add(x=value, y=2)
57-
return chosen
58-
59-
60-
# Compute the predicate in a task instead:
61-
62-
# @task
63-
# def positive(value: int) -> bool:
64-
# return value > 0
65-
66-
6749
@graph
6850
def inline_conditional(value: int, flag: bool) -> int:
6951
"""Select inline; branch explicitly instead."""

‎tests/engine/processes/graphs/language/constructs/conditional/supported.py‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from tests.engine.processes.graphs.language.tasks import add, is_positive
1313

1414
__all__ = (
15+
'compare_condition',
1516
'computed_choice',
1617
'external_choice',
1718
'inline_condition',
@@ -39,6 +40,16 @@ def computed_choice(value: int) -> int:
3940
return selected_branch
4041

4142

43+
@graph
44+
def compare_condition(value: int) -> int:
45+
"""Compare against a bound; the scheduler evaluates the comparison."""
46+
if value > 0:
47+
chosen = add(x=value, y=1)
48+
else:
49+
chosen = add(x=value, y=2)
50+
return chosen
51+
52+
4253
@graph
4354
def inline_condition(value: int) -> int:
4455
"""Branch on an inline predicate call, bound like the explicit form."""

0 commit comments

Comments
 (0)